feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
Generated
+400
-98
@@ -32,6 +32,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
@@ -134,6 +184,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"base64",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
@@ -152,8 +203,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -401,6 +454,46 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
@@ -410,6 +503,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -669,6 +768,12 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deltae"
|
||||
version = "0.3.2"
|
||||
@@ -1624,6 +1729,12 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1937,6 +2048,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -2142,6 +2263,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -2483,6 +2610,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2619,6 +2755,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
@@ -2630,6 +2776,16 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -2639,6 +2795,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
@@ -3249,6 +3414,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
@@ -3849,6 +4025,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3977,6 +4165,22 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -4609,6 +4813,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
@@ -4670,7 +4894,64 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-backend"
|
||||
name = "zesdex-api"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"jsonwebtoken",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-application"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zesdex-domain",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-bootstrap"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-daemon"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
@@ -4678,6 +4959,91 @@ dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"hex",
|
||||
"ignore",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-domain"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-gateway"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zesdex-api",
|
||||
"zesdex-application",
|
||||
"zesdex-daemon",
|
||||
"zesdex-domain",
|
||||
"zesdex-grpc",
|
||||
"zesdex-infrastructure",
|
||||
"zesdex-tui",
|
||||
"zesdex-web",
|
||||
"zesdex-ws",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-grpc"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-infrastructure"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"dom_smoothie",
|
||||
"fast_html2md",
|
||||
"futures-util",
|
||||
@@ -4686,12 +5052,13 @@ dependencies = [
|
||||
"ignore",
|
||||
"include_dir",
|
||||
"infer",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"lsp-types",
|
||||
"nucleo-matcher",
|
||||
"percent-encoding",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
"rand_core 0.6.4",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
@@ -4705,143 +5072,78 @@ dependencies = [
|
||||
"syntect",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"webbrowser",
|
||||
"zesdex-cms",
|
||||
"zesdex-entities",
|
||||
"zesdex-iam",
|
||||
"zesdex-infra",
|
||||
"zesdex-ipc",
|
||||
"zesdex-middleware",
|
||||
"zesdex-utils",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-cms"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-entities"
|
||||
name = "zesdex-tui"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-iam"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hex",
|
||||
"libc",
|
||||
"rand_core 0.6.4",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-infra"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"jsonwebtoken",
|
||||
"rand_core 0.6.4",
|
||||
"nucleo-matcher",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-cms",
|
||||
"zesdex-entities",
|
||||
"zesdex-iam",
|
||||
"zesdex-middleware",
|
||||
"zesdex-utils",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-ipc"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"zesdex-entities",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-middleware"
|
||||
name = "zesdex-web"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"include_dir",
|
||||
"mime_guess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-utils"
|
||||
name = "zesdex-ws"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+25
-10
@@ -1,14 +1,17 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/zesdex-entities",
|
||||
"crates/zesdex-utils",
|
||||
"crates/zesdex-ipc",
|
||||
"crates/zesdex-iam",
|
||||
"crates/zesdex-cms",
|
||||
"crates/zesdex-middleware",
|
||||
"crates/zesdex-infra",
|
||||
"crates/zesdex-backend",
|
||||
"apps/domain",
|
||||
"apps/application",
|
||||
"apps/infrastructure",
|
||||
"apps/interfaces/tui",
|
||||
"apps/interfaces/api",
|
||||
"apps/interfaces/daemon",
|
||||
"apps/interfaces/ws",
|
||||
"apps/interfaces/grpc",
|
||||
"apps/interfaces/web",
|
||||
"apps/gateway",
|
||||
"apps/bootstrap",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -76,6 +79,18 @@ tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "limit"] }
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
zesdex-entities = { path = "crates/zesdex-entities" }
|
||||
zesdex-utils = { path = "crates/zesdex-utils" }
|
||||
# Clean-architecture workspace crate references
|
||||
zesdex-domain = { path = "apps/domain" }
|
||||
zesdex-application = { path = "apps/application" }
|
||||
zesdex-infrastructure = { path = "apps/infrastructure" }
|
||||
zesdex-tui = { path = "apps/interfaces/tui" }
|
||||
zesdex-api = { path = "apps/interfaces/api" }
|
||||
zesdex-daemon = { path = "apps/interfaces/daemon" }
|
||||
zesdex-ws = { path = "apps/interfaces/ws" }
|
||||
zesdex-grpc = { path = "apps/interfaces/grpc" }
|
||||
zesdex-web = { path = "apps/interfaces/web" }
|
||||
zesdex-gateway = { path = "apps/gateway" }
|
||||
zesdex-bootstrap = { path = "apps/bootstrap" }
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
[package]
|
||||
name = "zesdex-iam"
|
||||
name = "zesdex-application"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Application layer — port traits (interfaces), use cases, DTOs.
|
||||
# Depends ONLY on domain. Application services orchestrate domain objects
|
||||
# through port traits without knowing concrete implementations.
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
zesdex-domain = { path = "../domain" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
zesdex-entities = { path = "../zesdex-entities" }
|
||||
zesdex-utils = { path = "../zesdex-utils" }
|
||||
reqwest.workspace = true
|
||||
libc.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
tokio.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
url.workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Auth use-case implementations.
|
||||
//!
|
||||
//! Contains concrete service types that implement the domain's
|
||||
//! authentication and session management traits by coordinating
|
||||
//! injected repository and port dependencies.
|
||||
//!
|
||||
//! # Use Cases
|
||||
//!
|
||||
//! - [`oauth_service`] — `OAuthUseCase`: OAuth 2.0 authorization-code + PKCE flow
|
||||
//! - [`session_service`] — `SessionServiceImpl`: session CRUD lifecycle
|
||||
|
||||
pub mod oauth_service;
|
||||
pub mod session_service;
|
||||
|
||||
pub use oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger};
|
||||
pub use session_service::SessionServiceImpl;
|
||||
@@ -0,0 +1,250 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow use-case.
|
||||
//!
|
||||
//! `OAuthUseCase` orchestrates the standard PKCE-enhanced OAuth flow:
|
||||
//!
|
||||
//! 1. **`start_flow`** — generates a cryptographic PKCE code verifier,
|
||||
//! derives its S256 challenge, creates a CSRF state token, persists
|
||||
//! the verifier + state via `OAuthFlowStore`, and builds an
|
||||
//! authorization URL with all required parameters.
|
||||
//! 2. **`complete_flow`** — validates the returned `state` against the
|
||||
//! stored value (CSRF check), reads the stored verifier, delegates
|
||||
//! the token-code exchange to an injected `TokenExchanger`, and
|
||||
//! persists the resulting `OAuthToken` via `OAuthRepository`.
|
||||
//! 3. **`get_token`** — loads the stored OAuth token (if any).
|
||||
//!
|
||||
//! # Portability
|
||||
//!
|
||||
//! The service is generic over three injected dependencies:
|
||||
//! - `R: OAuthRepository` — token persistence
|
||||
//! - `S: OAuthFlowStore` — ephemeral flow state (verifier + CSRF state)
|
||||
//! - `E: TokenExchanger` — the HTTP token-endpoint exchange
|
||||
//!
|
||||
//! This keeps all I/O and protocol-level concerns abstracted behind
|
||||
//! port traits; the service itself contains only orchestration logic.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::auth::{OAuthConfig, OAuthRepository, OAuthToken, ServiceError};
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port traits (defined here because they are specific to this use-case)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Persistence contract for ephemeral OAuth flow state.
|
||||
///
|
||||
/// Between `start_flow` and `complete_flow` the verifier and CSRF state
|
||||
/// must survive across process boundaries (the user opens a browser, the
|
||||
/// provider redirects back to a loopback listener on the next invocation).
|
||||
///
|
||||
/// Implementors store key-value pairs to disk or another durable medium
|
||||
/// and clear them after a successful (or failed) flow completion.
|
||||
pub trait OAuthFlowStore: Send + Sync {
|
||||
/// Persist the PKCE code verifier and CSRF state token.
|
||||
fn save_flow_state(
|
||||
&self,
|
||||
verifier: &str,
|
||||
state: &str,
|
||||
) -> Result<(), ServiceError>;
|
||||
|
||||
/// Load the stored PKCE code verifier.
|
||||
fn load_verifier(&self) -> Result<String, ServiceError>;
|
||||
|
||||
/// Load the stored CSRF state token.
|
||||
fn load_state(&self) -> Result<String, ServiceError>;
|
||||
|
||||
/// Clear stored flow state (verifier + state).
|
||||
fn clear(&self) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Abstraction for exchanging an authorization code for tokens.
|
||||
///
|
||||
/// Implementors handle the HTTP POST to the provider's token endpoint
|
||||
/// with the appropriate form-encoded parameters, parse the JSON
|
||||
/// response, and return the extracted `OAuthToken`.
|
||||
pub trait TokenExchanger: Send + Sync {
|
||||
/// Exchange an authorization code for an access token.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `token_url` — the provider's token endpoint URL
|
||||
/// - `client_id` — OAuth client identifier
|
||||
/// - `client_secret` — optional client secret
|
||||
/// - `redirect_uri` — must match the URI used in `start_flow`
|
||||
/// - `code` — the authorization code from the provider's redirect
|
||||
/// - `code_verifier` — the PKCE verifier from `start_flow`
|
||||
fn exchange_code(
|
||||
&self,
|
||||
token_url: &str,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a PKCE code-verifier and its S256 code-challenge.
|
||||
///
|
||||
/// Uses 32 cryptographically random bytes, base64url-encoded (no padding)
|
||||
/// for the verifier, then SHA-256 hashes the verifier and base64url-encodes
|
||||
/// the digest for the challenge. This satisfies the PKCE `S256` method
|
||||
/// which requires a minimum verifier length of 43 characters.
|
||||
fn generate_pkce_pair() -> (String, String) {
|
||||
// 32 random bytes → 43 base64url chars (well above the 43-char PKCE
|
||||
// minimum).
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
|
||||
let verifier = URL_SAFE_NO_PAD.encode(&bytes);
|
||||
let challenge = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(hasher.finalize())
|
||||
};
|
||||
(verifier, challenge)
|
||||
}
|
||||
|
||||
/// Generate a random CSRF state token (UUID-based, 36 chars).
|
||||
fn generate_state_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Concrete OAuth flow use-case.
|
||||
///
|
||||
/// Generic over three dependencies:
|
||||
/// - `R` — token persistence (`OAuthRepository`)
|
||||
/// - `S` — flow-state persistence (`OAuthFlowStore`)
|
||||
/// - `E` — token-endpoint HTTP exchange (`TokenExchanger`)
|
||||
pub struct OAuthUseCase<R, S, E> {
|
||||
/// Repository for persisting / loading OAuth tokens.
|
||||
pub token_repo: R,
|
||||
/// Store for ephemeral flow state (verifier + CSRF state).
|
||||
pub flow_store: S,
|
||||
/// Token-endpoint HTTP exchanger.
|
||||
pub token_exchanger: E,
|
||||
/// File path for the token JSON file.
|
||||
pub token_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
|
||||
/// Create a new OAuth use-case.
|
||||
pub fn new(
|
||||
token_repo: R,
|
||||
flow_store: S,
|
||||
token_exchanger: E,
|
||||
token_path: PathBuf,
|
||||
) -> Self {
|
||||
OAuthUseCase {
|
||||
token_repo,
|
||||
flow_store,
|
||||
token_exchanger,
|
||||
token_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
|
||||
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E>
|
||||
{
|
||||
fn start_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(String, String), ServiceError> {
|
||||
if config.auth_url.is_empty() {
|
||||
return Err(ServiceError::InvalidConfig(
|
||||
"OAuth auth_url is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (verifier, challenge) = generate_pkce_pair();
|
||||
let state = generate_state_token();
|
||||
|
||||
// Persist verifier + state so `complete_flow` can retrieve them.
|
||||
self.flow_store.save_flow_state(&verifier, &state)?;
|
||||
|
||||
tracing::debug!(
|
||||
auth_url = %config.auth_url,
|
||||
redirect_uri = %redirect_uri,
|
||||
state_len = state.len(),
|
||||
"starting OAuth flow",
|
||||
);
|
||||
|
||||
let mut url = url::Url::parse(&config.auth_url)
|
||||
.map_err(|e| {
|
||||
ServiceError::InvalidConfig(format!(
|
||||
"invalid auth_url '{}': {e}",
|
||||
config.auth_url
|
||||
))
|
||||
})?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &config.client_id)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("scope", &config.scopes.join(" "))
|
||||
.append_pair("state", &state)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", &challenge);
|
||||
|
||||
Ok((url.to_string(), state))
|
||||
}
|
||||
|
||||
fn complete_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> Result<OAuthToken, ServiceError> {
|
||||
// CSRF check: validate the returned state against the stored value.
|
||||
let expected_state = self.flow_store.load_state()?;
|
||||
if expected_state != state {
|
||||
return Err(ServiceError::StateMismatch);
|
||||
}
|
||||
|
||||
// Read the PKCE verifier that was saved in `start_flow`.
|
||||
let verifier = self.flow_store.load_verifier()?;
|
||||
|
||||
tracing::debug!(
|
||||
token_url = %config.token_url,
|
||||
code_len = code.len(),
|
||||
"completing OAuth flow — exchanging code for token",
|
||||
);
|
||||
|
||||
// Delegate the HTTP token exchange to the injected exchanger.
|
||||
let token = self.token_exchanger.exchange_code(
|
||||
&config.token_url,
|
||||
&config.client_id,
|
||||
config.client_secret.as_deref(),
|
||||
redirect_uri,
|
||||
code,
|
||||
&verifier,
|
||||
)?;
|
||||
|
||||
// Persist the token and clean up flow state.
|
||||
self.token_repo.save_token(&self.token_path, &token)?;
|
||||
let _ = self.flow_store.clear();
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError> {
|
||||
self.token_repo
|
||||
.load_token(&self.token_path)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
}
|
||||
+25
-24
@@ -1,32 +1,30 @@
|
||||
//! Session management use-cases.
|
||||
//! Session management use-case.
|
||||
//!
|
||||
//! `SessionServiceImpl` implements [`SessionService`] by delegating to
|
||||
//! injected repository implementations, keeping the orchestration logic
|
||||
//! independent of any concrete persistence mechanism.
|
||||
//! `SessionServiceImpl` implements [`SessionService`] from the domain
|
||||
//! layer by delegating CRUD operations to injected repository traits.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! - **`create_session`** — generates a UUID v4 id, creates a `Session` entity,
|
||||
//! delegates persistence to `SessionRepository`.
|
||||
//! - **`create_session`** — generates a UUID v4 id, creates a `Session`
|
||||
//! entity with the given title, persists via `SessionRepository`.
|
||||
//! - **`list_all`** — delegates to `SessionRepository::list_sessions`.
|
||||
//! - **`archive_session`** — loads session, sets `archived = true`, persists.
|
||||
//! - **`archive_session`** — loads session, sets `archived = true`,
|
||||
//! persists the updated entity.
|
||||
//!
|
||||
//! # Components
|
||||
//! # Generics
|
||||
//!
|
||||
//! - `SessionServiceImpl<R, L>` — service over two generic repositories
|
||||
//! - `new` / `create_session` / `list_all` / `archive_session` — lifecycle ops
|
||||
//! - `R: SessionRepository` — session CRUD persistence
|
||||
//! - `L: SessionLockRepository` — session lock acquire/release
|
||||
|
||||
use std::path::PathBuf;
|
||||
use zesdex_entities::domain::auth::SessionId;
|
||||
use zesdex_utils::CastOr;
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::error::ServiceError;
|
||||
use crate::domain::repository::{SessionLockRepository, SessionRepository};
|
||||
use crate::domain::service::SessionService;
|
||||
use crate::domain::session::Session;
|
||||
use zesdex_domain::auth::{
|
||||
ServiceError, Session, SessionId, SessionLockRepository, SessionRepository,
|
||||
};
|
||||
|
||||
/// Concrete session service backed by generic repository implementations.
|
||||
/// Concrete session service backed by injected repository implementations.
|
||||
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
|
||||
/// Repository for session CRUD operations.
|
||||
pub session_repo: R,
|
||||
@@ -48,10 +46,12 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionServiceImpl<R, L> {
|
||||
impl<R: SessionRepository, L: SessionLockRepository>
|
||||
zesdex_domain::auth::SessionService for SessionServiceImpl<R, L>
|
||||
{
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
||||
let id = SessionId::new(&Uuid::new_v4().to_string())
|
||||
.expect("UUID is always a valid session id");
|
||||
.map_err(|e| ServiceError::Other(e))?;
|
||||
let title_owned = if title.is_empty() {
|
||||
"New Session".to_string()
|
||||
} else {
|
||||
@@ -60,7 +60,7 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionS
|
||||
let session = Session::new(id.into_string(), title_owned);
|
||||
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
|
||||
self.session_repo
|
||||
.save_session(&self.base_dir, &session)?; // RepositoryError → ServiceError via From
|
||||
.save_session(&self.base_dir, &session)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -73,16 +73,17 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionS
|
||||
|
||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
|
||||
tracing::debug!(session_id = %id, "archiving session");
|
||||
let mut session = self.session_repo
|
||||
.load_session(&self.base_dir, &id)?; // RepositoryError → ServiceError
|
||||
let mut session = self
|
||||
.session_repo
|
||||
.load_session(&self.base_dir, &id)?;
|
||||
session.archived = true;
|
||||
let millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
session.updated_at = millis.cast_or(i64::MAX);
|
||||
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
|
||||
self.session_repo
|
||||
.save_session(&self.base_dir, &session)?; // RepositoryError → ServiceError
|
||||
.save_session(&self.base_dir, &session)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+17
-37
@@ -1,32 +1,25 @@
|
||||
//! Conversation use-case implementations for the CMS.
|
||||
//! Conversation use-case implementation.
|
||||
//!
|
||||
//! `ConversationServiceImpl` implements `ConversationService` (defined in
|
||||
//! `domain::service`) and is generic over `R: ConversationRepository`
|
||||
//! (defined in `domain::repository`), delegating all persistence to that
|
||||
//! adapter. The repository is injected at composition root.
|
||||
//! `ConversationServiceImpl` implements [`ConversationService`] from the
|
||||
//! domain layer. It is generic over `R: ConversationRepository`, delegating
|
||||
//! all persistence to that adapter.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! ## Flow
|
||||
//! Each method computes the session directory from the session ID, then
|
||||
//! delegates the actual I/O to the injected `repo`. Error context is
|
||||
//! added at this layer to identify which session caused the failure.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tracing;
|
||||
|
||||
use crate::domain::conversation::{ChatMessage, Conversation};
|
||||
use crate::domain::error::ServiceError;
|
||||
use crate::domain::repository::ConversationRepository;
|
||||
use crate::domain::service::ConversationService;
|
||||
use zesdex_domain::cms::{Conversation, ConversationRepository, ServiceError};
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
/// Service implementation for conversation CRUD operations.
|
||||
///
|
||||
/// Generic over `R: ConversationRepository` so the persistence layer
|
||||
/// can be swapped without changing business logic.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `repo` — injected conversation repository implementation
|
||||
/// - `sessions_dir` — base path under which session directories live
|
||||
pub struct ConversationServiceImpl<R> {
|
||||
pub repo: R,
|
||||
/// Base directory containing session subdirectories.
|
||||
@@ -35,10 +28,6 @@ pub struct ConversationServiceImpl<R> {
|
||||
|
||||
impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
||||
/// Create a new service with the given repository and sessions directory.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `repo` — the repository adapter to delegate persistence to
|
||||
/// - `sessions_dir` — base path for session directories (converted via `Into`)
|
||||
pub fn new(repo: R, sessions_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating ConversationServiceImpl");
|
||||
Self {
|
||||
@@ -48,26 +37,20 @@ impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
||||
}
|
||||
|
||||
/// Compute the session directory for a given session id.
|
||||
///
|
||||
/// Returns `{sessions_dir}/{session_id}`.
|
||||
fn session_dir(&self, session_id: &str) -> PathBuf {
|
||||
self.sessions_dir.join(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<R> {
|
||||
/// Load a conversation from disk for the given session.
|
||||
///
|
||||
/// Flow: resolve session dir → delegate to repo.load().
|
||||
impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
|
||||
for ConversationServiceImpl<R>
|
||||
{
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
|
||||
tracing::debug!("loading conversation for session {session_id}");
|
||||
let dir = self.session_dir(session_id);
|
||||
self.repo.load(&dir).map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
/// Persist a conversation to disk.
|
||||
///
|
||||
/// Flow: resolve session dir from conv.session_id → delegate to repo.save().
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving conversation for session {}", conv.session_id);
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
@@ -75,16 +58,13 @@ impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a message to a conversation and persist immediately.
|
||||
///
|
||||
/// Flow: push message to in-memory conversation → resolve session dir → delegate save.
|
||||
///
|
||||
/// ## Note
|
||||
/// This is a write-through operation: the message is appended to the
|
||||
/// in-memory `Conversation` and then the full conversation is persisted.
|
||||
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError> {
|
||||
fn add_message(
|
||||
&self,
|
||||
conv: &mut Conversation,
|
||||
msg: ChatMessage,
|
||||
) -> Result<(), ServiceError> {
|
||||
tracing::debug!("adding message to session {}", conv.session_id);
|
||||
conv.push(msg); // append message to in-memory conversation
|
||||
conv.push(msg);
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo.save(&dir, conv)?;
|
||||
Ok(())
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Memory use-case implementation.
|
||||
//!
|
||||
//! `MemoryServiceImpl` implements [`MemoryService`] from the domain
|
||||
//! layer. It is generic over `R: MemoryRepository`, delegating all
|
||||
//! persistence to that adapter.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method delegates to the injected `repo` with the configured
|
||||
//! `memory_dir`. Error context is added at this layer to identify which
|
||||
//! memory operation failed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{Memory, MemoryRepository, ServiceError};
|
||||
|
||||
/// Service implementation for memory CRUD operations.
|
||||
///
|
||||
/// Generic over `R: MemoryRepository` so the persistence layer can be
|
||||
/// swapped without changing business logic.
|
||||
pub struct MemoryServiceImpl<R> {
|
||||
pub repo: R,
|
||||
/// Base directory for memory storage files.
|
||||
pub memory_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> MemoryServiceImpl<R> {
|
||||
/// Create a new service with the given repository and memory directory.
|
||||
pub fn new(repo: R, memory_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating MemoryServiceImpl");
|
||||
Self {
|
||||
repo,
|
||||
memory_dir: memory_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> zesdex_domain::cms::MemoryService for MemoryServiceImpl<R> {
|
||||
fn list_memories(&self) -> Result<Vec<String>, ServiceError> {
|
||||
tracing::debug!("listing memories from {:?}", self.memory_dir);
|
||||
self.repo
|
||||
.list(&self.memory_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving memory '{}'", memory.name);
|
||||
self.repo.save(&self.memory_dir, memory)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_memory(&self, name: &str) -> Result<(), ServiceError> {
|
||||
tracing::debug!("deleting memory '{name}'");
|
||||
self.repo
|
||||
.delete(&self.memory_dir, name)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! CMS use-case implementations.
|
||||
//!
|
||||
//! Contains concrete service types that implement the domain's CMS
|
||||
//! service traits by coordinating injected repository dependencies.
|
||||
//!
|
||||
//! # Use Cases
|
||||
//!
|
||||
//! - [`conversation_service`] — `ConversationServiceImpl`: conversation CRUD
|
||||
//! - [`memory_service`] — `MemoryServiceImpl`: long-term memory management
|
||||
//! - [`settings_service`] — `SettingsServiceImpl`: settings & app-config management
|
||||
|
||||
pub mod conversation_service;
|
||||
pub mod memory_service;
|
||||
pub mod settings_service;
|
||||
|
||||
pub use conversation_service::ConversationServiceImpl;
|
||||
pub use memory_service::MemoryServiceImpl;
|
||||
pub use settings_service::SettingsServiceImpl;
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Settings and app-config use-case implementation.
|
||||
//!
|
||||
//! `SettingsServiceImpl` implements [`SettingsService`] from the domain
|
||||
//! layer. It is generic over `S: SettingsRepository` and `C: AppConfigRepository`,
|
||||
//! delegating persistence to those adapters.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method delegates to the appropriate injected repository with the
|
||||
//! configured `base_dir`. The `update_provider` method coordinates between
|
||||
//! both repositories: load app config → mutate provider map → save app config.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{
|
||||
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings,
|
||||
SettingsRepository,
|
||||
};
|
||||
|
||||
/// Service implementation for settings and app-config operations.
|
||||
///
|
||||
/// Generic over `S: SettingsRepository` and `C: AppConfigRepository` so
|
||||
/// the persistence layer can be swapped without changing business logic.
|
||||
pub struct SettingsServiceImpl<S, C> {
|
||||
pub settings_repo: S,
|
||||
pub app_config_repo: C,
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||
/// Create a new service with the given repositories and base directory.
|
||||
pub fn new(
|
||||
settings_repo: S,
|
||||
app_config_repo: C,
|
||||
base_dir: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
tracing::debug!("creating SettingsServiceImpl");
|
||||
Self {
|
||||
settings_repo,
|
||||
app_config_repo,
|
||||
base_dir: base_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository>
|
||||
zesdex_domain::cms::SettingsService for SettingsServiceImpl<S, C>
|
||||
{
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError> {
|
||||
tracing::debug!("loading settings");
|
||||
self.settings_repo
|
||||
.load(&self.base_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving settings");
|
||||
self.settings_repo.save(&self.base_dir, settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<(), ServiceError> {
|
||||
tracing::debug!("updating provider '{name}'");
|
||||
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
||||
app_config
|
||||
.providers
|
||||
.insert(name.to_string(), config.clone());
|
||||
self.app_config_repo.save(&self.base_dir, &app_config)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! # Zesdex Application Layer
|
||||
//!
|
||||
//! Defines port traits (interfaces) and use-case implementations for the
|
||||
//! Zesdex application. This crate depends **only** on the domain crate;
|
||||
//! it has no knowledge of infrastructure or interface adapters.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/application/src/
|
||||
//! ├── lib.rs — crate root, re-exports
|
||||
//! ├── ports/ — Port traits (interfaces to external services)
|
||||
//! │ ├── provider.rs -- ProviderService (LLM chat completion)
|
||||
//! │ ├── password.rs -- PasswordService (hash / verify)
|
||||
//! │ ├── token.rs -- TokenService (JWT create / verify)
|
||||
//! │ └── authentication.rs -- AuthService (combined auth)
|
||||
//! ├── auth/ — Auth use-cases
|
||||
//! │ ├── oauth_service.rs -- OAuth 2.0 PKCE flow
|
||||
//! │ └── session_service.rs -- Session CRUD lifecycle
|
||||
//! └── cms/ — CMS use-cases
|
||||
//! ├── conversation_service.rs -- Conversation CRUD
|
||||
//! ├── memory_service.rs -- Long-term memory management
|
||||
//! └── settings_service.rs -- Settings & app-config management
|
||||
//! ```
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//!
|
||||
//! Application services are generic over their repository/port dependencies.
|
||||
//! Concrete implementations are injected at the composition root, keeping
|
||||
//! the use-case logic independent of any specific persistence or infrastructure
|
||||
//! technology.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cms;
|
||||
pub mod ports;
|
||||
|
||||
// Re-export port traits for ergonomic access.
|
||||
pub use ports::*;
|
||||
|
||||
// Re-export auth use-cases.
|
||||
pub use auth::{
|
||||
oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger},
|
||||
session_service::SessionServiceImpl,
|
||||
};
|
||||
|
||||
// Re-export CMS use-cases.
|
||||
pub use cms::{
|
||||
conversation_service::ConversationServiceImpl,
|
||||
memory_service::MemoryServiceImpl,
|
||||
settings_service::SettingsServiceImpl,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
//! AuthService port — combined authentication operations.
|
||||
//!
|
||||
//! Defines a high-level authentication trait that composes password
|
||||
//! verification and token generation into a single use-case boundary.
|
||||
//! Implementations delegate to the injected `PasswordService` and
|
||||
//! `TokenService` adapters.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
/// High-level authentication service combining password verification
|
||||
/// and token issuance (login flow).
|
||||
///
|
||||
/// # Flow
|
||||
///
|
||||
/// 1. **`authenticate`** — verify a subject's password against a stored hash.
|
||||
/// 2. **`issue_tokens`** — generate an access + refresh token pair for a subject.
|
||||
///
|
||||
/// Implementations are generic over `PasswordService` and `TokenService`
|
||||
/// port traits.
|
||||
pub trait AuthService: Send + Sync {
|
||||
/// Authenticate a user by verifying a password against a stored hash.
|
||||
///
|
||||
/// Returns `true` if the password matches, `false` otherwise.
|
||||
fn authenticate(
|
||||
&self,
|
||||
password: &str,
|
||||
hash: &str,
|
||||
) -> impl Future<Output = Result<bool>> + Send;
|
||||
|
||||
/// Issue a new access + refresh token pair for the given subject.
|
||||
///
|
||||
/// Returns `(access_token, refresh_token)`.
|
||||
fn issue_tokens(&self, sub: &str) -> Result<(String, String)>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Port traits — interfaces for external / infrastructure services.
|
||||
//!
|
||||
//! These traits define the boundaries between the application layer and
|
||||
//! the outside world. Infrastructure adapters implement these traits;
|
||||
//! the application layer depends only on the trait definitions.
|
||||
//!
|
||||
//! # Ports
|
||||
//!
|
||||
//! - [`provider`] — `ProviderService`: LLM chat completion (streaming + non-streaming)
|
||||
//! - [`password`] — `PasswordService`: password hashing and verification
|
||||
//! - [`token`] — `TokenService`: JWT access/refresh token generation and verification
|
||||
//! - [`authentication`] — `AuthService`: combined authentication operations
|
||||
|
||||
pub mod authentication;
|
||||
pub mod password;
|
||||
pub mod provider;
|
||||
pub mod token;
|
||||
|
||||
pub use authentication::AuthService;
|
||||
pub use password::PasswordService;
|
||||
pub use provider::ProviderService;
|
||||
pub use token::TokenService;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! PasswordService port — password hashing and verification abstraction.
|
||||
//!
|
||||
//! Defines the trait that password-hashing adapters (argon2, bcrypt, etc.)
|
||||
//! implement. The application layer depends only on this trait, never on
|
||||
//! a concrete hashing library.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
/// Abstraction for password hashing and verification.
|
||||
///
|
||||
/// Implementors handle the actual hashing algorithm (argon2, bcrypt, etc.)
|
||||
/// and parameter selection. The trait is `Send + Sync` for use in async
|
||||
/// service layers.
|
||||
pub trait PasswordService: Send + Sync {
|
||||
/// Hash a plaintext password and return the encoded hash string
|
||||
/// (suitable for storage in a credential store).
|
||||
fn hash(&self, password: &str) -> impl Future<Output = Result<String>> + Send;
|
||||
|
||||
/// Verify a plaintext password against a previously-hashed string.
|
||||
///
|
||||
/// Returns `true` if the password matches the hash, `false` otherwise.
|
||||
fn verify(&self, password: &str, hash: &str) -> impl Future<Output = Result<bool>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! ProviderService port — LLM chat completion provider abstraction.
|
||||
//!
|
||||
//! Defines the trait that HTTP-based provider clients (OpenAI, Anthropic,
|
||||
//! etc.) implement. Supports both non-streaming and SSE-streaming chat
|
||||
//! completion requests.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. Caller builds a message list and optional tool definitions.
|
||||
//! 2. `chat` sends a non-streaming request and returns the full response.
|
||||
//! 3. `chat_stream` sends a streaming request and invokes `on_event` for
|
||||
//! each parsed `StreamEvent` as it arrives, then returns the assembled
|
||||
//! message and usage.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||
|
||||
/// Abstraction for an LLM provider chat-completion service.
|
||||
///
|
||||
/// Both methods accept a message list, optional tool definitions, and
|
||||
/// generation parameters. Implementors handle authentication, HTTP
|
||||
/// transport, retry logic, and response parsing internally.
|
||||
///
|
||||
/// # Send + Sync
|
||||
///
|
||||
/// This trait is `Send + Sync` so it can be shared across async tasks
|
||||
/// and injected into service structs that require thread safety.
|
||||
pub trait ProviderService: Send + Sync {
|
||||
/// Send a non-streaming chat completion request.
|
||||
///
|
||||
/// Returns the assistant's `ChatMessage` and optional token usage
|
||||
/// `(prompt_tokens, completion_tokens)`.
|
||||
fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
|
||||
|
||||
/// Send a streaming chat completion request.
|
||||
///
|
||||
/// `on_event` is called for every parsed SSE event and returns `false`
|
||||
/// to signal abort (caller cancellation). Returns the fully assembled
|
||||
/// assistant message and optional usage once the stream completes.
|
||||
fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
on_event: Box<dyn FnMut(&StreamEvent) -> bool + Send>,
|
||||
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! TokenService port — JWT access and refresh token abstraction.
|
||||
//!
|
||||
//! Defines the trait that JWT adapter implementations provide. Covers
|
||||
//! token generation (pair of access + refresh tokens) and access token
|
||||
//! verification (returns the subject claim).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Abstraction for JWT-based token generation and verification.
|
||||
///
|
||||
/// Implementors handle signing key management, token serialisation,
|
||||
/// and expiry validation. The trait is `Send + Sync` for use across
|
||||
/// thread boundaries.
|
||||
pub trait TokenService: Send + Sync {
|
||||
/// Generate an access + refresh token pair for the given subject
|
||||
/// identifier.
|
||||
///
|
||||
/// Returns `(access_token, refresh_token)`.
|
||||
fn generate_tokens(&self, sub: &str) -> Result<(String, String)>;
|
||||
|
||||
/// Verify an access token and return the embedded subject claim.
|
||||
///
|
||||
/// Returns `Err` if the token is expired, malformed, or has an
|
||||
/// invalid signature.
|
||||
fn verify_access_token(&self, token: &str) -> Result<String>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "zesdex-bootstrap"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Bootstrap binary — seeds initial system data (permissions, roles,
|
||||
# admin user) idempotently. Run once after first deployment.
|
||||
[[bin]]
|
||||
name = "bootstrap"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
zesdex-application = { path = "../application" }
|
||||
zesdex-infrastructure = { path = "../infrastructure" }
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
dirs.workspace = true
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Bootstrap library — shared utilities for the bootstrap binary.
|
||||
//! The main entry point is in `main.rs`.
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Bootstrap binary — seeds initial system data idempotently.
|
||||
//!
|
||||
//! Creates default permissions, roles, and admin user if they don't
|
||||
//! already exist. Run once after first deployment.
|
||||
//!
|
||||
//! Usage: cargo run --bin bootstrap
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
println!("Zesdex Bootstrap — seeding initial data...");
|
||||
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
// Seed default settings if not present
|
||||
let settings_path = store.base_dir.join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
let settings = zesdex_domain::cms::Settings::default();
|
||||
let content = serde_json::to_string_pretty(&settings)?;
|
||||
std::fs::write(&settings_path, content)?;
|
||||
println!(" ✓ Default settings created");
|
||||
} else {
|
||||
println!(" · Settings already exist, skipping");
|
||||
}
|
||||
|
||||
// Seed default app config if not present
|
||||
let config_path = store.base_dir.join("app_config.json");
|
||||
if !config_path.exists() {
|
||||
let config = zesdex_domain::cms::AppConfig::default();
|
||||
let content = serde_json::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_path, content)?;
|
||||
println!(" ✓ Default app_config created");
|
||||
} else {
|
||||
println!(" · App config already exists, skipping");
|
||||
}
|
||||
|
||||
println!("Bootstrap complete.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
[package]
|
||||
name = "zesdex-entities"
|
||||
name = "zesdex-domain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Domain layer — PURE entities, value objects, repository/service traits.
|
||||
# Zero framework dependencies. Only serde for serialization, chrono for
|
||||
# timestamps, uuid for identity.
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
dirs.workspace = true
|
||||
libc.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
reqwest.workspace = true
|
||||
tokio.workspace = true
|
||||
libc.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
zesdex-utils.workspace = true
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Domain error types for the IAM (auth) module.
|
||||
//!
|
||||
//! Typed error enums replace `anyhow::Result` in domain traits and
|
||||
//! application services, enabling callers to match on specific error
|
||||
//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (config, state
|
||||
//! mismatch, provider failures)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for IAM persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the IAM domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided configuration is invalid.
|
||||
InvalidConfig(String),
|
||||
/// OAuth state mismatch — possible CSRF attack.
|
||||
StateMismatch,
|
||||
/// The OAuth provider returned an error.
|
||||
OAuthProvider(String),
|
||||
/// A generic error with a message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
ServiceError::Repository(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
|
||||
ServiceError::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"),
|
||||
ServiceError::StateMismatch => {
|
||||
write!(f, "OAuth state mismatch — possible CSRF attack")
|
||||
}
|
||||
ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {msg}"),
|
||||
ServiceError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ServiceError::Repository(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! IAM Session re-export.
|
||||
//!
|
||||
//! Re-exports `Session` from the auth module for consistent IAM-boundary
|
||||
//! imports. Consumers of the IAM module import `Session` from here rather
|
||||
//! than from the core session module directly, keeping the dependency
|
||||
//! internal and allowing the IAM crate to own its domain vocabulary.
|
||||
|
||||
pub use super::session::Session;
|
||||
|
||||
/// Alias for `Session` used in IAM contexts to distinguish from other
|
||||
/// session types in the system.
|
||||
pub type IamSession = Session;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Authentication domain entities, commands, errors, and repository/service traits.
|
||||
//!
|
||||
//! Combines the session types from `zesdex-entities` (auth sub-module) with the
|
||||
//! IAM domain types (commands, OAuth, repository/service traits) from `zesdex-iam`.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`session`] — `Session` entity (session metadata)
|
||||
//! - [`session_id`] — `SessionId` value object (validated newtype)
|
||||
//! - [`session_lock`] — `SessionLock` RAII guard (PID-file lock)
|
||||
//! - [`oauth`] — `OAuthToken`, `OAuthConfig` entities
|
||||
//! - [`iam_session`] — Re-export of `Session` for IAM-boundary consistency
|
||||
//! - [`commands`] — `NewSession` command type
|
||||
//! - [`error`] — `RepositoryError`, `ServiceError` types
|
||||
//! - [`repository`] — `SessionRepository`, `SessionLockRepository`, `OAuthRepository`
|
||||
//! - [`service`] — `SessionService`, `OAuthService` traits
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod iam_session;
|
||||
pub mod oauth;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
pub mod session_id;
|
||||
pub mod session_lock;
|
||||
|
||||
pub use commands::NewSession;
|
||||
pub use error::{RepositoryError, ServiceError};
|
||||
pub use iam_session::IamSession;
|
||||
pub use oauth::{OAuthConfig, OAuthToken};
|
||||
pub use repository::{OAuthRepository, SessionLockRepository, SessionRepository};
|
||||
pub use service::{OAuthService, SessionService};
|
||||
pub use session::Session;
|
||||
pub use session_id::SessionId;
|
||||
pub use session_lock::SessionLock;
|
||||
@@ -9,13 +9,13 @@
|
||||
//! - [`SessionRepository`] — CRUD for session metadata
|
||||
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
|
||||
//! - [`OAuthRepository`] — persist/load OAuth tokens
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_entities::domain::auth::SessionId;
|
||||
|
||||
use crate::domain::error::RepositoryError;
|
||||
use crate::domain::oauth::OAuthToken;
|
||||
use crate::domain::session::Session;
|
||||
use crate::auth::error::RepositoryError;
|
||||
use crate::auth::oauth::OAuthToken;
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Repository for loading, saving, listing, and deleting sessions.
|
||||
pub trait SessionRepository {
|
||||
@@ -2,17 +2,17 @@
|
||||
//! and OAuth flows.
|
||||
//!
|
||||
//! These traits define the boundary between the application orchestration
|
||||
//! layer and the domain. Implementations live in `application/`.
|
||||
//! layer and the domain. Implementations live in the application layer.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionService`] — create, list, archive sessions
|
||||
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
|
||||
use zesdex_entities::domain::auth::SessionId;
|
||||
|
||||
use crate::domain::error::ServiceError;
|
||||
use crate::domain::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::domain::session::Session;
|
||||
use crate::auth::error::ServiceError;
|
||||
use crate::auth::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Session management use-case boundary.
|
||||
pub trait SessionService {
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||
//! persisted as `session.json` per session directory.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Created via [`Session::new`] → mutated in-memory → persisted via repository.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Session` struct — fields for all session metadata
|
||||
//! - `new` — timestamped constructor
|
||||
//! - `session_dir` / `conversation_path` — pure path computation
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata for one conversation session (distinct from the message
|
||||
/// history itself, which lives in `Conversation`/the msglog).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
/// Unique session identifier (validated against path traversal in `load`).
|
||||
pub id: String,
|
||||
/// Epoch-millis timestamp of creation (`Utc::now().timestamp_millis()`).
|
||||
pub created_at: i64,
|
||||
/// Epoch-millis timestamp of last update.
|
||||
pub updated_at: i64,
|
||||
/// Human-readable title for the conversation.
|
||||
pub title: String,
|
||||
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Workspace root directories associated with this session.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Running count of messages in the conversation.
|
||||
pub message_count: u32,
|
||||
/// Running count of tokens consumed.
|
||||
pub token_count: u32,
|
||||
/// Soft-delete flag — archived sessions are hidden from the default list.
|
||||
pub archived: bool,
|
||||
/// Optional AI-generated conversation summary (used for compact context).
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session with the given id/title, defaulting the
|
||||
/// model, workspace root (current dir), and counters.
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Session {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
/// Compute this session's `conversation.json` path.
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
}
|
||||
-8
@@ -27,14 +27,6 @@ impl SessionId {
|
||||
///
|
||||
/// Returns `Err(msg)` if the input contains path separators, `..`, or
|
||||
/// is empty.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// # use zesdex_entities::domain::auth::session_id::SessionId;
|
||||
/// let sid = SessionId::new("abc-123_def").unwrap();
|
||||
/// assert!(SessionId::new("../evil").is_err());
|
||||
/// ```
|
||||
pub fn new(id: &str) -> Result<Self, String> {
|
||||
if id.is_empty() {
|
||||
return Err("session id must not be empty".to_string());
|
||||
+42
-38
@@ -5,17 +5,15 @@
|
||||
//!
|
||||
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
|
||||
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
|
||||
//! owning PID is checked via `kill(pid, 0)` + `/proc/<pid>/exe` verification.
|
||||
//! Stale locks are overwritten atomically (temp-file + rename + fsync).
|
||||
//! On [`Drop`], the lock file is removed automatically.
|
||||
//! owning PID is checked via liveness verification. Stale locks are
|
||||
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
|
||||
//! the lock file is removed automatically.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
|
||||
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
|
||||
//! - `unlock` / `Drop` — explicit and implicit release
|
||||
//! - `is_alive` — liveness check via `libc::kill` + `/proc` verification
|
||||
use std::convert::TryInto;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -26,9 +24,9 @@ use tracing;
|
||||
#[derive(Debug)]
|
||||
pub struct SessionLock {
|
||||
/// Path to the `.lock` file inside the session directory.
|
||||
path: PathBuf,
|
||||
pub(crate) path: PathBuf,
|
||||
/// Process ID that holds (or will hold) this lock.
|
||||
pid: u32,
|
||||
pub(crate) pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
@@ -45,14 +43,9 @@ impl SessionLock {
|
||||
///
|
||||
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
||||
/// succeeds, the lock is ours — write our PID and return ok. If the
|
||||
/// file already exists, read the PID inside it and check `is_alive`:
|
||||
/// if that process is still running, fail to acquire; otherwise the
|
||||
/// lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Why: `create_new(true)` is atomic on POSIX (unlike the previous
|
||||
/// read-then-write pattern which had a TOCTOU race between checking
|
||||
/// `path.exists()` and writing). The stale-lock recovery path reads
|
||||
/// the stale PID and verifies liveness via `kill(pid, 0)`.
|
||||
/// file already exists, read the PID inside it and check whether that
|
||||
/// PID is still alive: if the process is still running, fail to acquire;
|
||||
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
@@ -111,33 +104,44 @@ impl SessionLock {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive and
|
||||
/// is actually a zesdex process (not a recycled PID from a different
|
||||
/// program).
|
||||
/// Check whether a process with the given PID is currently alive.
|
||||
///
|
||||
/// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production;
|
||||
/// here we provide a best-effort check using the process table.
|
||||
/// On non-Unix platforms this always returns `true` (conservative).
|
||||
fn is_alive(pid: u32) -> bool {
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal
|
||||
// it. The integer argument is a PID already validated by `try_lock`.
|
||||
// PIDs on Linux fit in i32 (default pid_max ≈ 4 million).
|
||||
let pid_signed: i32 = pid.try_into()
|
||||
.expect("PID exceeds i32 range — kernel pid_max > 2^31");
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
// Extra check: verify the PID belongs to a zesdex process via
|
||||
// /proc/<pid>/exe to mitigate the PID-reuse race (a recycled PID
|
||||
// from a different program would answer kill but shouldn't hold
|
||||
// our lock). This is best-effort — /proc may not be available
|
||||
// on all platforms.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
// On Unix, signal 0 checks process existence without sending a signal.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal it.
|
||||
// The integer argument is a PID validated by `try_lock`.
|
||||
let pid_signed: i32 = match pid.try_into() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
// Extra check: verify the PID belongs to a zesdex process via
|
||||
// /proc/<pid>/exe to mitigate the PID-reuse race.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Fallback: always assume alive (conservative).
|
||||
let _ = pid;
|
||||
true
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,11 @@ impl SettingsPatch {
|
||||
"Off" => InternetMode::Off,
|
||||
"ReadOnly" => InternetMode::ReadOnly,
|
||||
"Full" => InternetMode::Full,
|
||||
_ => return Err(format!("invalid internet_mode '{val}'; expected Off, ReadOnly, or Full")),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"invalid internet_mode '{val}'; expected Off, ReadOnly, or Full"
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(ref val) = self.provider {
|
||||
@@ -1,15 +1,15 @@
|
||||
//! Pure domain entity for conversations and chat messages.
|
||||
//!
|
||||
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
|
||||
//! types from `zesdex_entities` to provide a consistent domain import
|
||||
//! boundary within the `zesdex-cms` crate. All CMS code references
|
||||
//! conversation types through this module rather than depending on the
|
||||
//! entities crate directly.
|
||||
//! types from the core module to provide a consistent domain import
|
||||
//! boundary within the CMS module. All CMS code references conversation
|
||||
//! types through this module rather than depending on the core module
|
||||
//! directly.
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//! - `Conversation` — top-level conversation container with message list
|
||||
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
||||
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
||||
|
||||
pub use zesdex_entities::domain::common::message::{ChatMessage, Role};
|
||||
pub use zesdex_entities::domain::common::conversation::Conversation;
|
||||
pub use crate::core::message::{ChatMessage, Role};
|
||||
pub use crate::core::conversation::Conversation;
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Domain error types for the CMS module.
|
||||
//!
|
||||
//! Typed error enums for repository and service operations.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (invalid input, generic)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for CMS persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the CMS domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided input is invalid.
|
||||
InvalidInput(String),
|
||||
/// A generic error with a message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
ServiceError::Repository(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
|
||||
ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
|
||||
ServiceError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ServiceError::Repository(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,12 +57,6 @@ impl Memory {
|
||||
/// collapse/trim repeated `-`.
|
||||
///
|
||||
/// Returns `None` if the result is empty or exceeds 80 characters.
|
||||
///
|
||||
/// ## Example
|
||||
/// ```
|
||||
/// # use zesdex_cms::domain::memory::Memory;
|
||||
/// assert_eq!(Memory::slugify("Hello World!").unwrap(), "hello-world");
|
||||
/// ```
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
// Phase 1: replace every non-alphanumeric character with '-'
|
||||
let slug: String = s
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Domain layer — pure entities, value objects, repository traits, and service interfaces.
|
||||
//! Domain layer for CMS — pure entities, value objects, repository traits,
|
||||
//! and service interfaces.
|
||||
//!
|
||||
//! This is the innermost layer of the Clean Architecture onion. It has **zero
|
||||
//! infrastructure dependencies** — all I/O is expressed through repository
|
||||
@@ -7,7 +8,7 @@
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`)
|
||||
//! - `conversation` — conversation entity + chat message model (`Conversation`, `ChatMessage`)
|
||||
//! - `conversation` — conversation entity + chat message model (re-exported from core)
|
||||
//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`)
|
||||
//! - `memory` — memory file model (`Memory`)
|
||||
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
|
||||
@@ -34,15 +35,18 @@ pub use app_config::ProviderConfig;
|
||||
pub use conversation::Conversation;
|
||||
pub use edit_log::EditLog;
|
||||
pub use edit_log::EditLogEntry;
|
||||
pub use error::{RepositoryError, ServiceError};
|
||||
pub use memory::Memory;
|
||||
pub use repository::AppConfigRepository;
|
||||
pub use repository::ConversationRepository;
|
||||
pub use repository::EditLogRepository;
|
||||
pub use repository::MemoryRepository;
|
||||
pub use repository::RewindBlobRepository;
|
||||
pub use repository::SettingsRepository;
|
||||
pub use service::ConversationService;
|
||||
pub use service::MemoryService;
|
||||
pub use service::SettingsService;
|
||||
pub use commands::{NewMemory, SettingsPatch};
|
||||
pub use settings::InternetMode;
|
||||
pub use settings::Settings;
|
||||
pub use settings::SettingsFlags;
|
||||
@@ -56,7 +56,11 @@ pub trait ConversationRepository {
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
||||
|
||||
/// Persist a `Conversation` to the given session directory.
|
||||
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError>;
|
||||
fn save(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
conversation: &Conversation,
|
||||
) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Memory` (long-term agent memory entries).
|
||||
@@ -91,7 +95,11 @@ pub trait RewindBlobRepository {
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Retrieve a blob's raw bytes by key, or `None` if not found.
|
||||
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>, RepositoryError>;
|
||||
fn retrieve_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>, RepositoryError>;
|
||||
|
||||
/// List all blob keys for this session, ordered oldest-first.
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
@@ -106,7 +114,12 @@ pub trait EditLogRepository {
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
|
||||
|
||||
/// Append one entry to the log and persist immediately (write-through).
|
||||
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<(), RepositoryError>;
|
||||
fn append(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
log: &mut EditLog,
|
||||
entry: EditLogEntry,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Return a cloned copy of all in-memory entries for inspection.
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
||||
@@ -28,7 +28,11 @@ pub trait SettingsService {
|
||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
|
||||
|
||||
/// Update (or insert) a provider configuration entry.
|
||||
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) -> Result<(), ServiceError>;
|
||||
fn update_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
config: &super::app_config::ProviderConfig,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for conversation (session message) management.
|
||||
@@ -40,7 +44,11 @@ pub trait ConversationService {
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
||||
|
||||
/// Append a single `ChatMessage` to the conversation and persist.
|
||||
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError>;
|
||||
fn add_message(
|
||||
&self,
|
||||
conv: &mut Conversation,
|
||||
msg: ChatMessage,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for long-term memory management.
|
||||
+1
-41
@@ -5,18 +5,14 @@
|
||||
//!
|
||||
//! [`Conversation::new`] → [`push`](Conversation::push) to add messages →
|
||||
//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM
|
||||
//! API (system prompt prepended). Persisted via [`save_conversation`](Conversation::save_conversation)
|
||||
//! and loaded via [`load_conversation`](Conversation::load_conversation).
|
||||
//! The system prompt can be hot-swapped via [`rebuild_system`](Conversation::rebuild_system).
|
||||
//! API (system prompt prepended).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Conversation` — message vector + session metadata + generation params
|
||||
//! - `push` / `rebuild_system` — mutation helpers
|
||||
//! - `to_api_messages` — formats messages for API consumption
|
||||
//! - `save_conversation` / `load_conversation` — filesystem persistence
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing;
|
||||
|
||||
use super::message::{ChatMessage, Role};
|
||||
|
||||
@@ -89,40 +85,4 @@ impl Conversation {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// Persist the conversation to a JSON file at the given base directory.
|
||||
///
|
||||
/// Flow: compute path from `session_id` → ensure directory exists →
|
||||
/// atomically write pretty-printed JSON via `write_json_atomic`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `anyhow::Error` from any step.
|
||||
pub fn save_conversation(&self, base_dir: &std::path::Path) -> anyhow::Result<()> {
|
||||
let dir = base_dir.join("sessions").join(&self.session_id);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("conversation.json");
|
||||
tracing::debug!(session_id = %self.session_id, path = %path.display(), "saving conversation");
|
||||
zesdex_utils::write_json_atomic(&path, self, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a conversation from a JSON file for the given session id.
|
||||
///
|
||||
/// Flow: read `<base_dir>/sessions/<session_id>/conversation.json` →
|
||||
/// JSON-parse.
|
||||
///
|
||||
/// Return: the parsed `Conversation`, or an `io::Error` if the file is
|
||||
/// missing or malformed.
|
||||
pub fn load_conversation(
|
||||
session_id: &str,
|
||||
base_dir: &std::path::Path,
|
||||
) -> std::io::Result<Self> {
|
||||
let path = base_dir
|
||||
.join("sessions")
|
||||
.join(session_id)
|
||||
.join("conversation.json");
|
||||
tracing::debug!(session_id = %session_id, path = %path.display(), "loading conversation");
|
||||
let data = std::fs::read_to_string(&path)?;
|
||||
let conv: Conversation = serde_json::from_str(&data)?;
|
||||
Ok(conv)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Common entity types shared across the Zesdex application.
|
||||
//! Core domain entities shared across the Zesdex application.
|
||||
//!
|
||||
//! Contains pure data structures for conversations, messages, tool calls,
|
||||
//! usage statistics, provider API types (chat request/response, SSE stream),
|
||||
//! and store path configuration. All types derive `Serialize`/`Deserialize`
|
||||
//! and are persisted as JSON files.
|
||||
//! usage statistics, provider API types, and store path configuration.
|
||||
//! All types derive `Serialize`/`Deserialize` for JSON persistence.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
@@ -27,8 +26,8 @@ pub mod usage;
|
||||
pub use conversation::Conversation;
|
||||
pub use message::{ChatMessage, Role};
|
||||
pub use provider::{
|
||||
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||
ToolFunctionDef,
|
||||
ChatRequest, ChatResponse, Choice, Delta, SseParser, StreamEvent, StreamOptions, TokenUsage,
|
||||
ToolDef, ToolFunctionDef,
|
||||
};
|
||||
pub use store::Store;
|
||||
pub use tool_call::{ToolCall, ToolFunction};
|
||||
@@ -40,9 +40,13 @@ impl Store {
|
||||
///
|
||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||
pub fn new() -> Self {
|
||||
let base = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
||||
.join("zesdex");
|
||||
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
|
||||
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
|
||||
{
|
||||
PathBuf::from(data_dir).join("zesdex")
|
||||
} else {
|
||||
PathBuf::from(".local/share/zesdex")
|
||||
};
|
||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||
Store {
|
||||
memory_dir: base.join("memory"),
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Shared domain error types for the entire domain layer.
|
||||
//!
|
||||
//! Provides [`DomainError`] — a unified repository-level error enum used
|
||||
//! by both the `auth` and `cms` modules (type-aliased as `RepositoryError`
|
||||
//! in each module). This avoids a dependency on `thiserror` while still
|
||||
//! giving callers distinct error variants to match on.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Infrastructure adapters convert their native errors (I/O, serde, etc.)
|
||||
//! into `DomainError` via `From` impls. Domain service layers wrap
|
||||
//! `DomainError` in their own `ServiceError` enum via `From`.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `DomainError` — 6 variants: `NotFound`, `Conflict`, `Io`, `Serde`,
|
||||
//! `InvalidId`, `Other`
|
||||
//! - `From<std::io::Error>` — converts I/O errors
|
||||
//! - `From<serde_json::Error>` — converts serialisation errors
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Unified repository-level error for domain operations.
|
||||
///
|
||||
/// Covers the common failure modes across all persistence adapters:
|
||||
/// missing entities, conflicts, I/O failures, serialization errors,
|
||||
/// invalid identifiers, and a catch-all `Other` variant.
|
||||
#[derive(Debug)]
|
||||
pub enum DomainError {
|
||||
/// The requested entity was not found.
|
||||
NotFound(String),
|
||||
/// An operation failed due to a conflict (e.g. duplicate key).
|
||||
Conflict(String),
|
||||
/// An I/O error occurred during persistence.
|
||||
Io(std::io::Error),
|
||||
/// A serialization / deserialization error occurred.
|
||||
Serde(String),
|
||||
/// An identifier was rejected as invalid (e.g. path traversal).
|
||||
InvalidId(String),
|
||||
/// A generic / uncategorised error.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for DomainError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DomainError::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||
DomainError::Conflict(msg) => write!(f, "conflict: {msg}"),
|
||||
DomainError::Io(err) => write!(f, "I/O error: {err}"),
|
||||
DomainError::Serde(msg) => write!(f, "serialization error: {msg}"),
|
||||
DomainError::InvalidId(msg) => write!(f, "invalid id: {msg}"),
|
||||
DomainError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DomainError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
DomainError::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for DomainError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
DomainError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for DomainError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
DomainError::Serde(err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! # Zesdex Domain Layer
|
||||
//!
|
||||
//! Pure domain entities, value objects, repository traits, and service traits
|
||||
//! for the Zesdex application. This crate has **zero framework dependencies**
|
||||
//! — it depends only on serialization (`serde`), timestamping (`chrono`),
|
||||
//! identity (`uuid`), and a few other narrowly-scoped utilities.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/domain
|
||||
//! ├── core/ Shared domain entities (Conversation, Message, Provider,
|
||||
//! │ Store, ToolCall, ToolResult, Usage)
|
||||
//! ├── auth/ Authentication domain (Session, SessionId, SessionLock,
|
||||
//! │ OAuth, commands, errors, repository/service traits)
|
||||
//! ├── cms/ CMS domain (AppConfig, Conversation, EditLog, Memory,
|
||||
//! │ Settings, commands, errors, repository/service traits)
|
||||
//! └── error.rs Unified DomainError type
|
||||
//! ```
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//!
|
||||
//! All types are pure Rust structs and enums with `serde` derives. No I/O,
|
||||
//! no framework imports, no side effects. All persistence is expressed
|
||||
//! through repository traits that infrastructure adapters implement.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cms;
|
||||
pub mod core;
|
||||
pub mod error;
|
||||
|
||||
// Re-export all public items from each module for ergonomic imports.
|
||||
// Consumers can do `use zesdex_domain::*` for common types.
|
||||
pub use auth::{
|
||||
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService,
|
||||
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session,
|
||||
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService,
|
||||
};
|
||||
pub use cms::{
|
||||
AppConfig, AppConfigRepository, Conversation as CmsConversation,
|
||||
ConversationRepository, ConversationService, EditLog, EditLogEntry,
|
||||
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService,
|
||||
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError,
|
||||
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch,
|
||||
SettingsRepository, SettingsService,
|
||||
};
|
||||
pub use core::{
|
||||
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role,
|
||||
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall,
|
||||
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
|
||||
};
|
||||
pub use error::DomainError;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user