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",
|
"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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
@@ -134,6 +184,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"axum-macros",
|
"axum-macros",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -152,8 +203,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -401,6 +454,46 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -410,6 +503,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -669,6 +768,12 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "deltae"
|
name = "deltae"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1624,6 +1729,12 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1937,6 +2048,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2142,6 +2263,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.81"
|
version = "0.10.81"
|
||||||
@@ -2483,6 +2610,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
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]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2619,6 +2755,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -2630,6 +2776,16 @@ dependencies = [
|
|||||||
"rand_core 0.10.1",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -2639,6 +2795,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -3249,6 +3414,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha1_smol"
|
name = "sha1_smol"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -3849,6 +4025,18 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3977,6 +4165,22 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -4609,6 +4813,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"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]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
@@ -4670,7 +4894,64 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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"
|
version = "1.15.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -4678,6 +4959,91 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"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",
|
"dom_smoothie",
|
||||||
"fast_html2md",
|
"fast_html2md",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -4686,12 +5052,13 @@ dependencies = [
|
|||||||
"ignore",
|
"ignore",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"infer",
|
"infer",
|
||||||
|
"jsonwebtoken",
|
||||||
"libc",
|
"libc",
|
||||||
"lsp-types",
|
"lsp-types",
|
||||||
"nucleo-matcher",
|
"nucleo-matcher",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"ratatui",
|
"rand_core 0.6.4",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
@@ -4705,143 +5072,78 @@ dependencies = [
|
|||||||
"syntect",
|
"syntect",
|
||||||
"tiktoken-rs",
|
"tiktoken-rs",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
"zesdex-cms",
|
"zesdex-application",
|
||||||
"zesdex-entities",
|
"zesdex-domain",
|
||||||
"zesdex-iam",
|
|
||||||
"zesdex-infra",
|
|
||||||
"zesdex-ipc",
|
|
||||||
"zesdex-middleware",
|
|
||||||
"zesdex-utils",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-cms"
|
name = "zesdex-tui"
|
||||||
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"
|
|
||||||
version = "1.15.2"
|
version = "1.15.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"crossterm",
|
||||||
"dirs",
|
"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",
|
"hex",
|
||||||
"libc",
|
"nucleo-matcher",
|
||||||
"rand_core 0.6.4",
|
"pulldown-cmark",
|
||||||
"reqwest",
|
"ratatui",
|
||||||
"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",
|
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tiktoken-rs",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zesdex-cms",
|
"zesdex-application",
|
||||||
"zesdex-entities",
|
"zesdex-domain",
|
||||||
"zesdex-iam",
|
"zesdex-infrastructure",
|
||||||
"zesdex-middleware",
|
|
||||||
"zesdex-utils",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-ipc"
|
name = "zesdex-web"
|
||||||
version = "1.15.2"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tracing",
|
|
||||||
"zesdex-entities",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zesdex-middleware"
|
|
||||||
version = "1.15.2"
|
version = "1.15.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"include_dir",
|
||||||
|
"mime_guess",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tracing",
|
||||||
"zesdex-entities",
|
"uuid",
|
||||||
"zesdex-utils",
|
"zesdex-application",
|
||||||
|
"zesdex-domain",
|
||||||
|
"zesdex-infrastructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-utils"
|
name = "zesdex-ws"
|
||||||
version = "1.15.2"
|
version = "1.15.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"futures-util",
|
||||||
"hex",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"tokio",
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"uuid",
|
||||||
|
"zesdex-application",
|
||||||
|
"zesdex-domain",
|
||||||
|
"zesdex-infrastructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+25
-10
@@ -1,14 +1,17 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/zesdex-entities",
|
"apps/domain",
|
||||||
"crates/zesdex-utils",
|
"apps/application",
|
||||||
"crates/zesdex-ipc",
|
"apps/infrastructure",
|
||||||
"crates/zesdex-iam",
|
"apps/interfaces/tui",
|
||||||
"crates/zesdex-cms",
|
"apps/interfaces/api",
|
||||||
"crates/zesdex-middleware",
|
"apps/interfaces/daemon",
|
||||||
"crates/zesdex-infra",
|
"apps/interfaces/ws",
|
||||||
"crates/zesdex-backend",
|
"apps/interfaces/grpc",
|
||||||
|
"apps/interfaces/web",
|
||||||
|
"apps/gateway",
|
||||||
|
"apps/bootstrap",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -76,6 +79,18 @@ tower = "0.5"
|
|||||||
tower-http = { version = "0.6", features = ["cors", "limit"] }
|
tower-http = { version = "0.6", features = ["cors", "limit"] }
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
|
||||||
zesdex-entities = { path = "crates/zesdex-entities" }
|
# Clean-architecture workspace crate references
|
||||||
zesdex-utils = { path = "crates/zesdex-utils" }
|
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]
|
[package]
|
||||||
name = "zesdex-iam"
|
name = "zesdex-application"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.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]
|
[dependencies]
|
||||||
thiserror.workspace = true
|
zesdex-domain = { path = "../domain" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
anyhow.workspace = true
|
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
zesdex-entities = { path = "../zesdex-entities" }
|
anyhow.workspace = true
|
||||||
zesdex-utils = { path = "../zesdex-utils" }
|
|
||||||
reqwest.workspace = true
|
|
||||||
libc.workspace = true
|
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
url.workspace = true
|
tokio.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hex.workspace = true
|
url.workspace = true
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
|
||||||
@@ -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
|
//! `SessionServiceImpl` implements [`SessionService`] from the domain
|
||||||
//! injected repository implementations, keeping the orchestration logic
|
//! layer by delegating CRUD operations to injected repository traits.
|
||||||
//! independent of any concrete persistence mechanism.
|
|
||||||
//!
|
//!
|
||||||
//! # Flow
|
//! # Flow
|
||||||
//!
|
//!
|
||||||
//! - **`create_session`** — generates a UUID v4 id, creates a `Session` entity,
|
//! - **`create_session`** — generates a UUID v4 id, creates a `Session`
|
||||||
//! delegates persistence to `SessionRepository`.
|
//! entity with the given title, persists via `SessionRepository`.
|
||||||
//! - **`list_all`** — delegates to `SessionRepository::list_sessions`.
|
//! - **`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
|
//! - `R: SessionRepository` — session CRUD persistence
|
||||||
//! - `new` / `create_session` / `list_all` / `archive_session` — lifecycle ops
|
//! - `L: SessionLockRepository` — session lock acquire/release
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use zesdex_entities::domain::auth::SessionId;
|
|
||||||
use zesdex_utils::CastOr;
|
|
||||||
use tracing;
|
use tracing;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::domain::error::ServiceError;
|
use zesdex_domain::auth::{
|
||||||
use crate::domain::repository::{SessionLockRepository, SessionRepository};
|
ServiceError, Session, SessionId, SessionLockRepository, SessionRepository,
|
||||||
use crate::domain::service::SessionService;
|
};
|
||||||
use crate::domain::session::Session;
|
|
||||||
|
|
||||||
/// Concrete session service backed by generic repository implementations.
|
/// Concrete session service backed by injected repository implementations.
|
||||||
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
|
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
|
||||||
/// Repository for session CRUD operations.
|
/// Repository for session CRUD operations.
|
||||||
pub session_repo: R,
|
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> {
|
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
||||||
let id = SessionId::new(&Uuid::new_v4().to_string())
|
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() {
|
let title_owned = if title.is_empty() {
|
||||||
"New Session".to_string()
|
"New Session".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -60,7 +60,7 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionS
|
|||||||
let session = Session::new(id.into_string(), title_owned);
|
let session = Session::new(id.into_string(), title_owned);
|
||||||
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
|
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
|
||||||
self.session_repo
|
self.session_repo
|
||||||
.save_session(&self.base_dir, &session)?; // RepositoryError → ServiceError via From
|
.save_session(&self.base_dir, &session)?;
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +73,17 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionS
|
|||||||
|
|
||||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
|
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
|
||||||
tracing::debug!(session_id = %id, "archiving session");
|
tracing::debug!(session_id = %id, "archiving session");
|
||||||
let mut session = self.session_repo
|
let mut session = self
|
||||||
.load_session(&self.base_dir, &id)?; // RepositoryError → ServiceError
|
.session_repo
|
||||||
|
.load_session(&self.base_dir, &id)?;
|
||||||
session.archived = true;
|
session.archived = true;
|
||||||
let millis = std::time::SystemTime::now()
|
let millis = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis();
|
.as_millis();
|
||||||
session.updated_at = millis.cast_or(i64::MAX);
|
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
|
||||||
self.session_repo
|
self.session_repo
|
||||||
.save_session(&self.base_dir, &session)?; // RepositoryError → ServiceError
|
.save_session(&self.base_dir, &session)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-37
@@ -1,32 +1,25 @@
|
|||||||
//! Conversation use-case implementations for the CMS.
|
//! Conversation use-case implementation.
|
||||||
//!
|
//!
|
||||||
//! `ConversationServiceImpl` implements `ConversationService` (defined in
|
//! `ConversationServiceImpl` implements [`ConversationService`] from the
|
||||||
//! `domain::service`) and is generic over `R: ConversationRepository`
|
//! domain layer. It is generic over `R: ConversationRepository`, delegating
|
||||||
//! (defined in `domain::repository`), delegating all persistence to that
|
//! all persistence to that adapter.
|
||||||
//! adapter. The repository is injected at composition root.
|
//!
|
||||||
|
//! # Flow
|
||||||
//!
|
//!
|
||||||
//! ## Flow
|
|
||||||
//! Each method computes the session directory from the session ID, then
|
//! Each method computes the session directory from the session ID, then
|
||||||
//! delegates the actual I/O to the injected `repo`. Error context is
|
//! delegates the actual I/O to the injected `repo`. Error context is
|
||||||
//! added at this layer to identify which session caused the failure.
|
//! added at this layer to identify which session caused the failure.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use tracing;
|
use tracing;
|
||||||
|
|
||||||
use crate::domain::conversation::{ChatMessage, Conversation};
|
use zesdex_domain::cms::{Conversation, ConversationRepository, ServiceError};
|
||||||
use crate::domain::error::ServiceError;
|
use zesdex_domain::core::ChatMessage;
|
||||||
use crate::domain::repository::ConversationRepository;
|
|
||||||
use crate::domain::service::ConversationService;
|
|
||||||
|
|
||||||
/// Service implementation for conversation CRUD operations.
|
/// Service implementation for conversation CRUD operations.
|
||||||
///
|
///
|
||||||
/// Generic over `R: ConversationRepository` so the persistence layer
|
/// Generic over `R: ConversationRepository` so the persistence layer
|
||||||
/// can be swapped without changing business logic.
|
/// 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 struct ConversationServiceImpl<R> {
|
||||||
pub repo: R,
|
pub repo: R,
|
||||||
/// Base directory containing session subdirectories.
|
/// Base directory containing session subdirectories.
|
||||||
@@ -35,10 +28,6 @@ pub struct ConversationServiceImpl<R> {
|
|||||||
|
|
||||||
impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
||||||
/// Create a new service with the given repository and sessions directory.
|
/// 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 {
|
pub fn new(repo: R, sessions_dir: impl Into<PathBuf>) -> Self {
|
||||||
tracing::debug!("creating ConversationServiceImpl");
|
tracing::debug!("creating ConversationServiceImpl");
|
||||||
Self {
|
Self {
|
||||||
@@ -48,26 +37,20 @@ impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the session directory for a given session id.
|
/// Compute the session directory for a given session id.
|
||||||
///
|
|
||||||
/// Returns `{sessions_dir}/{session_id}`.
|
|
||||||
fn session_dir(&self, session_id: &str) -> PathBuf {
|
fn session_dir(&self, session_id: &str) -> PathBuf {
|
||||||
self.sessions_dir.join(session_id)
|
self.sessions_dir.join(session_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<R> {
|
impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
|
||||||
/// Load a conversation from disk for the given session.
|
for ConversationServiceImpl<R>
|
||||||
///
|
{
|
||||||
/// Flow: resolve session dir → delegate to repo.load().
|
|
||||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
|
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
|
||||||
tracing::debug!("loading conversation for session {session_id}");
|
tracing::debug!("loading conversation for session {session_id}");
|
||||||
let dir = self.session_dir(session_id);
|
let dir = self.session_dir(session_id);
|
||||||
self.repo.load(&dir).map_err(ServiceError::Repository)
|
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> {
|
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> {
|
||||||
tracing::debug!("saving conversation for session {}", conv.session_id);
|
tracing::debug!("saving conversation for session {}", conv.session_id);
|
||||||
let dir = self.session_dir(&conv.session_id);
|
let dir = self.session_dir(&conv.session_id);
|
||||||
@@ -75,16 +58,13 @@ impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a message to a conversation and persist immediately.
|
fn add_message(
|
||||||
///
|
&self,
|
||||||
/// Flow: push message to in-memory conversation → resolve session dir → delegate save.
|
conv: &mut Conversation,
|
||||||
///
|
msg: ChatMessage,
|
||||||
/// ## Note
|
) -> Result<(), ServiceError> {
|
||||||
/// 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> {
|
|
||||||
tracing::debug!("adding message to session {}", conv.session_id);
|
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);
|
let dir = self.session_dir(&conv.session_id);
|
||||||
self.repo.save(&dir, conv)?;
|
self.repo.save(&dir, conv)?;
|
||||||
Ok(())
|
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]
|
[package]
|
||||||
name = "zesdex-entities"
|
name = "zesdex-domain"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.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]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
anyhow.workspace = true
|
|
||||||
dirs.workspace = true
|
|
||||||
libc.workspace = true
|
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
reqwest.workspace = true
|
libc.workspace = true
|
||||||
tokio.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.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
|
//! - [`SessionRepository`] — CRUD for session metadata
|
||||||
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
|
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
|
||||||
//! - [`OAuthRepository`] — persist/load OAuth tokens
|
//! - [`OAuthRepository`] — persist/load OAuth tokens
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use zesdex_entities::domain::auth::SessionId;
|
use crate::auth::error::RepositoryError;
|
||||||
|
use crate::auth::oauth::OAuthToken;
|
||||||
use crate::domain::error::RepositoryError;
|
use crate::auth::session::Session;
|
||||||
use crate::domain::oauth::OAuthToken;
|
use crate::auth::session_id::SessionId;
|
||||||
use crate::domain::session::Session;
|
|
||||||
|
|
||||||
/// Repository for loading, saving, listing, and deleting sessions.
|
/// Repository for loading, saving, listing, and deleting sessions.
|
||||||
pub trait SessionRepository {
|
pub trait SessionRepository {
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
//! and OAuth flows.
|
//! and OAuth flows.
|
||||||
//!
|
//!
|
||||||
//! These traits define the boundary between the application orchestration
|
//! 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
|
//! # Traits
|
||||||
//!
|
//!
|
||||||
//! - [`SessionService`] — create, list, archive sessions
|
//! - [`SessionService`] — create, list, archive sessions
|
||||||
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
|
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
|
||||||
use zesdex_entities::domain::auth::SessionId;
|
|
||||||
|
|
||||||
use crate::domain::error::ServiceError;
|
use crate::auth::error::ServiceError;
|
||||||
use crate::domain::oauth::{OAuthConfig, OAuthToken};
|
use crate::auth::oauth::{OAuthConfig, OAuthToken};
|
||||||
use crate::domain::session::Session;
|
use crate::auth::session::Session;
|
||||||
|
use crate::auth::session_id::SessionId;
|
||||||
|
|
||||||
/// Session management use-case boundary.
|
/// Session management use-case boundary.
|
||||||
pub trait SessionService {
|
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
|
/// Returns `Err(msg)` if the input contains path separators, `..`, or
|
||||||
/// is empty.
|
/// 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> {
|
pub fn new(id: &str) -> Result<Self, String> {
|
||||||
if id.is_empty() {
|
if id.is_empty() {
|
||||||
return Err("session id must not be empty".to_string());
|
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
|
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
|
||||||
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
|
//! 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.
|
//! owning PID is checked via liveness verification. Stale locks are
|
||||||
//! Stale locks are overwritten atomically (temp-file + rename + fsync).
|
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
|
||||||
//! On [`Drop`], the lock file is removed automatically.
|
//! the lock file is removed automatically.
|
||||||
//!
|
//!
|
||||||
//! # Components
|
//! # Components
|
||||||
//!
|
//!
|
||||||
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
|
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
|
||||||
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
|
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
|
||||||
//! - `unlock` / `Drop` — explicit and implicit release
|
//! - `unlock` / `Drop` — explicit and implicit release
|
||||||
//! - `is_alive` — liveness check via `libc::kill` + `/proc` verification
|
|
||||||
use std::convert::TryInto;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -26,9 +24,9 @@ use tracing;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SessionLock {
|
pub struct SessionLock {
|
||||||
/// Path to the `.lock` file inside the session directory.
|
/// Path to the `.lock` file inside the session directory.
|
||||||
path: PathBuf,
|
pub(crate) path: PathBuf,
|
||||||
/// Process ID that holds (or will hold) this lock.
|
/// Process ID that holds (or will hold) this lock.
|
||||||
pid: u32,
|
pub(crate) pid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionLock {
|
impl SessionLock {
|
||||||
@@ -45,14 +43,9 @@ impl SessionLock {
|
|||||||
///
|
///
|
||||||
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
/// 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
|
/// 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`:
|
/// file already exists, read the PID inside it and check whether that
|
||||||
/// if that process is still running, fail to acquire; otherwise the
|
/// PID is still alive: if the process is still running, fail to acquire;
|
||||||
/// lock is stale — overwrite it with our own PID and succeed.
|
/// 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)`.
|
|
||||||
///
|
///
|
||||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||||
/// process holds it, `Err` on I/O failure.
|
/// process holds it, `Err` on I/O failure.
|
||||||
@@ -111,33 +104,44 @@ impl SessionLock {
|
|||||||
let _ = fs::remove_file(&self.path);
|
let _ = fs::remove_file(&self.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether a process with the given PID is currently alive and
|
/// Check whether a process with the given PID is currently alive.
|
||||||
/// is actually a zesdex process (not a recycled PID from a different
|
///
|
||||||
/// program).
|
/// 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 {
|
fn is_alive(pid: u32) -> bool {
|
||||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
// On Unix, signal 0 checks process existence without sending a signal.
|
||||||
// whether the process exists and the caller has permission to signal
|
#[cfg(unix)]
|
||||||
// it. The integer argument is a PID already validated by `try_lock`.
|
{
|
||||||
// PIDs on Linux fit in i32 (default pid_max ≈ 4 million).
|
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||||
let pid_signed: i32 = pid.try_into()
|
// whether the process exists and the caller has permission to signal it.
|
||||||
.expect("PID exceeds i32 range — kernel pid_max > 2^31");
|
// The integer argument is a PID validated by `try_lock`.
|
||||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
let pid_signed: i32 = match pid.try_into() {
|
||||||
return false;
|
Ok(p) => p,
|
||||||
}
|
Err(_) => 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
|
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||||
// from a different program would answer kill but shouldn't hold
|
return false;
|
||||||
// our lock). This is best-effort — /proc may not be available
|
}
|
||||||
// on all platforms.
|
// Extra check: verify the PID belongs to a zesdex process via
|
||||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
// /proc/<pid>/exe to mitigate the PID-reuse race.
|
||||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||||
if target != exe {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
return false;
|
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,
|
"Off" => InternetMode::Off,
|
||||||
"ReadOnly" => InternetMode::ReadOnly,
|
"ReadOnly" => InternetMode::ReadOnly,
|
||||||
"Full" => InternetMode::Full,
|
"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 {
|
if let Some(ref val) = self.provider {
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
//! Pure domain entity for conversations and chat messages.
|
//! Pure domain entity for conversations and chat messages.
|
||||||
//!
|
//!
|
||||||
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
|
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
|
||||||
//! types from `zesdex_entities` to provide a consistent domain import
|
//! types from the core module to provide a consistent domain import
|
||||||
//! boundary within the `zesdex-cms` crate. All CMS code references
|
//! boundary within the CMS module. All CMS code references conversation
|
||||||
//! conversation types through this module rather than depending on the
|
//! types through this module rather than depending on the core module
|
||||||
//! entities crate directly.
|
//! directly.
|
||||||
//!
|
//!
|
||||||
//! ## Re-exports
|
//! ## Re-exports
|
||||||
//! - `Conversation` — top-level conversation container with message list
|
//! - `Conversation` — top-level conversation container with message list
|
||||||
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
||||||
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
||||||
|
|
||||||
pub use zesdex_entities::domain::common::message::{ChatMessage, Role};
|
pub use crate::core::message::{ChatMessage, Role};
|
||||||
pub use zesdex_entities::domain::common::conversation::Conversation;
|
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 `-`.
|
/// collapse/trim repeated `-`.
|
||||||
///
|
///
|
||||||
/// Returns `None` if the result is empty or exceeds 80 characters.
|
/// 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> {
|
pub fn slugify(s: &str) -> Option<String> {
|
||||||
// Phase 1: replace every non-alphanumeric character with '-'
|
// Phase 1: replace every non-alphanumeric character with '-'
|
||||||
let slug: String = s
|
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
|
//! This is the innermost layer of the Clean Architecture onion. It has **zero
|
||||||
//! infrastructure dependencies** — all I/O is expressed through repository
|
//! infrastructure dependencies** — all I/O is expressed through repository
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Sub-modules
|
//! ## Sub-modules
|
||||||
//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`)
|
//! - `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`)
|
//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`)
|
||||||
//! - `memory` — memory file model (`Memory`)
|
//! - `memory` — memory file model (`Memory`)
|
||||||
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
|
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
|
||||||
@@ -34,15 +35,18 @@ pub use app_config::ProviderConfig;
|
|||||||
pub use conversation::Conversation;
|
pub use conversation::Conversation;
|
||||||
pub use edit_log::EditLog;
|
pub use edit_log::EditLog;
|
||||||
pub use edit_log::EditLogEntry;
|
pub use edit_log::EditLogEntry;
|
||||||
|
pub use error::{RepositoryError, ServiceError};
|
||||||
pub use memory::Memory;
|
pub use memory::Memory;
|
||||||
pub use repository::AppConfigRepository;
|
pub use repository::AppConfigRepository;
|
||||||
pub use repository::ConversationRepository;
|
pub use repository::ConversationRepository;
|
||||||
pub use repository::EditLogRepository;
|
pub use repository::EditLogRepository;
|
||||||
pub use repository::MemoryRepository;
|
pub use repository::MemoryRepository;
|
||||||
|
pub use repository::RewindBlobRepository;
|
||||||
pub use repository::SettingsRepository;
|
pub use repository::SettingsRepository;
|
||||||
pub use service::ConversationService;
|
pub use service::ConversationService;
|
||||||
pub use service::MemoryService;
|
pub use service::MemoryService;
|
||||||
pub use service::SettingsService;
|
pub use service::SettingsService;
|
||||||
|
pub use commands::{NewMemory, SettingsPatch};
|
||||||
pub use settings::InternetMode;
|
pub use settings::InternetMode;
|
||||||
pub use settings::Settings;
|
pub use settings::Settings;
|
||||||
pub use settings::SettingsFlags;
|
pub use settings::SettingsFlags;
|
||||||
@@ -56,7 +56,11 @@ pub trait ConversationRepository {
|
|||||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
||||||
|
|
||||||
/// Persist a `Conversation` to the given session directory.
|
/// 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).
|
/// Persistence contract for `Memory` (long-term agent memory entries).
|
||||||
@@ -91,7 +95,11 @@ pub trait RewindBlobRepository {
|
|||||||
) -> Result<(), RepositoryError>;
|
) -> Result<(), RepositoryError>;
|
||||||
|
|
||||||
/// Retrieve a blob's raw bytes by key, or `None` if not found.
|
/// 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.
|
/// List all blob keys for this session, ordered oldest-first.
|
||||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
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>;
|
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
|
||||||
|
|
||||||
/// Append one entry to the log and persist immediately (write-through).
|
/// 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.
|
/// Return a cloned copy of all in-memory entries for inspection.
|
||||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
||||||
@@ -28,7 +28,11 @@ pub trait SettingsService {
|
|||||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
|
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
|
||||||
|
|
||||||
/// Update (or insert) a provider configuration entry.
|
/// 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.
|
/// Use-cases for conversation (session message) management.
|
||||||
@@ -40,7 +44,11 @@ pub trait ConversationService {
|
|||||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
||||||
|
|
||||||
/// Append a single `ChatMessage` to the conversation and persist.
|
/// 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.
|
/// Use-cases for long-term memory management.
|
||||||
+1
-41
@@ -5,18 +5,14 @@
|
|||||||
//!
|
//!
|
||||||
//! [`Conversation::new`] → [`push`](Conversation::push) to add messages →
|
//! [`Conversation::new`] → [`push`](Conversation::push) to add messages →
|
||||||
//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM
|
//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM
|
||||||
//! API (system prompt prepended). Persisted via [`save_conversation`](Conversation::save_conversation)
|
//! API (system prompt prepended).
|
||||||
//! and loaded via [`load_conversation`](Conversation::load_conversation).
|
|
||||||
//! The system prompt can be hot-swapped via [`rebuild_system`](Conversation::rebuild_system).
|
|
||||||
//!
|
//!
|
||||||
//! # Components
|
//! # Components
|
||||||
//!
|
//!
|
||||||
//! - `Conversation` — message vector + session metadata + generation params
|
//! - `Conversation` — message vector + session metadata + generation params
|
||||||
//! - `push` / `rebuild_system` — mutation helpers
|
//! - `push` / `rebuild_system` — mutation helpers
|
||||||
//! - `to_api_messages` — formats messages for API consumption
|
//! - `to_api_messages` — formats messages for API consumption
|
||||||
//! - `save_conversation` / `load_conversation` — filesystem persistence
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing;
|
|
||||||
|
|
||||||
use super::message::{ChatMessage, Role};
|
use super::message::{ChatMessage, Role};
|
||||||
|
|
||||||
@@ -89,40 +85,4 @@ impl Conversation {
|
|||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.messages.is_empty()
|
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,
|
//! Contains pure data structures for conversations, messages, tool calls,
|
||||||
//! usage statistics, provider API types (chat request/response, SSE stream),
|
//! usage statistics, provider API types, and store path configuration.
|
||||||
//! and store path configuration. All types derive `Serialize`/`Deserialize`
|
//! All types derive `Serialize`/`Deserialize` for JSON persistence.
|
||||||
//! and are persisted as JSON files.
|
|
||||||
//!
|
//!
|
||||||
//! # Sub-modules
|
//! # Sub-modules
|
||||||
//!
|
//!
|
||||||
@@ -27,8 +26,8 @@ pub mod usage;
|
|||||||
pub use conversation::Conversation;
|
pub use conversation::Conversation;
|
||||||
pub use message::{ChatMessage, Role};
|
pub use message::{ChatMessage, Role};
|
||||||
pub use provider::{
|
pub use provider::{
|
||||||
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef,
|
ChatRequest, ChatResponse, Choice, Delta, SseParser, StreamEvent, StreamOptions, TokenUsage,
|
||||||
ToolFunctionDef,
|
ToolDef, ToolFunctionDef,
|
||||||
};
|
};
|
||||||
pub use store::Store;
|
pub use store::Store;
|
||||||
pub use tool_call::{ToolCall, ToolFunction};
|
pub use tool_call::{ToolCall, ToolFunction};
|
||||||
@@ -40,9 +40,13 @@ impl Store {
|
|||||||
///
|
///
|
||||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let base = dirs::data_dir()
|
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
|
||||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
|
||||||
.join("zesdex");
|
{
|
||||||
|
PathBuf::from(data_dir).join("zesdex")
|
||||||
|
} else {
|
||||||
|
PathBuf::from(".local/share/zesdex")
|
||||||
|
};
|
||||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||||
Store {
|
Store {
|
||||||
memory_dir: base.join("memory"),
|
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;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[package]
|
||||||
|
name = "zesdex-gateway"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
# Gateway binary — assembles domain + application + infrastructure
|
||||||
|
# + selected interface(s) into a running application process.
|
||||||
|
# This is the main entry point that wires everything together.
|
||||||
|
[[bin]]
|
||||||
|
name = "zesdex"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "seed"
|
||||||
|
path = "src/bin/seed.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "migrate"
|
||||||
|
path = "src/bin/migrate.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
zesdex-domain = { path = "../domain" }
|
||||||
|
zesdex-application = { path = "../application" }
|
||||||
|
zesdex-infrastructure = { path = "../infrastructure" }
|
||||||
|
zesdex-tui = { path = "../interfaces/tui" }
|
||||||
|
zesdex-api = { path = "../interfaces/api" }
|
||||||
|
zesdex-daemon = { path = "../interfaces/daemon" }
|
||||||
|
zesdex-ws = { path = "../interfaces/ws" }
|
||||||
|
zesdex-grpc = { path = "../interfaces/grpc" }
|
||||||
|
zesdex-web = { path = "../interfaces/web" }
|
||||||
|
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
@@ -1,42 +1,15 @@
|
|||||||
//! Database migration binary for zesdex-backend.
|
//! Database migration binary.
|
||||||
//!
|
//!
|
||||||
//! Scans all session directories under the store path and initializes or
|
//! Scans all session directories and initializes or upgrades the SQLite
|
||||||
//! upgrades the SQLite schema (`messages.sqlite`) for each one. This is
|
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
|
||||||
//! a standalone CLI tool invoked as `cargo run --bin migrate`.
|
|
||||||
//!
|
|
||||||
//! ## Workflow
|
|
||||||
//! 1. Resolve the base store directory via `Store::new()`
|
|
||||||
//! 2. Iterate over each subdirectory under `sessions/`
|
|
||||||
//! 3. For each session directory, call `migrate_session_msglog()` to
|
|
||||||
//! create/upgrade the `messages.sqlite` schema
|
|
||||||
//! 4. Report count of succeeded and failed migrations
|
|
||||||
//! 5. Exit with error if any session failed
|
|
||||||
//!
|
|
||||||
//! ## Schema
|
|
||||||
//! - `messages` table — stores conversation message rows
|
|
||||||
//! - `archives` table — stores session archive metadata
|
|
||||||
//! - `blobs` table — stores binary blob data per session
|
|
||||||
//! - Indexes on `session_id`, `created_at`, and `role` columns
|
|
||||||
//!
|
|
||||||
//! ## Versioning
|
|
||||||
//! SQLite `PRAGMA user_version` tracks schema version for incremental upgrades.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
||||||
/// Entry point: migrate all session databases.
|
|
||||||
///
|
|
||||||
/// Flow: load store → iterate sessions → migrate each → summarise.
|
|
||||||
///
|
|
||||||
/// Returns an error if any session migration failed.
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
tracing::info!("starting database migration");
|
let store = zesdex_domain::core::Store::new();
|
||||||
let store = zesdex_entities::domain::common::store::Store::new();
|
|
||||||
|
|
||||||
// Resolve the sessions directory under the store base path
|
|
||||||
let sessions_dir = store.base_dir.join("sessions");
|
let sessions_dir = store.base_dir.join("sessions");
|
||||||
|
|
||||||
if !sessions_dir.exists() {
|
if !sessions_dir.exists() {
|
||||||
tracing::info!("no sessions directory found at {:?}", sessions_dir);
|
|
||||||
eprintln!("No sessions directory found, nothing to migrate");
|
eprintln!("No sessions directory found, nothing to migrate");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -44,29 +17,25 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let mut migrated = 0u32;
|
let mut migrated = 0u32;
|
||||||
let mut failed = 0u32;
|
let mut failed = 0u32;
|
||||||
|
|
||||||
// Iterate over all session subdirectories
|
|
||||||
for entry in std::fs::read_dir(&sessions_dir)? {
|
for entry in std::fs::read_dir(&sessions_dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
continue; // skip non-directory entries
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match migrate_session_msglog(&path) {
|
match migrate_session_msglog(&path) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
migrated += 1;
|
migrated += 1;
|
||||||
tracing::info!("migrated session: {:?}", path.file_name());
|
|
||||||
eprintln!("Migrated session: {:?}", path.file_name());
|
eprintln!("Migrated session: {:?}", path.file_name());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
tracing::error!("failed to migrate session {:?}: {e}", path.file_name());
|
|
||||||
eprintln!("Failed to migrate session {:?}: {e}", path.file_name());
|
eprintln!("Failed to migrate session {:?}: {e}", path.file_name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("migration complete: {migrated} succeeded, {failed} failed");
|
|
||||||
eprintln!("Migration complete: {migrated} succeeded, {failed} failed");
|
eprintln!("Migration complete: {migrated} succeeded, {failed} failed");
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
anyhow::bail!("{failed} session(s) failed to migrate");
|
anyhow::bail!("{failed} session(s) failed to migrate");
|
||||||
@@ -74,18 +43,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open (or create) a session's `messages.sqlite` and ensure its schema is current.
|
|
||||||
///
|
|
||||||
/// Flow: resolve path → open/ create DB → set PRAGMAs → create tables → upgrade version.
|
|
||||||
///
|
|
||||||
/// ## Parameters
|
|
||||||
/// - `session_dir`: path to the individual session directory
|
|
||||||
///
|
|
||||||
/// ## Returns
|
|
||||||
/// - `Ok(())` on success
|
|
||||||
/// - `Err` if file I/O or SQLite operations fail
|
|
||||||
fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
||||||
tracing::debug!("migrating session at {:?}", session_dir);
|
|
||||||
let msglog_path = session_dir.join("messages.sqlite");
|
let msglog_path = session_dir.join("messages.sqlite");
|
||||||
|
|
||||||
if let Some(parent) = msglog_path.parent() {
|
if let Some(parent) = msglog_path.parent() {
|
||||||
@@ -95,12 +53,9 @@ fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
|||||||
let conn = rusqlite::Connection::open(&msglog_path)?;
|
let conn = rusqlite::Connection::open(&msglog_path)?;
|
||||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||||
|
|
||||||
// Initialize schema
|
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"CREATE TABLE IF NOT EXISTS messages (
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
@@ -132,11 +87,9 @@ fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
|||||||
mime_type TEXT,
|
mime_type TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
UNIQUE(session_id, blob_key)
|
UNIQUE(session_id, blob_key)
|
||||||
);
|
);",
|
||||||
",
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Check and upgrade schema version
|
|
||||||
let version: i32 = conn
|
let version: i32 = conn
|
||||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
//! Database seeder binary.
|
||||||
|
//!
|
||||||
|
//! Initialises the store directory structure and creates default
|
||||||
|
//! configuration files plus a seed session for development/testing.
|
||||||
|
//! Invoked as `cargo run --bin seed`.
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let store = zesdex_domain::core::Store::new();
|
||||||
|
store.ensure_dirs()?;
|
||||||
|
|
||||||
|
// Create 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)?;
|
||||||
|
let tmp = store.base_dir.join("settings.json.tmp");
|
||||||
|
std::fs::write(&tmp, content)?;
|
||||||
|
let f = std::fs::File::open(&tmp)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, settings_path)?;
|
||||||
|
println!("Default settings created");
|
||||||
|
} else {
|
||||||
|
println!("Settings already exist, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 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)?;
|
||||||
|
let tmp = store.base_dir.join("app_config.json.tmp");
|
||||||
|
std::fs::write(&tmp, content)?;
|
||||||
|
let f = std::fs::File::open(&tmp)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, config_path)?;
|
||||||
|
println!("Default app_config created");
|
||||||
|
} else {
|
||||||
|
println!("App config already exists, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data directories
|
||||||
|
std::fs::create_dir_all(&store.memory_dir)?;
|
||||||
|
std::fs::create_dir_all(&store.session_images_dir)?;
|
||||||
|
println!("All store directories verified");
|
||||||
|
|
||||||
|
// Create a seed session
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let session = zesdex_domain::auth::Session::new(
|
||||||
|
session_id.clone(),
|
||||||
|
"Seed Session".to_string(),
|
||||||
|
);
|
||||||
|
// Persist via the session repository
|
||||||
|
use zesdex_domain::SessionRepository;
|
||||||
|
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
|
||||||
|
repo.save_session(&store.base_dir, &session)?;
|
||||||
|
println!("Seed session created: id={session_id}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
//! Gateway library — provides shared utilities for the gateway binary.
|
||||||
|
//! The main entry point is in `main.rs`.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Zesdex Gateway — main entry point.
|
||||||
|
//!
|
||||||
|
//! Assembles domain + application + infrastructure layers and dispatches
|
||||||
|
//! to the requested interface: TUI (default), daemon (background IPC),
|
||||||
|
//! API server (REST), WebSocket server, gRPC server, or Web frontend.
|
||||||
|
//!
|
||||||
|
//! # CLI flags
|
||||||
|
//!
|
||||||
|
//! | Flag | Description |
|
||||||
|
//! |------|-------------|
|
||||||
|
//! | `--daemon` | Run as background daemon with IPC socket |
|
||||||
|
//! | `--attach <id>` | Attach TUI client to a running daemon |
|
||||||
|
//! | `--api` | Run REST API server |
|
||||||
|
//! | `--api-port <port>` | REST API port (default 8080) |
|
||||||
|
//! | `--ws` | Run WebSocket server |
|
||||||
|
//! | `--ws-port <port>` | WebSocket port (default 8081) |
|
||||||
|
//! | `--grpc` | Run gRPC server |
|
||||||
|
//! | `--grpc-port <port>` | gRPC port (default 50051) |
|
||||||
|
//! | `--web` | Serve web frontend |
|
||||||
|
//! | `--version` | Print version and exit |
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let is_daemon = args.iter().any(|a| a == "--daemon");
|
||||||
|
let is_api = args.iter().any(|a| a == "--api");
|
||||||
|
let is_ws = args.iter().any(|a| a == "--ws");
|
||||||
|
let is_grpc = args.iter().any(|a| a == "--grpc");
|
||||||
|
let is_web = args.iter().any(|a| a == "--web");
|
||||||
|
let attach_session = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--attach")
|
||||||
|
.and_then(|i| args.get(i + 1).cloned());
|
||||||
|
|
||||||
|
if args.iter().any(|a| a == "--version") {
|
||||||
|
println!("Zesdex version {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup logging ────────────────────────────────────────────────────
|
||||||
|
let log_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join("zesdex");
|
||||||
|
let _ = std::fs::create_dir_all(&log_dir);
|
||||||
|
let log_path = log_dir.join("zesdex.log");
|
||||||
|
let log_file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open("/dev/null")
|
||||||
|
.expect("cannot open /dev/null")
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.with_writer(Mutex::new(log_file))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("zesdex gateway starting");
|
||||||
|
|
||||||
|
// ── Dispatch to interface ────────────────────────────────────────────
|
||||||
|
// Validate mutually exclusive flags
|
||||||
|
let mode_count = [is_daemon, is_api, is_ws, is_grpc, is_web]
|
||||||
|
.iter()
|
||||||
|
.filter(|&&b| b)
|
||||||
|
.count()
|
||||||
|
+ if attach_session.is_some() { 1 } else { 0 };
|
||||||
|
|
||||||
|
if mode_count > 1 {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_daemon {
|
||||||
|
tracing::info!("starting in daemon mode");
|
||||||
|
zesdex_daemon::server::run_daemon()?;
|
||||||
|
} else if let Some(session_id) = attach_session {
|
||||||
|
tracing::info!("starting in attach mode for session {session_id}");
|
||||||
|
zesdex_daemon::client::run_attach(&session_id)?;
|
||||||
|
} else if is_api {
|
||||||
|
tracing::info!("starting in API server mode");
|
||||||
|
run_api_server(&args)?;
|
||||||
|
} else if is_ws {
|
||||||
|
tracing::info!("starting in WebSocket server mode");
|
||||||
|
run_ws_server()?;
|
||||||
|
} else if is_grpc {
|
||||||
|
tracing::info!("starting in gRPC server mode");
|
||||||
|
run_grpc_server()?;
|
||||||
|
} else if is_web {
|
||||||
|
tracing::info!("starting in web server mode");
|
||||||
|
run_web_server()?;
|
||||||
|
} else {
|
||||||
|
// Default: run TUI single-process mode
|
||||||
|
tracing::info!("starting in TUI single-process mode");
|
||||||
|
run_tui_single_process()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the TUI in single-process mode (TUI + agent in one process).
|
||||||
|
fn run_tui_single_process() -> anyhow::Result<()> {
|
||||||
|
// Import and run the TUI's single-process entry point
|
||||||
|
zesdex_tui::run_single_process()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the REST API server.
|
||||||
|
fn run_api_server(args: &[String]) -> anyhow::Result<()> {
|
||||||
|
let port = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--api-port")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse::<u16>().ok())
|
||||||
|
.unwrap_or(8080);
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(async {
|
||||||
|
let store = zesdex_domain::core::Store::new();
|
||||||
|
let state = zesdex_api::ApiState::new(
|
||||||
|
store.base_dir.clone(),
|
||||||
|
"dev-secret",
|
||||||
|
"",
|
||||||
|
"deepseek-v4-flash-free",
|
||||||
|
Some("https://opencode.ai/zen/v1".to_string()),
|
||||||
|
);
|
||||||
|
let app = zesdex_api::build_router(state);
|
||||||
|
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
tracing::info!("REST API server listening on {addr}");
|
||||||
|
println!("REST API server listening on http://{addr}/api/v1/health");
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the WebSocket server.
|
||||||
|
fn run_ws_server() -> anyhow::Result<()> {
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(async { zesdex_ws::run_server(8081).await })?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the gRPC server.
|
||||||
|
fn run_grpc_server() -> anyhow::Result<()> {
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(async { zesdex_grpc::run_server(50051).await })?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the web frontend.
|
||||||
|
fn run_web_server() -> anyhow::Result<()> {
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(async { zesdex_web::run_server(3000, None).await })?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "zesdex-backend"
|
name = "zesdex-infrastructure"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
|
# Infrastructure layer — concrete implementations of domain repository
|
||||||
|
# traits, application port traits, and all platform services.
|
||||||
|
# Depends on domain + application; NEVER on interfaces.
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Workspace crates
|
zesdex-domain = { path = "../domain" }
|
||||||
zesdex-entities = { path = "../zesdex-entities" }
|
zesdex-application = { path = "../application" }
|
||||||
zesdex-utils = { path = "../zesdex-utils" }
|
|
||||||
zesdex-ipc = { path = "../zesdex-ipc" }
|
|
||||||
zesdex-iam = { path = "../zesdex-iam" }
|
|
||||||
zesdex-cms = { path = "../zesdex-cms" }
|
|
||||||
zesdex-middleware = { path = "../zesdex-middleware" }
|
|
||||||
zesdex-infra = { path = "../zesdex-infra" }
|
|
||||||
|
|
||||||
# External deps
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_yaml_ng.workspace = true
|
serde_yaml_ng.workspace = true
|
||||||
@@ -23,10 +19,7 @@ uuid.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
ratatui.workspace = true
|
|
||||||
crossterm.workspace = true
|
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
@@ -52,15 +45,10 @@ dom_smoothie.workspace = true
|
|||||||
fast_html2md.workspace = true
|
fast_html2md.workspace = true
|
||||||
scraper.workspace = true
|
scraper.workspace = true
|
||||||
include_dir.workspace = true
|
include_dir.workspace = true
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
[[bin]]
|
axum.workspace = true
|
||||||
name = "zesdex"
|
tower.workspace = true
|
||||||
path = "src/main.rs"
|
tower-http.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
[[bin]]
|
jsonwebtoken.workspace = true
|
||||||
name = "seed"
|
clap.workspace = true
|
||||||
path = "src/bin/seed.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "migrate"
|
|
||||||
path = "src/bin/migrate.rs"
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Standard JWT claims with optional session binding.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JwtClaims {
|
||||||
|
pub sub: String,
|
||||||
|
pub exp: u64,
|
||||||
|
pub iat: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtClaims {
|
||||||
|
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
|
||||||
|
let iat = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
Self {
|
||||||
|
sub,
|
||||||
|
exp,
|
||||||
|
iat,
|
||||||
|
session_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a set of claims into a JWT string using HS256.
|
||||||
|
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
|
||||||
|
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||||
|
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
|
||||||
|
let token = jsonwebtoken::encode(&header, &claims, &key)?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a JWT string and return its claims.
|
||||||
|
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
|
||||||
|
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.required_spec_claims = ["sub", "exp", "iat"]
|
||||||
|
.iter()
|
||||||
|
.map(|&s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
|
||||||
|
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! Auth service implementations: JWT signing/verification, Argon2 password
|
||||||
|
//! hashing, and OAuth loopback server.
|
||||||
|
|
||||||
|
pub mod jwt;
|
||||||
|
pub mod oauth_loopback;
|
||||||
|
pub mod password;
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||||
|
/// `?code=...` redirect and serves back a static confirmation page.
|
||||||
|
pub struct LoopbackServer {
|
||||||
|
listener: TcpListener,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoopbackServer {
|
||||||
|
pub fn bind() -> std::io::Result<Self> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
Ok(LoopbackServer { listener, port })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redirect_uri(&self) -> String {
|
||||||
|
format!("http://127.0.0.1:{}/callback", self.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_code(
|
||||||
|
&self,
|
||||||
|
timeout_ms: u64,
|
||||||
|
expected_state: &str,
|
||||||
|
) -> std::io::Result<String> {
|
||||||
|
let (mut stream, _) = self.listener.accept()?;
|
||||||
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||||
|
Self::read_callback(&mut stream, expected_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_callback(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
expected_state: &str,
|
||||||
|
) -> std::io::Result<String> {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let n = stream.read(&mut buf)?;
|
||||||
|
let request = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
let code = Self::extract_code(&request);
|
||||||
|
let state = Self::extract_state(&request);
|
||||||
|
let state_ok = state.as_deref() == Some(expected_state);
|
||||||
|
let response = match (code.as_ref(), state_ok) {
|
||||||
|
(Some(_), true) => {
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
|
||||||
|
Authorization complete. You may close this tab."
|
||||||
|
}
|
||||||
|
(Some(_), false) => {
|
||||||
|
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||||
|
State mismatch — possible CSRF attack."
|
||||||
|
}
|
||||||
|
(None, _) => {
|
||||||
|
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||||
|
Missing authorization code."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = stream.write_all(response.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
if !state_ok {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"state mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
code.ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_code(request: &str) -> Option<String> {
|
||||||
|
let line = request.lines().next()?;
|
||||||
|
let path = line.split(' ').nth(1)?;
|
||||||
|
let query = path.split('?').nth(1)?;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let mut parts = pair.splitn(2, '=');
|
||||||
|
if parts.next()? == "code" {
|
||||||
|
return parts.next().map(urlencoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_state(request: &str) -> Option<String> {
|
||||||
|
let line = request.lines().next()?;
|
||||||
|
let path = line.split(' ').nth(1)?;
|
||||||
|
let query = path.split('?').nth(1)?;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let mut parts = pair.splitn(2, '=');
|
||||||
|
if parts.next()? == "state" {
|
||||||
|
return parts.next().map(urlencoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent-decode a string (e.g. `%20` -> space).
|
||||||
|
fn urlencoding(s: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(s.len());
|
||||||
|
let mut chars = s.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '%' {
|
||||||
|
match (
|
||||||
|
chars.next().and_then(|c| c.to_digit(16)),
|
||||||
|
chars.next().and_then(|c| c.to_digit(16)),
|
||||||
|
) {
|
||||||
|
(Some(hi), Some(lo)) => {
|
||||||
|
let byte: u8 = (hi as u8) * 16 + lo as u8;
|
||||||
|
result.push(char::from(byte));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
result.push('%');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! Argon2 password hashing and verification utilities.
|
||||||
|
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
|
||||||
|
/// Hash a plaintext password using Argon2id with a random salt.
|
||||||
|
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||||
|
let password = password.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a plaintext password against a previously-hashed PHC string.
|
||||||
|
pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
|
||||||
|
let password = password.to_string();
|
||||||
|
let hash = hash.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let parsed_hash = PasswordHash::new(&hash)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let valid = argon2
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok();
|
||||||
|
Ok(valid)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! Background bash control — list, cancel, and inspect background processes.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use super::job::BashJob;
|
||||||
|
|
||||||
|
/// Central registry of all running background bash jobs.
|
||||||
|
pub struct BashControl {
|
||||||
|
jobs: Mutex<HashMap<String, Arc<BashJob>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashControl {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
BashControl {
|
||||||
|
jobs: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new background job.
|
||||||
|
pub fn register(&self, job: Arc<BashJob>) {
|
||||||
|
if let Ok(mut guard) = self.jobs.lock() {
|
||||||
|
guard.insert(job.id.clone(), job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a job by ID.
|
||||||
|
pub fn cancel(&self, id: &str) -> bool {
|
||||||
|
if let Ok(mut guard) = self.jobs.lock() {
|
||||||
|
if let Some(job) = guard.remove(id) {
|
||||||
|
job.cancel();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all active jobs.
|
||||||
|
pub fn list(&self) -> Vec<(String, String, bool)> {
|
||||||
|
let mut guard = self.jobs.lock().unwrap();
|
||||||
|
guard.retain(|_, j| j.is_running());
|
||||||
|
guard
|
||||||
|
.iter()
|
||||||
|
.map(|(id, job)| (id.clone(), job.command.clone(), job.is_running()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up completed jobs.
|
||||||
|
pub fn prune(&self) {
|
||||||
|
if let Ok(mut guard) = self.jobs.lock() {
|
||||||
|
guard.retain(|_, j| j.is_running());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Background bash job — spawns a `bash -c` subprocess and tracks its life.
|
||||||
|
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// A handle to a spawned background bash job.
|
||||||
|
pub struct BashJob {
|
||||||
|
pub id: String,
|
||||||
|
pub command: String,
|
||||||
|
pub process: Mutex<Option<Child>>,
|
||||||
|
pub cancelled: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a background bash job and return a handle.
|
||||||
|
///
|
||||||
|
/// The job runs until completion or until `cancel()` is called.
|
||||||
|
pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
|
||||||
|
let child = Command::new("bash")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let job = Arc::new(BashJob {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
command: cmd,
|
||||||
|
process: Mutex::new(child),
|
||||||
|
cancelled: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn a monitor thread (in production this would use an async task)
|
||||||
|
let job_clone = Arc::clone(&job);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut guard = job_clone.process.lock().unwrap();
|
||||||
|
if let Some(ref mut child) = *guard {
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashJob {
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.cancelled.store(true, Ordering::SeqCst);
|
||||||
|
if let Ok(mut guard) = self.process.lock() {
|
||||||
|
if let Some(ref mut child) = *guard {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
if self.cancelled.load(Ordering::SeqCst) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(mut guard) = self.process.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
guard.as_mut().map_or(false, |c| {
|
||||||
|
matches!(c.try_wait(), Ok(None))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! Background bash job management — spawn, track, and query long-running
|
||||||
|
//! shell processes.
|
||||||
|
|
||||||
|
pub mod control;
|
||||||
|
pub mod job;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
//! Tool gate — per-tool access control and permissions.
|
||||||
|
|
||||||
|
pub mod patterns;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//! Tool usage patterns — detect dangerous or suspicious tool invocations.
|
||||||
|
|
||||||
|
/// Check whether a tool invocation matches a known dangerous pattern.
|
||||||
|
///
|
||||||
|
/// Returns a description of the risk if the pattern matches, or `None`
|
||||||
|
/// if the invocation appears safe.
|
||||||
|
pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
||||||
|
match tool_name {
|
||||||
|
"bash" => {
|
||||||
|
let cmd = args
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
// Detect git push with --force
|
||||||
|
if cmd.contains("git push") && cmd.contains("--force") {
|
||||||
|
return Some("Force-pushing to git is destructive and may lose history".to_string());
|
||||||
|
}
|
||||||
|
// Detect rm -rf /
|
||||||
|
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
|
||||||
|
return Some("Recursive deletion of the root filesystem is never allowed".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"delete" => {
|
||||||
|
let path = args
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if path == "/" || path.starts_with("/etc") {
|
||||||
|
return Some(format!("Deleting '{}' is too dangerous", path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! IPC client — connects to the daemon's Unix socket and sends/receives
|
||||||
|
//! framed JSON messages.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// A thread-safe IPC client connected to a Zesdex daemon over a Unix socket.
|
||||||
|
pub struct IpcClient {
|
||||||
|
conn: Mutex<crate::ipc::conn::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IpcClient {
|
||||||
|
pub fn connect_unix(path: &str) -> anyhow::Result<Self> {
|
||||||
|
let stream = UnixStream::connect(path)?;
|
||||||
|
let conn = crate::ipc::conn::Connection::new(stream);
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send<T: serde::Serialize>(&self, msg: &T) -> anyhow::Result<()> {
|
||||||
|
let mut guard = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.expect("IpcClient mutex poisoned");
|
||||||
|
guard.send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn receive<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<Option<T>> {
|
||||||
|
let mut guard = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.expect("IpcClient mutex poisoned");
|
||||||
|
guard.receive()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! Connection wrapper around a Unix socket stream,
|
||||||
|
//! pairing a buffered reader with a raw writer.
|
||||||
|
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
|
/// A framed JSON connection over a Unix socket.
|
||||||
|
pub struct Connection {
|
||||||
|
reader: BufReader<UnixStream>,
|
||||||
|
writer: UnixStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
pub fn new(stream: UnixStream) -> Self {
|
||||||
|
let reader = BufReader::new(
|
||||||
|
stream
|
||||||
|
.try_clone()
|
||||||
|
.expect("UnixStream::try_clone should never fail on Linux"),
|
||||||
|
);
|
||||||
|
let writer = stream;
|
||||||
|
Self { reader, writer }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send<T: serde::Serialize>(&mut self, msg: &T) -> anyhow::Result<()> {
|
||||||
|
let json = serde_json::to_vec(msg)?;
|
||||||
|
crate::ipc::frame::write_frame(&mut self.writer, &json)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> anyhow::Result<Option<T>> {
|
||||||
|
let raw = crate::ipc::frame::read_frame(&mut self.reader)?;
|
||||||
|
match raw {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(bytes) => {
|
||||||
|
let msg: T = serde_json::from_slice(&bytes)?;
|
||||||
|
Ok(Some(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Length-prefixed framing for Unix-socket IPC.
|
||||||
|
//!
|
||||||
|
//! Every message on the wire is encoded as:
|
||||||
|
//! ```text
|
||||||
|
//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
const MAX_PAYLOAD: u32 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Read one length-prefixed frame from `reader`.
|
||||||
|
pub fn read_frame(reader: &mut impl Read) -> anyhow::Result<Option<Vec<u8>>> {
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
|
||||||
|
match reader.read_exact(&mut len_buf) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e).context("failed to read frame length prefix"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
|
||||||
|
if payload_len > MAX_PAYLOAD as usize {
|
||||||
|
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = vec![0u8; payload_len];
|
||||||
|
reader.read_exact(&mut payload)?;
|
||||||
|
|
||||||
|
Ok(Some(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one length-prefixed frame to `writer`.
|
||||||
|
pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> anyhow::Result<()> {
|
||||||
|
let payload_len: u32 = data.len().try_into()?;
|
||||||
|
|
||||||
|
if payload_len > MAX_PAYLOAD {
|
||||||
|
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let len_bytes = payload_len.to_be_bytes();
|
||||||
|
writer.write_all(&len_bytes)?;
|
||||||
|
writer.write_all(data)?;
|
||||||
|
writer.flush()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! Unix-socket IPC layer for daemon/client communication.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod conn;
|
||||||
|
pub mod frame;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod server;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Wire types for the Zesdex IPC protocol.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A resolved key press sent from the daemon to the client.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum KeyAction {
|
||||||
|
Char(char),
|
||||||
|
Enter,
|
||||||
|
Escape,
|
||||||
|
Backspace,
|
||||||
|
Delete,
|
||||||
|
Tab,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
|
Function(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message sent from the TUI client to the daemon over the IPC socket.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ClientRequest {
|
||||||
|
Tick,
|
||||||
|
KeyPress {
|
||||||
|
key: KeyAction,
|
||||||
|
ctrl: bool,
|
||||||
|
alt: bool,
|
||||||
|
shift: bool,
|
||||||
|
},
|
||||||
|
Submit(String),
|
||||||
|
Paste(String),
|
||||||
|
Resize(u16, u16),
|
||||||
|
Close,
|
||||||
|
ScrollUp,
|
||||||
|
ScrollDown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single chat message within a session.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessageEntry {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transient toast notification sent to the client.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToastEntry {
|
||||||
|
pub kind: String,
|
||||||
|
pub message: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub lifetime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full UI state snapshot pushed from the daemon to the client.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StatePayload {
|
||||||
|
pub session_id: String,
|
||||||
|
pub messages: Vec<MessageEntry>,
|
||||||
|
pub edit_count: u32,
|
||||||
|
pub message_count: usize,
|
||||||
|
pub overlay: Option<String>,
|
||||||
|
pub toasts: Vec<ToastEntry>,
|
||||||
|
pub dirty: bool,
|
||||||
|
pub input_buffer: String,
|
||||||
|
pub input_cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frame sent from the daemon to the client.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum DaemonFrame {
|
||||||
|
StateUpdate(Box<StatePayload>),
|
||||||
|
StreamToken(String),
|
||||||
|
SystemNote {
|
||||||
|
kind: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
ClipboardCopy(String),
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! IPC server — binds a Unix socket and accepts incoming client connections.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// A Unix-socket IPC server.
|
||||||
|
pub struct IpcServer {
|
||||||
|
listener: UnixListener,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IpcServer {
|
||||||
|
pub fn bind_unix(path: &str) -> anyhow::Result<Self> {
|
||||||
|
let p = Path::new(path);
|
||||||
|
if p.exists() {
|
||||||
|
std::fs::remove_file(p)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(path)?;
|
||||||
|
Ok(Self { listener })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accept(&self) -> anyhow::Result<crate::ipc::conn::Connection> {
|
||||||
|
let (stream, _addr) = self.listener.accept()?;
|
||||||
|
Ok(crate::ipc::conn::Connection::new(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
//! # Zesdex Infrastructure Layer
|
||||||
|
//!
|
||||||
|
//! ALL concrete implementations of domain repository traits, application port
|
||||||
|
//! traits, and platform services. This is the outermost ring of the Clean
|
||||||
|
//! Architecture onion — it depends on `zesdex-domain` and `zesdex-application`
|
||||||
|
//! but NEVER on interface/presentation crates.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! src/
|
||||||
|
//! ├── lib.rs — Foundational types + re-exports
|
||||||
|
//! ├── utils.rs — CastOr, write_json_atomic, slugify
|
||||||
|
//! ├── persistence/ — Repository implementations (IAM, CMS, SQLite)
|
||||||
|
//! ├── auth/ — JWT, Argon2, OAuth loopback
|
||||||
|
//! ├── llm/ — LLM provider HTTP client
|
||||||
|
//! ├── ipc/ — Unix-socket IPC protocol
|
||||||
|
//! ├── lsp/ — Native LSP client + provisioner
|
||||||
|
//! ├── mcp/ — Model Context Protocol bridge
|
||||||
|
//! ├── bgbash/ — Background bash job management
|
||||||
|
//! ├── tools/ — All 37 agent-invocable tools
|
||||||
|
//! ├── subagent/ — Subagent spawning & execution engine
|
||||||
|
//! ├── workflow/ — Hive-mind orchestration engine
|
||||||
|
//! ├── review/ — Post-edit auto-review subagent
|
||||||
|
//! ├── guard/ — Tool-gate access control
|
||||||
|
//! └── middleware/ — Axum HTTP middleware (auth, cors, rate-limit)
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod bgbash;
|
||||||
|
pub mod guard;
|
||||||
|
pub mod ipc;
|
||||||
|
pub mod llm;
|
||||||
|
pub mod lsp;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod persistence;
|
||||||
|
pub mod review;
|
||||||
|
pub mod subagent;
|
||||||
|
pub mod tools;
|
||||||
|
pub mod utils;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Re-exports from domain
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
pub use zesdex_domain::*;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Foundation types — these replace `crate::app::state::*` references
|
||||||
|
// from the legacy backend code.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||||
|
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||||
|
pub enum Origin {
|
||||||
|
/// The main agent turn loop.
|
||||||
|
Main,
|
||||||
|
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
|
||||||
|
SubAgent,
|
||||||
|
/// The auto-inline review step after an edit.
|
||||||
|
Reviewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Origin {
|
||||||
|
/// Short string tag for this origin, used in filenames and logs.
|
||||||
|
pub fn tag(self) -> String {
|
||||||
|
match self {
|
||||||
|
Origin::Main => "main",
|
||||||
|
Origin::SubAgent => "subagent",
|
||||||
|
Origin::Reviewer => "reviewer",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Severity/category of a toast notification, used to pick its color.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ToastKind {
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
Lesson,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Toast {
|
||||||
|
pub kind: ToastKind,
|
||||||
|
pub message: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub lifetime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Toast {
|
||||||
|
/// Create a toast with a default 5-second lifetime, stamped with now.
|
||||||
|
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||||
|
Toast {
|
||||||
|
kind,
|
||||||
|
message,
|
||||||
|
created_at: chrono::Utc::now().timestamp_millis(),
|
||||||
|
lifetime_ms: 5000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
||||||
|
pub fn expired(&self, now_ms: i64) -> bool {
|
||||||
|
let lifetime = self.lifetime_ms as i64;
|
||||||
|
now_ms - self.created_at > lifetime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shared, async-writable cache of directory entries, used to avoid
|
||||||
|
/// re-reading a directory every render frame.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DirCache {
|
||||||
|
entries: Arc<tokio::sync::RwLock<Vec<PathBuf>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
DirCache {
|
||||||
|
entries: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(&self, paths: Vec<PathBuf>) {
|
||||||
|
let mut w = self.entries.write().await;
|
||||||
|
*w = paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DirCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shared, whole-workspace file-path index used for `@file` mention
|
||||||
|
/// autocomplete.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MentionIndex {
|
||||||
|
entries: Arc<std::sync::RwLock<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MentionIndex {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
MentionIndex {
|
||||||
|
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, paths: Vec<String>) {
|
||||||
|
if let Ok(mut w) = self.entries.write() {
|
||||||
|
*w = paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&self, path: String) {
|
||||||
|
if let Ok(mut w) = self.entries.write() {
|
||||||
|
w.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Vec<String> {
|
||||||
|
self.entries.read().map(|r| r.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MentionIndex {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TurnEvent & runtime types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Events emitted onto the turn-event queue while an agent turn runs,
|
||||||
|
/// consumed by the event loop to update state and drive re-renders.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TurnEvent {
|
||||||
|
AssistantMessage(ChatMessage),
|
||||||
|
ToolResult {
|
||||||
|
tool_call_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
output: String,
|
||||||
|
is_error: bool,
|
||||||
|
path: Option<String>,
|
||||||
|
},
|
||||||
|
SystemNote {
|
||||||
|
kind: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
StreamStart,
|
||||||
|
StreamToken(String),
|
||||||
|
StreamDone(ChatMessage),
|
||||||
|
Usage {
|
||||||
|
tokens_in: u64,
|
||||||
|
tokens_out: u64,
|
||||||
|
},
|
||||||
|
ReviewUsage {
|
||||||
|
tokens_in: u64,
|
||||||
|
tokens_out: u64,
|
||||||
|
},
|
||||||
|
Compacted(Vec<ChatMessage>),
|
||||||
|
Error(String),
|
||||||
|
Done,
|
||||||
|
WorkflowAgentUpdate {
|
||||||
|
agent_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
status: crate::AgentStatus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool call awaiting execution, along with which execution model
|
||||||
|
/// (inline, deferred, async) it should run under.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PendingTool {
|
||||||
|
pub tool_name: String,
|
||||||
|
pub args: serde_json::Value,
|
||||||
|
pub execution_model: ExecutionModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a pending tool call should be executed when the turn resumes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ExecutionModel {
|
||||||
|
Inline,
|
||||||
|
Deferred,
|
||||||
|
AsyncTokio,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference to a background bash job tracked in session state.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BashJobRef {
|
||||||
|
pub id: String,
|
||||||
|
pub command: String,
|
||||||
|
pub started_at: i64,
|
||||||
|
pub running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-session runtime state: message history, pending tool queue,
|
||||||
|
/// background bash jobs, lesson/review counters.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionRuntime {
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
pub tool_call_results: Vec<ToolCallResult>,
|
||||||
|
pub pending_tool_queue: Vec<PendingTool>,
|
||||||
|
pub bash_jobs: Vec<BashJobRef>,
|
||||||
|
pub subagent_queue: usize,
|
||||||
|
pub edit_count: u32,
|
||||||
|
pub consecutive_empty_reviews: u32,
|
||||||
|
pub session_start: i64,
|
||||||
|
pub lesson_count: u32,
|
||||||
|
pub lessons_user: u32,
|
||||||
|
pub lessons_feedback: u32,
|
||||||
|
pub lessons_project: u32,
|
||||||
|
pub lessons_reference: u32,
|
||||||
|
pub lessons_active: u32,
|
||||||
|
pub lessons_stale: u32,
|
||||||
|
pub lessons_contradicted: u32,
|
||||||
|
pub lessons_human: u32,
|
||||||
|
pub lessons_verified: u32,
|
||||||
|
pub lessons_unverified: u32,
|
||||||
|
pub review_count: u32,
|
||||||
|
pub session_dir: PathBuf,
|
||||||
|
pub usage: UsageStats,
|
||||||
|
pub hive_mind_converged: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRuntime {
|
||||||
|
pub fn new(session_dir: PathBuf) -> Self {
|
||||||
|
SessionRuntime {
|
||||||
|
messages: Vec::new(),
|
||||||
|
tool_call_results: Vec::new(),
|
||||||
|
pending_tool_queue: Vec::new(),
|
||||||
|
bash_jobs: Vec::new(),
|
||||||
|
subagent_queue: 0,
|
||||||
|
edit_count: 0,
|
||||||
|
consecutive_empty_reviews: 0,
|
||||||
|
session_start: chrono::Utc::now().timestamp_millis(),
|
||||||
|
lesson_count: 0,
|
||||||
|
lessons_user: 0,
|
||||||
|
lessons_feedback: 0,
|
||||||
|
lessons_project: 0,
|
||||||
|
lessons_reference: 0,
|
||||||
|
lessons_active: 0,
|
||||||
|
lessons_stale: 0,
|
||||||
|
lessons_contradicted: 0,
|
||||||
|
lessons_human: 0,
|
||||||
|
lessons_verified: 0,
|
||||||
|
lessons_unverified: 0,
|
||||||
|
review_count: 0,
|
||||||
|
session_dir,
|
||||||
|
usage: UsageStats::default(),
|
||||||
|
hive_mind_converged: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_message(&mut self, msg: ChatMessage) {
|
||||||
|
self.messages.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple ASCII progress display for a long-running operation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProgressState {
|
||||||
|
pub current: u64,
|
||||||
|
pub total: u64,
|
||||||
|
pub message: String,
|
||||||
|
pub start_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent status for workflow engine progress tracking.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum AgentStatus {
|
||||||
|
Pending,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed(String),
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AgentStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AgentStatus::Pending => write!(f, "pending"),
|
||||||
|
AgentStatus::Running => write!(f, "running"),
|
||||||
|
AgentStatus::Completed => write!(f, "completed"),
|
||||||
|
AgentStatus::Failed(msg) => write!(f, "failed: {msg}"),
|
||||||
|
AgentStatus::Cancelled => write!(f, "cancelled"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool types — needed by all tool modules
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
|
||||||
|
|
||||||
|
// Re-export commonly needed types at the crate root
|
||||||
|
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! LLM provider HTTP client for OpenAI/Anthropic-compatible chat completion APIs.
|
||||||
|
|
||||||
|
pub mod provider;
|
||||||
|
|
||||||
|
pub use provider::{resolve_api_key, LlmClient};
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
//! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs,
|
||||||
|
//! supporting both non-streaming and SSE-streaming requests with automatic retry.
|
||||||
|
|
||||||
|
use rand_core::RngCore;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use zesdex_domain::core::{
|
||||||
|
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
||||||
|
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||||
|
pub const DEFAULT_API_KEY: &str = "";
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Retry helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn backoff_seconds(attempt: u32, cap: u64) -> Duration {
|
||||||
|
let base = 2u64.pow(attempt.saturating_sub(1));
|
||||||
|
let delay = std::cmp::min(base, cap);
|
||||||
|
// ±25% jitter
|
||||||
|
let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0;
|
||||||
|
Duration::from_secs_f64(delay as f64 * jitter_factor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the error an auth / billing failure that retrying won't fix?
|
||||||
|
pub fn is_auth_error(err_str: &str) -> bool {
|
||||||
|
let err_lower = err_str.to_lowercase();
|
||||||
|
(err_str.contains("API error 401")
|
||||||
|
|| err_str.contains("API error 402")
|
||||||
|
|| err_str.contains("API error 403"))
|
||||||
|
|| err_lower.contains("unauthorized")
|
||||||
|
|| err_lower.contains("forbidden")
|
||||||
|
|| err_lower.contains("authentication failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_rate_limit(err_str: &str) -> bool {
|
||||||
|
err_str.contains("API error 429") || err_str.to_lowercase().contains("rate limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backoff_for_error(attempt: u32, err_str: &str) -> Duration {
|
||||||
|
if is_rate_limit(err_str) {
|
||||||
|
backoff_seconds(attempt, 60)
|
||||||
|
} else {
|
||||||
|
backoff_seconds(attempt, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Blocking HTTP client for a single LLM provider endpoint.
|
||||||
|
pub struct LlmClient {
|
||||||
|
pub client: reqwest::blocking::Client,
|
||||||
|
pub api_key: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmClient {
|
||||||
|
pub fn new(mut api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||||
|
if api_key.is_empty() {
|
||||||
|
api_key = DEFAULT_API_KEY.to_string();
|
||||||
|
}
|
||||||
|
let model = if model.is_empty() {
|
||||||
|
DEFAULT_MODEL.to_string()
|
||||||
|
} else {
|
||||||
|
model
|
||||||
|
};
|
||||||
|
let client = match reqwest::blocking::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"failed to build reqwest client with connect timeout: {}. \
|
||||||
|
retrying without connect timeout",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
match reqwest::blocking::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e2) => {
|
||||||
|
tracing::warn!("also failed: {e2}. using default client");
|
||||||
|
reqwest::blocking::Client::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
LlmClient {
|
||||||
|
client,
|
||||||
|
api_key,
|
||||||
|
base_url: base_url
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
|
||||||
|
model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chat_with_tools_non_streaming(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
tools: Option<Vec<ToolDef>>,
|
||||||
|
max_tokens: Option<u32>,
|
||||||
|
temperature: Option<f32>,
|
||||||
|
abort_flag: Option<&AtomicBool>,
|
||||||
|
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
|
let req = ChatRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
messages: messages.to_vec(),
|
||||||
|
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
||||||
|
temperature: Some(temperature.unwrap_or(0.7)),
|
||||||
|
tools,
|
||||||
|
stream: Some(false),
|
||||||
|
stop: None,
|
||||||
|
stream_options: None,
|
||||||
|
tool_choice: None,
|
||||||
|
top_p: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
|
let max_retries = 10;
|
||||||
|
let mut attempt = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
|
||||||
|
if let Some(ref flag) = abort_flag {
|
||||||
|
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
anyhow::bail!("aborted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut http_req = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
if !self.api_key.is_empty() {
|
||||||
|
http_req =
|
||||||
|
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result =
|
||||||
|
(|| -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
|
let resp = http_req.json(&req).send().map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
||||||
|
Check your network or try again."
|
||||||
|
)
|
||||||
|
} else if e.is_connect() {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Could not connect to {}. \
|
||||||
|
Is the URL correct and is the service reachable?",
|
||||||
|
self.base_url
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!("API request failed: {e}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: ChatResponse = resp.json()?;
|
||||||
|
let usage = data.usage.map(|u| {
|
||||||
|
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens))
|
||||||
|
});
|
||||||
|
let message = data
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|c| c.message)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
|
||||||
|
Ok((message, usage))
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((msg, usage)) => return Ok((msg, usage)),
|
||||||
|
Err(e) => {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if attempt >= max_retries || is_auth_error(&err_str) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let delay = backoff_for_error(attempt, &err_str);
|
||||||
|
std::thread::sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chat_with_tools_streaming(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
tools: Option<Vec<ToolDef>>,
|
||||||
|
temperature: Option<f32>,
|
||||||
|
max_tokens: Option<u32>,
|
||||||
|
mut on_event: impl FnMut(&StreamEvent) -> bool,
|
||||||
|
abort_flag: Option<&AtomicBool>,
|
||||||
|
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
|
let tools_for_fallback = tools.clone();
|
||||||
|
let req = ChatRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
messages: messages.to_vec(),
|
||||||
|
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
||||||
|
temperature: Some(temperature.unwrap_or(0.7)),
|
||||||
|
tools,
|
||||||
|
stream: Some(true),
|
||||||
|
stop: None,
|
||||||
|
stream_options: Some(StreamOptions {
|
||||||
|
include_usage: true,
|
||||||
|
}),
|
||||||
|
tool_choice: None,
|
||||||
|
top_p: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
|
let max_retries_stream = 5;
|
||||||
|
let mut attempt = 0u32;
|
||||||
|
let mut meaningful_content = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let mut captured_content = false;
|
||||||
|
let mut wrapped = |event: &StreamEvent| -> bool {
|
||||||
|
match event {
|
||||||
|
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
|
||||||
|
captured_content = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
on_event(event)
|
||||||
|
};
|
||||||
|
match self.try_stream_once(&req, &url, &mut wrapped) {
|
||||||
|
Ok(result) => return Ok(result),
|
||||||
|
Err(e) => {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if is_auth_error(&err_str) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
if captured_content || (attempt >= max_retries_stream) {
|
||||||
|
meaningful_content = captured_content || meaningful_content;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if attempt >= max_retries_stream {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let delay = backoff_for_error(attempt, &err_str);
|
||||||
|
std::thread::sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if meaningful_content {
|
||||||
|
if let Some(ref flag) = abort_flag {
|
||||||
|
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return Err(anyhow::anyhow!("aborted"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.chat_with_tools_non_streaming(
|
||||||
|
messages,
|
||||||
|
tools_for_fallback,
|
||||||
|
max_tokens,
|
||||||
|
temperature,
|
||||||
|
abort_flag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"streaming request failed after {max_retries_stream} attempts"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_stream_once(
|
||||||
|
&self,
|
||||||
|
req: &ChatRequest,
|
||||||
|
url: &str,
|
||||||
|
on_event: &mut dyn FnMut(&StreamEvent) -> bool,
|
||||||
|
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
let mut http_req = self
|
||||||
|
.client
|
||||||
|
.post(url)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
if !self.api_key.is_empty() {
|
||||||
|
http_req =
|
||||||
|
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = http_req.json(req).send().map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
||||||
|
Check your network or try again."
|
||||||
|
)
|
||||||
|
} else if e.is_connect() {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Could not connect to {}. \
|
||||||
|
Is the URL correct and is the service reachable?",
|
||||||
|
self.base_url
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!("API request failed: {e}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamedTurn {
|
||||||
|
content: String,
|
||||||
|
tool_calls: Vec<zesdex_domain::core::ToolCall>,
|
||||||
|
done_received: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamedTurn {
|
||||||
|
fn new() -> Self {
|
||||||
|
StreamedTurn {
|
||||||
|
content: String::new(),
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
done_received: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_event(&mut self, event: &StreamEvent) {
|
||||||
|
match event {
|
||||||
|
StreamEvent::Token(t) => self.content.push_str(t),
|
||||||
|
StreamEvent::Reasoning(_) => {}
|
||||||
|
StreamEvent::ToolCallDelta {
|
||||||
|
index: _,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments_delta,
|
||||||
|
} => {
|
||||||
|
let existing = self.tool_calls.iter_mut().find(|tc| {
|
||||||
|
if let Some(ref id_val) = id {
|
||||||
|
tc.id == *id_val
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(tc) = existing {
|
||||||
|
if let Some(ref n) = name {
|
||||||
|
tc.function.name = n.clone();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.tool_calls.push(
|
||||||
|
zesdex_domain::core::ToolCall {
|
||||||
|
id: id.clone().unwrap_or_default(),
|
||||||
|
type_: "function".to_string(),
|
||||||
|
function: zesdex_domain::core::ToolFunction {
|
||||||
|
name: name.clone().unwrap_or_default(),
|
||||||
|
arguments: serde_json::Value::String(arguments_delta.clone()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_assistant_message(self) -> ChatMessage {
|
||||||
|
ChatMessage {
|
||||||
|
role: zesdex_domain::core::Role::Assistant,
|
||||||
|
content: if self.content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.content)
|
||||||
|
},
|
||||||
|
tool_calls: if self.tool_calls.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.tool_calls)
|
||||||
|
},
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut turn = StreamedTurn::new();
|
||||||
|
let mut usage: Option<(u64, u64)> = None;
|
||||||
|
let mut parser = SseParser::new();
|
||||||
|
|
||||||
|
let mut reader = resp;
|
||||||
|
let mut byte_buf: Vec<u8> = Vec::new();
|
||||||
|
let mut chunk_buf = [0u8; 4096];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut chunk_buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
byte_buf.extend_from_slice(&chunk_buf[..n]);
|
||||||
|
let valid_len = match std::str::from_utf8(&byte_buf) {
|
||||||
|
Ok(s) => s.len(),
|
||||||
|
Err(e) => e.valid_up_to(),
|
||||||
|
};
|
||||||
|
if valid_len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text =
|
||||||
|
String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
|
||||||
|
byte_buf.drain(..valid_len);
|
||||||
|
|
||||||
|
for event in parser.feed(&text) {
|
||||||
|
if !on_event(&event) {
|
||||||
|
anyhow::bail!("aborted");
|
||||||
|
}
|
||||||
|
match &event {
|
||||||
|
StreamEvent::Usage {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
usage = Some((*prompt_tokens, *completion_tokens));
|
||||||
|
}
|
||||||
|
StreamEvent::Error(msg) => {
|
||||||
|
anyhow::bail!("stream error: {msg}");
|
||||||
|
}
|
||||||
|
StreamEvent::Done => {
|
||||||
|
turn.apply_event(&event);
|
||||||
|
turn.done_received = true;
|
||||||
|
return Ok((turn.build_assistant_message(), usage));
|
||||||
|
}
|
||||||
|
_ => turn.apply_event(&event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((turn.build_assistant_message(), usage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the API key for the currently configured provider, falling back
|
||||||
|
/// through settings -> env var -> provider default.
|
||||||
|
pub fn resolve_api_key(
|
||||||
|
settings: &zesdex_domain::cms::Settings,
|
||||||
|
app_config: &zesdex_domain::cms::AppConfig,
|
||||||
|
) -> String {
|
||||||
|
let provider = &settings.provider;
|
||||||
|
|
||||||
|
let mut api_key = settings
|
||||||
|
.api_keys
|
||||||
|
.get(provider)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if api_key.is_empty() {
|
||||||
|
if let Some(provider_cfg) = app_config.providers.get(provider) {
|
||||||
|
api_key = provider_cfg
|
||||||
|
.api_key_env
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|env| std::env::var(env).ok())
|
||||||
|
.or_else(|| provider_cfg.default_api_key.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api_key
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! LSP client — sends JSON-RPC requests to language servers.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
/// Mutable inner state of an LSP client, protected by a mutex so that
|
||||||
|
/// `send_request` and `shutdown` can be called via `&self` (required by
|
||||||
|
/// [`LspManager`](super::manager::LspManager)).
|
||||||
|
struct LspClientInner {
|
||||||
|
process: Child,
|
||||||
|
stdin: ChildStdin,
|
||||||
|
stdout: BufReader<ChildStdout>,
|
||||||
|
request_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal but functional LSP client.
|
||||||
|
pub struct LspClient {
|
||||||
|
inner: Mutex<LspClientInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LspClient {
|
||||||
|
/// Spawn a language server process.
|
||||||
|
pub fn start(command: &str, args: &[String]) -> Result<Self> {
|
||||||
|
let mut child = Command::new(command)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let stdin = child.stdin.take().unwrap();
|
||||||
|
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||||
|
|
||||||
|
info!("LSP client spawned: {command}");
|
||||||
|
Ok(LspClient {
|
||||||
|
inner: Mutex::new(LspClientInner {
|
||||||
|
process: child,
|
||||||
|
stdin,
|
||||||
|
stdout,
|
||||||
|
request_id: 0,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a JSON-RPC request and read the response.
|
||||||
|
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
inner.request_id += 1;
|
||||||
|
let request = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": inner.request_id,
|
||||||
|
"method": method,
|
||||||
|
"params": params.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write Content-Length header + body
|
||||||
|
let body = serde_json::to_string(&request)?;
|
||||||
|
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||||
|
inner.stdin.write_all(header.as_bytes())?;
|
||||||
|
inner.stdin.write_all(body.as_bytes())?;
|
||||||
|
inner.stdin.flush()?;
|
||||||
|
|
||||||
|
debug!("LSP request: {method} (id={})", inner.request_id);
|
||||||
|
|
||||||
|
// Read Content-Length header
|
||||||
|
let mut content_length = 0usize;
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
inner.stdout.read_line(&mut line)?;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
break; // end of headers
|
||||||
|
}
|
||||||
|
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
|
||||||
|
content_length = len_str.parse::<usize>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the JSON body
|
||||||
|
let mut buf = vec![0u8; content_length];
|
||||||
|
inner.stdout.read_exact(&mut buf)?;
|
||||||
|
let response: Value = serde_json::from_slice(&buf)?;
|
||||||
|
|
||||||
|
debug!("LSP response for {method}: response received");
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gracefully shut down the server.
|
||||||
|
pub fn shutdown(&self) -> Result<()> {
|
||||||
|
let null = Value::Null;
|
||||||
|
let _ = self.send_request("shutdown", &null);
|
||||||
|
let _ = self.send_request("exit", &null);
|
||||||
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
let _ = inner.process.wait();
|
||||||
|
}
|
||||||
|
info!("LSP client shut down");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LspClient {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
let _ = inner.process.kill();
|
||||||
|
let _ = inner.process.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! Manages multiple LSP server processes, keyed by language ID.
|
||||||
|
//!
|
||||||
|
//! Each language (e.g. "rust", "python") maps to one `LspClient`.
|
||||||
|
//! The manager provides a unified `request` method that dispatches
|
||||||
|
//! to the correct client by language.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::client::LspClient;
|
||||||
|
|
||||||
|
/// Manages one `LspClient` per language.
|
||||||
|
pub struct LspManager {
|
||||||
|
clients: HashMap<String, LspClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LspManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
LspManager {
|
||||||
|
clients: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> {
|
||||||
|
let client = LspClient::start(command, args)?;
|
||||||
|
self.clients.insert(language.to_string(), client);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_client(&self, language: &str) -> Option<&LspClient> {
|
||||||
|
self.clients.get(language)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown_all(&mut self) {
|
||||||
|
for (_lang, client) in &self.clients {
|
||||||
|
let _ = client.shutdown();
|
||||||
|
}
|
||||||
|
self.clients.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn languages(&self) -> Vec<String> {
|
||||||
|
self.clients.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.clients.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! Native LSP client integration — manage language server processes and
|
||||||
|
//! dispatch requests for completion, hover, diagnostics, etc.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod manager;
|
||||||
|
pub mod provisioner;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Configuration for LSP language server provisioning.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Describes how to provision a language server for a given language.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LspProvisionerConfig {
|
||||||
|
/// Language identifier, e.g. "rust", "python".
|
||||||
|
pub language: String,
|
||||||
|
/// The command to start the language server.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments for the command.
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// How to install the language server (if not found).
|
||||||
|
pub install_hint: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! Discovers installed language servers on the system PATH.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::config::LspProvisionerConfig;
|
||||||
|
|
||||||
|
/// Known language server configurations keyed by language.
|
||||||
|
fn known_configs() -> HashMap<&'static str, (&'static str, Vec<&'static str>)> {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert("rust", ("rust-analyzer", vec![]));
|
||||||
|
m.insert("python", ("pyright-langserver", vec!["--stdio"]));
|
||||||
|
m.insert("typescript", ("typescript-language-server", vec!["--stdio"]));
|
||||||
|
m.insert("javascript", ("typescript-language-server", vec!["--stdio"]));
|
||||||
|
m.insert("go", ("gopls", vec![]));
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a command is available on PATH.
|
||||||
|
fn command_exists(cmd: &str) -> bool {
|
||||||
|
std::env::var_os("PATH")
|
||||||
|
.and_then(|path| {
|
||||||
|
std::env::split_paths(&path).find_map(|dir| {
|
||||||
|
let full_path = dir.join(cmd);
|
||||||
|
if full_path.is_file() {
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover which language servers are already on PATH.
|
||||||
|
pub fn discover_installed() -> Vec<LspProvisionerConfig> {
|
||||||
|
let mut configs = Vec::new();
|
||||||
|
for (lang, (cmd, args)) in known_configs() {
|
||||||
|
if command_exists(cmd) {
|
||||||
|
configs.push(LspProvisionerConfig {
|
||||||
|
language: lang.to_string(),
|
||||||
|
command: cmd.to_string(),
|
||||||
|
args: args.iter().map(|s| s.to_string()).collect(),
|
||||||
|
install_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configs
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! Installs language servers (non-interactive, via package managers or
|
||||||
|
//! direct download).
|
||||||
|
|
||||||
|
/// Install a language server for the given language.
|
||||||
|
///
|
||||||
|
/// Returns a success message or an error describing why installation failed.
|
||||||
|
pub fn install_language_server(language: &str) -> anyhow::Result<String> {
|
||||||
|
match language {
|
||||||
|
"rust" => {
|
||||||
|
// rust-analyzer is typically installed via rustup
|
||||||
|
let output = std::process::Command::new("rustup")
|
||||||
|
.args(["component", "add", "rust-analyzer"])
|
||||||
|
.output()?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok("rust-analyzer installed via rustup".to_string())
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("failed to install rust-analyzer: {}", String::from_utf8_lossy(&output.stderr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"python" => {
|
||||||
|
let output = std::process::Command::new("npm")
|
||||||
|
.args(["install", "-g", "pyright"])
|
||||||
|
.output()?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok("pyright installed via npm".to_string())
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("failed to install pyright: {}", String::from_utf8_lossy(&output.stderr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lang => anyhow::bail!("no install method known for language '{lang}'"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//! High-level manager that discovers, installs (if needed), and starts
|
||||||
|
//! LSP servers.
|
||||||
|
|
||||||
|
use crate::lsp::manager::LspManager;
|
||||||
|
use super::discovery::discover_installed;
|
||||||
|
use super::install::install_language_server;
|
||||||
|
|
||||||
|
/// Auto-provision language servers for the given list of languages.
|
||||||
|
///
|
||||||
|
/// Flow: discover already-installed servers → for each requested language
|
||||||
|
/// not yet available, attempt auto-install → start each server.
|
||||||
|
pub fn auto_provision(
|
||||||
|
lsp_manager: &mut LspManager,
|
||||||
|
languages: &[String],
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut started = Vec::new();
|
||||||
|
let installed = discover_installed();
|
||||||
|
let mut installed_map: std::collections::HashMap<&str, &crate::lsp::provisioner::config::LspProvisionerConfig> = std::collections::HashMap::new();
|
||||||
|
for cfg in &installed {
|
||||||
|
installed_map.insert(cfg.language.as_str(), cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
for lang in languages {
|
||||||
|
if let Some(cfg) = installed_map.get(lang.as_str()) {
|
||||||
|
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
|
||||||
|
started.push(lang.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not installed — try auto-install
|
||||||
|
if install_language_server(lang).is_ok() {
|
||||||
|
// Re-discover after install
|
||||||
|
let refreshed = discover_installed();
|
||||||
|
for cfg in refreshed {
|
||||||
|
if cfg.language == *lang {
|
||||||
|
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
|
||||||
|
started.push(lang.clone());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
started
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! LSP language server provisioner — discovers, installs, and manages
|
||||||
|
//! language server executables.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod discovery;
|
||||||
|
pub mod install;
|
||||||
|
pub mod manager;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Manages MCP server connections — start, stop, list, and dispatch
|
||||||
|
//! tool calls to remote MCP servers.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Metadata for a connected MCP server.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct McpServerHandle {
|
||||||
|
pub name: String,
|
||||||
|
pub transport: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages MCP server connections.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct McpManager {
|
||||||
|
servers: HashMap<String, McpServerHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
McpManager {
|
||||||
|
servers: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&mut self, name: &str, transport: &str) {
|
||||||
|
self.servers.insert(
|
||||||
|
name.to_string(),
|
||||||
|
McpServerHandle {
|
||||||
|
name: name.to_string(),
|
||||||
|
transport: transport.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unregister(&mut self, name: &str) {
|
||||||
|
self.servers.remove(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Vec<McpServerHandle> {
|
||||||
|
self.servers.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, name: &str) -> Option<&McpServerHandle> {
|
||||||
|
self.servers.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.servers.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! Model Context Protocol (MCP) — bridge between agent tools and external MCP
|
||||||
|
//! servers using the rmcp crate.
|
||||||
|
|
||||||
|
pub mod manager;
|
||||||
|
pub mod transport;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! MCP transport layer — manages child-process and HTTP-based transport
|
||||||
|
//! for connecting to MCP servers.
|
||||||
|
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
|
||||||
|
/// A running MCP server process connected via stdio.
|
||||||
|
pub struct McpTransport {
|
||||||
|
process: Option<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpTransport {
|
||||||
|
pub fn start_child_process(command: &str, args: &[String]) -> anyhow::Result<Self> {
|
||||||
|
let child = Command::new(command)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()?;
|
||||||
|
Ok(McpTransport {
|
||||||
|
process: Some(child),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self) -> anyhow::Result<()> {
|
||||||
|
if let Some(mut child) = self.process.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for McpTransport {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(mut child) = self.process.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! Authentication middleware — session-lock based auth for Axum.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, Response, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tower::{Layer, Service};
|
||||||
|
|
||||||
|
/// Identity extracted from a validated session.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionIdentity {
|
||||||
|
pub session_id: String,
|
||||||
|
pub user_agent: String,
|
||||||
|
pub connected_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionIdentity {
|
||||||
|
pub fn new(session_id: String, user_agent: String) -> Self {
|
||||||
|
let connected_at = chrono::Utc::now().timestamp();
|
||||||
|
Self {
|
||||||
|
session_id,
|
||||||
|
user_agent,
|
||||||
|
connected_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tower Layer that produces SessionAuthMiddleware services.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionAuthLayer;
|
||||||
|
|
||||||
|
impl SessionAuthLayer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionAuthLayer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Layer<S> for SessionAuthLayer {
|
||||||
|
type Service = SessionAuthMiddleware<S>;
|
||||||
|
|
||||||
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
|
SessionAuthMiddleware { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tower Service that validates X-Session-Id before forwarding.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionAuthMiddleware<S> {
|
||||||
|
inner: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, ReqBody> Service<Request<ReqBody>> for SessionAuthMiddleware<S>
|
||||||
|
where
|
||||||
|
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
||||||
|
S::Future: Send + 'static,
|
||||||
|
ReqBody: Send + 'static,
|
||||||
|
{
|
||||||
|
type Response = S::Response;
|
||||||
|
type Error = S::Error;
|
||||||
|
type Future =
|
||||||
|
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||||
|
|
||||||
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||||
|
self.inner.poll_ready(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||||
|
let session_id = req
|
||||||
|
.headers()
|
||||||
|
.get("X-Session-Id")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if session_id.as_deref() != Some("valid-session") {
|
||||||
|
// In production, this validates against the store
|
||||||
|
return Box::pin(async move {
|
||||||
|
Ok((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"missing or invalid X-Session-Id header",
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fut = self.inner.call(req);
|
||||||
|
Box::pin(fut)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! CORS layer factory for the daemon HTTP server.
|
||||||
|
|
||||||
|
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
|
||||||
|
|
||||||
|
/// Return a permissive CorsLayer for local daemon IPC.
|
||||||
|
pub fn default_cors_layer() -> CorsLayer {
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::any())
|
||||||
|
.allow_methods([
|
||||||
|
"GET".parse().unwrap(),
|
||||||
|
"POST".parse().unwrap(),
|
||||||
|
"PUT".parse().unwrap(),
|
||||||
|
"DELETE".parse().unwrap(),
|
||||||
|
"PATCH".parse().unwrap(),
|
||||||
|
"OPTIONS".parse().unwrap(),
|
||||||
|
])
|
||||||
|
.allow_headers(AllowHeaders::any())
|
||||||
|
.expose_headers([
|
||||||
|
"Content-Type".parse().unwrap(),
|
||||||
|
"X-Session-Id".parse().unwrap(),
|
||||||
|
"X-Request-Id".parse().unwrap(),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! Axum middleware tower for the HTTP API layer.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod cors;
|
||||||
|
pub mod rate_limit;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Simple in-memory rate limiter for Axum.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// In-memory sliding-window rate limiter.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RateLimiter {
|
||||||
|
windows: Mutex<HashMap<String, Vec<i64>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
RateLimiter {
|
||||||
|
windows: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_rate_limit(
|
||||||
|
&self,
|
||||||
|
client_id: &str,
|
||||||
|
max_requests: u32,
|
||||||
|
window_secs: u64,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let cutoff = now.saturating_sub(window_secs as i64);
|
||||||
|
let mut windows = self.windows.lock().map_err(|e| {
|
||||||
|
anyhow::anyhow!("rate limiter lock poisoned: {e}")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let timestamps = windows.entry(client_id.to_string()).or_insert_with(Vec::new);
|
||||||
|
timestamps.retain(|&ts| ts >= cutoff);
|
||||||
|
|
||||||
|
if timestamps.len() >= max_requests as usize {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.push(now);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&self) -> anyhow::Result<()> {
|
||||||
|
let mut windows = self
|
||||||
|
.windows
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?;
|
||||||
|
windows.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimiter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-55
@@ -1,62 +1,35 @@
|
|||||||
//! JSON file–backed `AppConfigRepository` implementation.
|
//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection.
|
||||||
//!
|
|
||||||
//! Stores `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`.
|
|
||||||
//! On load, auto-detects Claude credentials from the environment or from
|
|
||||||
//! `~/.claude/settings.json` and merges them into the provider map.
|
|
||||||
//!
|
|
||||||
//! ## Auto-Detection Flow
|
|
||||||
//! 1. Load `app_config.json` from disk (or use defaults if absent)
|
|
||||||
//! 2. Merge any default providers not present in the loaded config
|
|
||||||
//! 3. Detect Claude credentials from `~/.claude/settings.json` or env vars
|
|
||||||
//! 4. If Claude detected, add "claude" provider + model roles, set as default
|
|
||||||
//!
|
|
||||||
//! ## Atomicity
|
|
||||||
//! Writes use `write_json_atomic` (temp file + rename) to prevent corruption.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use zesdex_utils::write_json_atomic;
|
use zesdex_domain::cms::{AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError};
|
||||||
|
|
||||||
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
|
use crate::utils::write_json_atomic;
|
||||||
use crate::domain::error::RepositoryError;
|
|
||||||
use crate::domain::repository::AppConfigRepository;
|
|
||||||
|
|
||||||
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
|
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
|
||||||
///
|
|
||||||
/// On load, auto-detects Claude credentials and merges them into the
|
|
||||||
/// provider map (see module docs for the full flow).
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct JsonAppConfigRepository;
|
pub struct JsonAppConfigRepository;
|
||||||
|
|
||||||
impl JsonAppConfigRepository {
|
impl JsonAppConfigRepository {
|
||||||
/// Create a new repository instance (zero allocation).
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal helper: the `env` block inside `~/.claude/settings.json`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct ClaudeEnv {
|
struct ClaudeEnv {
|
||||||
/// Override URL for the Anthropic API.
|
|
||||||
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
||||||
anthropic_base_url: Option<String>,
|
anthropic_base_url: Option<String>,
|
||||||
/// Override API key for the Anthropic API.
|
|
||||||
#[serde(alias = "ANTHROPIC_API_KEY")]
|
#[serde(alias = "ANTHROPIC_API_KEY")]
|
||||||
anthropic_api_key: Option<String>,
|
anthropic_api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal helper: top-level structure of `~/.claude/settings.json`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct ClaudeSettings {
|
struct ClaudeSettings {
|
||||||
/// Environment variable overrides block.
|
|
||||||
env: Option<ClaudeEnv>,
|
env: Option<ClaudeEnv>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
|
|
||||||
///
|
|
||||||
/// Returns `(base_url, api_key)` if both are present, or `None`.
|
|
||||||
fn claude_credentials_from_file() -> Option<(String, String)> {
|
fn claude_credentials_from_file() -> Option<(String, String)> {
|
||||||
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
||||||
let content = std::fs::read_to_string(&path).ok()?;
|
let content = std::fs::read_to_string(&path).ok()?;
|
||||||
@@ -67,18 +40,12 @@ fn claude_credentials_from_file() -> Option<(String, String)> {
|
|||||||
Some((base_url, key))
|
Some((base_url, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to read Claude credentials from the process environment variables.
|
|
||||||
///
|
|
||||||
/// Returns `(ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY)` if both are set, or `None`.
|
|
||||||
fn claude_credentials_from_env() -> Option<(String, String)> {
|
fn claude_credentials_from_env() -> Option<(String, String)> {
|
||||||
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
|
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
|
||||||
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
|
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
|
||||||
Some((base_url, key))
|
Some((base_url, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a `ProviderConfig` for the Claude provider, checking both sources.
|
|
||||||
///
|
|
||||||
/// Flow: try ~/.claude/settings.json → fall back to env vars → return None if neither found.
|
|
||||||
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
||||||
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
||||||
Some(ProviderConfig {
|
Some(ProviderConfig {
|
||||||
@@ -90,33 +57,21 @@ fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfigRepository for JsonAppConfigRepository {
|
impl AppConfigRepository for JsonAppConfigRepository {
|
||||||
/// Load `AppConfig` from `<base_dir>/app_config.json`.
|
|
||||||
///
|
|
||||||
/// Flow: read file → parse JSON → merge default providers → auto-detect Claude → return.
|
|
||||||
///
|
|
||||||
/// If the file is missing, returns `AppConfig::default()`.
|
|
||||||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
|
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
|
||||||
tracing::debug!("loading app_config from {base_dir:?}");
|
|
||||||
let path = base_dir.join("app_config.json");
|
let path = base_dir.join("app_config.json");
|
||||||
// Try to read and parse the config file
|
|
||||||
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
||||||
Ok(s) => serde_json::from_str(&s)?,
|
Ok(s) => serde_json::from_str(&s)?,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
tracing::info!("app_config.json not found, using defaults");
|
|
||||||
AppConfig::default()
|
AppConfig::default()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => return Err(RepositoryError::Io(e)),
|
||||||
return Err(RepositoryError::Io(e));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase 1: merge default providers that are not yet in the loaded config
|
|
||||||
let defaults = AppConfig::default();
|
let defaults = AppConfig::default();
|
||||||
for (name, provider) in defaults.providers {
|
for (name, provider) in defaults.providers {
|
||||||
cfg.providers.entry(name).or_insert(provider);
|
cfg.providers.entry(name).or_insert(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: auto-detect Claude provider from file or environment
|
|
||||||
if let Some(claude_provider) = detect_claude_settings_provider() {
|
if let Some(claude_provider) = detect_claude_settings_provider() {
|
||||||
cfg.providers
|
cfg.providers
|
||||||
.entry("claude".to_string())
|
.entry("claude".to_string())
|
||||||
@@ -139,7 +94,6 @@ impl AppConfigRepository for JsonAppConfigRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set as default provider only if user hasn't picked a custom default
|
|
||||||
if cfg.default_provider == defaults.default_provider {
|
if cfg.default_provider == defaults.default_provider {
|
||||||
cfg.default_provider = "claude".to_string();
|
cfg.default_provider = "claude".to_string();
|
||||||
cfg.default_model = "claude-opus-4-8".to_string();
|
cfg.default_model = "claude-opus-4-8".to_string();
|
||||||
@@ -149,15 +103,10 @@ impl AppConfigRepository for JsonAppConfigRepository {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist `AppConfig` to `<base_dir>/app_config.json`.
|
|
||||||
///
|
|
||||||
/// Flow: create base dir → atomic JSON write → log success.
|
|
||||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
|
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
|
||||||
tracing::debug!("saving app_config to {base_dir:?}");
|
|
||||||
std::fs::create_dir_all(base_dir)?;
|
std::fs::create_dir_all(base_dir)?;
|
||||||
let path = base_dir.join("app_config.json");
|
let path = base_dir.join("app_config.json");
|
||||||
write_json_atomic(&path, config, None)?;
|
write_json_atomic(&path, config, None)?;
|
||||||
tracing::debug!("app_config saved to '{}'", path.display());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! JSON file–backed `ConversationRepository`.
|
||||||
|
//! Stores `Conversation` at `<session_dir>/conversation.json`.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use zesdex_domain::cms::{Conversation, ConversationRepository, RepositoryError};
|
||||||
|
|
||||||
|
use crate::utils::write_json_atomic;
|
||||||
|
|
||||||
|
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JsonConversationRepository;
|
||||||
|
|
||||||
|
impl JsonConversationRepository {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConversationRepository for JsonConversationRepository {
|
||||||
|
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError> {
|
||||||
|
let path = session_dir.join("conversation.json");
|
||||||
|
let data = std::fs::read_to_string(&path)?;
|
||||||
|
let conv: Conversation = serde_json::from_str(&data)?;
|
||||||
|
Ok(conv)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> {
|
||||||
|
std::fs::create_dir_all(session_dir)?;
|
||||||
|
let path = session_dir.join("conversation.json");
|
||||||
|
write_json_atomic(&path, conversation, None)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-39
@@ -1,41 +1,23 @@
|
|||||||
//! JSONL file–backed `EditLogRepository` implementation.
|
//! JSONL file–backed `EditLogRepository`.
|
||||||
//!
|
//! Stores `EditLog` as an append-only newline-delimited JSON file.
|
||||||
//! Stores `EditLog` as an append-only newline-delimited JSON file at
|
|
||||||
//! `<session_dir>/edits.jsonl`. New entries are appended to the file,
|
|
||||||
//! never rewritten, making this a durable write-ahead log.
|
|
||||||
//!
|
|
||||||
//! ## Data Flow
|
|
||||||
//! - `open()`: read existing JSONL lines from disk → parse into in-memory Vec
|
|
||||||
//! - `append()`: serialize entry as JSON line → fsync to disk → push to memory
|
|
||||||
//!
|
|
||||||
//! ## Memory Management
|
|
||||||
//! The in-memory cache is capped at `MAX_MEMORY_ENTRIES` (10K) to prevent
|
|
||||||
//! unbounded growth in long-running sessions. Old entries are evicted
|
|
||||||
//! from memory but remain on disk.
|
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES};
|
use zesdex_domain::cms::{EditLog, EditLogEntry, EditLogRepository, RepositoryError};
|
||||||
use crate::domain::error::RepositoryError;
|
|
||||||
use crate::domain::repository::EditLogRepository;
|
/// Maximum number of edit entries held in memory at once.
|
||||||
|
const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||||
|
|
||||||
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
|
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
|
||||||
///
|
|
||||||
/// Append-only JSONL format: each entry is one JSON line.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct JsonlEditLogRepository;
|
pub struct JsonlEditLogRepository;
|
||||||
|
|
||||||
impl JsonlEditLogRepository {
|
impl JsonlEditLogRepository {
|
||||||
/// Create a new repository instance.
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read existing entries from disk into memory, capped at `MAX_MEMORY_ENTRIES`.
|
|
||||||
///
|
|
||||||
/// Flow: open file → read lines → parse JSON → cap at MAX_MEMORY_ENTRIES → return.
|
|
||||||
/// Silently skips malformed lines.
|
|
||||||
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
||||||
let Ok(file) = std::fs::File::open(path) else {
|
let Ok(file) = std::fs::File::open(path) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -58,19 +40,12 @@ impl JsonlEditLogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EditLogRepository for JsonlEditLogRepository {
|
impl EditLogRepository for JsonlEditLogRepository {
|
||||||
/// Open (or initialise) the edit log for a session directory.
|
|
||||||
///
|
|
||||||
/// Flow: ensure parent dir exists → load existing entries from disk →
|
|
||||||
/// touch file if absent → return in-memory EditLog.
|
|
||||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
|
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
|
||||||
tracing::debug!("opening edit log for {session_dir:?}");
|
|
||||||
let path = session_dir.join("edits.jsonl");
|
let path = session_dir.join("edits.jsonl");
|
||||||
// Ensure parent dir exists
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
let entries = Self::load_from_disk(&path);
|
let entries = Self::load_from_disk(&path);
|
||||||
// Touch the file if it doesn't exist yet
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
std::fs::OpenOptions::new()
|
std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
@@ -80,12 +55,12 @@ impl EditLogRepository for JsonlEditLogRepository {
|
|||||||
Ok(EditLog { entries })
|
Ok(EditLog { entries })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append one entry to the edit log and persist immediately (write-through).
|
fn append(
|
||||||
///
|
&self,
|
||||||
/// Flow: serialize entry → open file (append mode) → write line → fsync →
|
session_dir: &Path,
|
||||||
/// push to in-memory Vec → evict oldest if over cap.
|
log: &mut EditLog,
|
||||||
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<(), RepositoryError> {
|
entry: EditLogEntry,
|
||||||
tracing::debug!("appending edit log entry for {session_dir:?}");
|
) -> Result<(), RepositoryError> {
|
||||||
let path = session_dir.join("edits.jsonl");
|
let path = session_dir.join("edits.jsonl");
|
||||||
let line = serde_json::to_string(&entry)? + "\n";
|
let line = serde_json::to_string(&entry)? + "\n";
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -100,14 +75,12 @@ impl EditLogRepository for JsonlEditLogRepository {
|
|||||||
file.sync_all()?;
|
file.sync_all()?;
|
||||||
}
|
}
|
||||||
log.entries.push(entry);
|
log.entries.push(entry);
|
||||||
// Enforce in-memory cap
|
|
||||||
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
||||||
log.entries.remove(0);
|
log.entries.remove(0);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a cloned copy of all in-memory entries for inspection.
|
|
||||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
||||||
log.entries.clone()
|
log.entries.clone()
|
||||||
}
|
}
|
||||||
+9
-74
@@ -1,60 +1,22 @@
|
|||||||
//! Markdown file–backed `MemoryRepository` implementation.
|
//! Markdown file–backed `MemoryRepository`.
|
||||||
//!
|
|
||||||
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
|
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
|
||||||
//! Filenames are derived from the memory's `name` via slugification
|
|
||||||
//! (see `Memory::slugify`).
|
|
||||||
//!
|
|
||||||
//! ## File Format
|
|
||||||
//! ```text
|
|
||||||
//! ---
|
|
||||||
//! name: my-memory
|
|
||||||
//! description: A useful lesson
|
|
||||||
//! kind: lesson
|
|
||||||
//! created_at: 1700000000
|
|
||||||
//! updated_at: 1700000000
|
|
||||||
//! lifecycle: active
|
|
||||||
//! outcome: success
|
|
||||||
//! scope: global
|
|
||||||
//! before: old content
|
|
||||||
//! after: new content
|
|
||||||
//! provenances: tool1, tool2
|
|
||||||
//! ---
|
|
||||||
//! Free-form markdown content body...
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! ## Data Flow
|
|
||||||
//! - `list()`: scan `*.md` files (excluding `MEMORY.md`), return slugs
|
|
||||||
//! - `load()`: read file → strip `---\n...\n---\n` frontmatter → parse fields
|
|
||||||
//! - `save()`: build frontmatter → write to temp file → rename atomically
|
|
||||||
//! - `delete()`: remove file from disk
|
|
||||||
//!
|
|
||||||
//! ## Atomicity
|
|
||||||
//! Writes use temp-file + rename + parent-directory fsync for crash safety.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::domain::error::RepositoryError;
|
use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError};
|
||||||
use crate::domain::memory::Memory;
|
|
||||||
use crate::domain::repository::MemoryRepository;
|
|
||||||
|
|
||||||
/// File-based `MemoryRepository` that stores memories as `.md` files with frontmatter.
|
/// File-based `MemoryRepository` that stores memories as `.md` files with
|
||||||
///
|
/// YAML-ish frontmatter.
|
||||||
/// Each file has a YAML-ish `---\n...\n---\n` header followed by free-form
|
|
||||||
/// markdown content. Filenames are derived from `Memory.name` via slugification.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct MarkdownMemoryRepository;
|
pub struct MarkdownMemoryRepository;
|
||||||
|
|
||||||
impl MarkdownMemoryRepository {
|
impl MarkdownMemoryRepository {
|
||||||
/// Create a new repository instance.
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the YAML-ish frontmatter string for a memory.
|
|
||||||
///
|
|
||||||
/// Only non-empty optional fields are included in the output.
|
|
||||||
fn build_frontmatter(memory: &Memory) -> String {
|
fn build_frontmatter(memory: &Memory) -> String {
|
||||||
let outcome_line = memory
|
let outcome_line = memory
|
||||||
.outcome
|
.outcome
|
||||||
@@ -99,26 +61,19 @@ impl MarkdownMemoryRepository {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse frontmatter lines into a `HashMap<String, String>`.
|
|
||||||
///
|
|
||||||
/// Flow: split lines → for each non-empty line, split on first ':' → insert.
|
|
||||||
/// Malformed lines (no ':') are silently skipped.
|
|
||||||
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
|
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
|
||||||
front
|
front
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|l| {
|
.filter_map(|l| {
|
||||||
let mut it = l.splitn(2, ':');
|
let mut it = l.splitn(2, ':');
|
||||||
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string()))
|
Some((
|
||||||
|
it.next()?.trim().to_string(),
|
||||||
|
it.next()?.trim().to_string(),
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a memory file's full contents (frontmatter + body) into a `Memory`.
|
|
||||||
///
|
|
||||||
/// Flow: strip `---\n` prefix → split on `\n---\n` → parse front half with
|
|
||||||
/// `parse_frontmatter()` → use back half as content body → build Memory.
|
|
||||||
///
|
|
||||||
/// Returns `InvalidData` error if the frontmatter delimiter is missing.
|
|
||||||
fn parse(content: &str) -> std::io::Result<Memory> {
|
fn parse(content: &str) -> std::io::Result<Memory> {
|
||||||
let content = content.strip_prefix("---\n").unwrap_or(content);
|
let content = content.strip_prefix("---\n").unwrap_or(content);
|
||||||
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
|
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
|
||||||
@@ -157,21 +112,13 @@ impl MarkdownMemoryRepository {
|
|||||||
provenances: front
|
provenances: front
|
||||||
.get("provenances")
|
.get("provenances")
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|s| {
|
.map(|s| s.split(", ").map(String::from).collect())
|
||||||
s.split(", ")
|
|
||||||
.map(std::string::ToString::to_string)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryRepository for MarkdownMemoryRepository {
|
impl MemoryRepository for MarkdownMemoryRepository {
|
||||||
/// List all memory slugs in `memory_dir` by scanning `*.md` files.
|
|
||||||
///
|
|
||||||
/// Flow: read directory entries → filter `*.md` → strip extension → exclude MEMORY.md.
|
|
||||||
/// Returns empty Vec if the directory doesn't exist.
|
|
||||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
|
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
|
||||||
let Ok(entries) = std::fs::read_dir(memory_dir) else {
|
let Ok(entries) = std::fs::read_dir(memory_dir) else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -181,7 +128,6 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
|||||||
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
|
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
let name = e.file_name().to_string_lossy().to_string();
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
// Skip special summary file
|
|
||||||
if name == "MEMORY.md" {
|
if name == "MEMORY.md" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -192,11 +138,7 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
|||||||
Ok(slugs)
|
Ok(slugs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a single `Memory` by name from `memory_dir`.
|
|
||||||
///
|
|
||||||
/// Flow: resolve file path → read file → parse frontmatter + body → return Memory.
|
|
||||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
|
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
|
||||||
tracing::debug!("loading memory '{name}'");
|
|
||||||
let path = Memory::path(memory_dir, name);
|
let path = Memory::path(memory_dir, name);
|
||||||
let content = std::fs::read_to_string(&path)?;
|
let content = std::fs::read_to_string(&path)?;
|
||||||
let memory = Self::parse(&content)
|
let memory = Self::parse(&content)
|
||||||
@@ -228,7 +170,6 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
|||||||
let _ = d.sync_all();
|
let _ = d.sync_all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::debug!("memory saved to '{}'", path.display());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +177,6 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
|||||||
let path = Memory::path(memory_dir, name);
|
let path = Memory::path(memory_dir, name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::remove_file(&path)?;
|
std::fs::remove_file(&path)?;
|
||||||
tracing::debug!("memory deleted: '{}'", path.display());
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"memory '{name}' not found at '{}', skipping delete",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! File-based repository implementations for CMS domain entities.
|
||||||
|
//!
|
||||||
|
//! ## Repositories
|
||||||
|
//! - `JsonSettingsRepository` — reads/writes `settings.json`
|
||||||
|
//! - `JsonAppConfigRepository` — reads/writes `app_config.json`
|
||||||
|
//! - `JsonConversationRepository` — reads/writes `conversation.json`
|
||||||
|
//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files
|
||||||
|
//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl`
|
||||||
|
//! - `FileRewindBlobRepository` — stores blobs as files
|
||||||
|
|
||||||
|
pub mod app_config_repo;
|
||||||
|
pub mod conversation_repo;
|
||||||
|
pub mod edit_log_repo;
|
||||||
|
pub mod memory_repo;
|
||||||
|
pub mod rewind_blob_repo;
|
||||||
|
pub mod settings_repo;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! Filesystem-backed `RewindBlobRepository`.
|
||||||
|
//! Blob bytes are stored at `<session_dir>/blobs/<hex(key)>.bin`.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zesdex_domain::cms::{RepositoryError, RewindBlobRepository};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct BlobIndexEntry {
|
||||||
|
key: String,
|
||||||
|
mime_type: Option<String>,
|
||||||
|
created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concrete filesystem rewind-blob repository.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FileRewindBlobRepository;
|
||||||
|
|
||||||
|
impl FileRewindBlobRepository {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
|
||||||
|
session_dir.join("blobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
|
||||||
|
Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_path(session_dir: &Path) -> std::path::PathBuf {
|
||||||
|
Self::blobs_dir(session_dir).join("index.jsonl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RewindBlobRepository for FileRewindBlobRepository {
|
||||||
|
fn store_blob(
|
||||||
|
&self,
|
||||||
|
session_dir: &Path,
|
||||||
|
blob_key: &str,
|
||||||
|
data: &[u8],
|
||||||
|
mime_type: Option<&str>,
|
||||||
|
) -> Result<(), RepositoryError> {
|
||||||
|
let blobs_dir = Self::blobs_dir(session_dir);
|
||||||
|
std::fs::create_dir_all(&blobs_dir)?;
|
||||||
|
|
||||||
|
let path = Self::blob_file_path(session_dir, blob_key);
|
||||||
|
let tmp = path.with_extension("bin.tmp");
|
||||||
|
std::fs::write(&tmp, data)?;
|
||||||
|
let f = std::fs::File::open(&tmp)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
|
||||||
|
let entry = BlobIndexEntry {
|
||||||
|
key: blob_key.to_string(),
|
||||||
|
mime_type: mime_type.map(String::from),
|
||||||
|
created_at: chrono::Utc::now().timestamp_millis(),
|
||||||
|
};
|
||||||
|
let index_path = Self::index_path(session_dir);
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&index_path)?;
|
||||||
|
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieve_blob(
|
||||||
|
&self,
|
||||||
|
session_dir: &Path,
|
||||||
|
blob_key: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, RepositoryError> {
|
||||||
|
let path = Self::blob_file_path(session_dir, blob_key);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let data = std::fs::read(&path)?;
|
||||||
|
Ok(Some(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError> {
|
||||||
|
let index_path = Self::index_path(session_dir);
|
||||||
|
let Ok(content) = std::fs::read_to_string(&index_path) else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut first_seen_order: Vec<String> = Vec::new();
|
||||||
|
let mut latest_by_key: std::collections::HashMap<String, BlobIndexEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for line in content.lines() {
|
||||||
|
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !latest_by_key.contains_key(&entry.key) {
|
||||||
|
first_seen_order.push(entry.key.clone());
|
||||||
|
}
|
||||||
|
latest_by_key.insert(entry.key.clone(), entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries: Vec<BlobIndexEntry> = first_seen_order
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|k| latest_by_key.get(&k).cloned())
|
||||||
|
.collect();
|
||||||
|
entries.sort_by_key(|e| e.created_at);
|
||||||
|
Ok(entries.into_iter().map(|e| e.key).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! JSON file–backed `SettingsRepository`.
|
||||||
|
//! Path: `<base_dir>/settings.json`
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use zesdex_domain::cms::{RepositoryError, Settings, SettingsRepository};
|
||||||
|
|
||||||
|
use crate::utils::write_json_atomic;
|
||||||
|
|
||||||
|
/// Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JsonSettingsRepository;
|
||||||
|
|
||||||
|
impl JsonSettingsRepository {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsRepository for JsonSettingsRepository {
|
||||||
|
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError> {
|
||||||
|
let path = base_dir.join("settings.json");
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(s) => match serde_json::from_str(&s) {
|
||||||
|
Ok(settings) => Ok(settings),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("settings.json at '{:?}' failed to parse ({e}); falling back to defaults", path);
|
||||||
|
Ok(Settings::default())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
Ok(Settings::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(RepositoryError::Io(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> {
|
||||||
|
std::fs::create_dir_all(base_dir)?;
|
||||||
|
let path = base_dir.join("settings.json");
|
||||||
|
write_json_atomic(&path, settings, None)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Filesystem-backed repository implementations for IAM entities.
|
||||||
|
//!
|
||||||
|
//! Implements domain repository traits using JSON file persistence for
|
||||||
|
//! sessions, OAuth tokens, and PID-file session locks.
|
||||||
|
|
||||||
|
pub mod oauth_repo;
|
||||||
|
pub mod session_lock_repo;
|
||||||
|
pub mod session_repo;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! Filesystem-backed `OAuthRepository` implementation.
|
||||||
|
//!
|
||||||
|
//! Tokens are stored as a single JSON file with write-then-rename + fsync
|
||||||
|
//! for crash safety, and restrictive owner-only mode `0o600` on Unix.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use zesdex_domain::auth::{OAuthRepository, OAuthToken, RepositoryError};
|
||||||
|
|
||||||
|
use crate::utils::write_json_atomic;
|
||||||
|
|
||||||
|
/// Concrete filesystem OAuth token repository.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FileSystemOAuthRepository;
|
||||||
|
|
||||||
|
impl FileSystemOAuthRepository {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
FileSystemOAuthRepository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthRepository for FileSystemOAuthRepository {
|
||||||
|
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
write_json_atomic(path, token, Some(0o600))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let data = std::fs::read_to_string(path)?;
|
||||||
|
let token: OAuthToken = serde_json::from_str(&data)?;
|
||||||
|
Ok(Some(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//! Filesystem-backed `SessionLockRepository` implementation using a PID file
|
||||||
|
//! (`<session_dir>/.lock`) with atomic `O_CREAT|O_EXCL` acquisition.
|
||||||
|
|
||||||
|
use std::convert::TryInto;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use zesdex_domain::auth::{RepositoryError, SessionLockRepository};
|
||||||
|
|
||||||
|
/// Concrete filesystem session-lock repository.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FileSystemSessionLockRepository;
|
||||||
|
|
||||||
|
impl FileSystemSessionLockRepository {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
FileSystemSessionLockRepository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionLockRepository for FileSystemSessionLockRepository {
|
||||||
|
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError> {
|
||||||
|
let path = session_dir.join(".lock");
|
||||||
|
let pid = std::process::id();
|
||||||
|
|
||||||
|
match std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
Ok(mut file) => {
|
||||||
|
write!(file, "{pid}")?;
|
||||||
|
file.sync_all()?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||||
|
Err(e) => return Err(RepositoryError::Io(e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
if let Ok(existing_pid) = content.trim().parse::<u32>() {
|
||||||
|
if self.is_alive(existing_pid) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp = path.with_extension("lock.tmp");
|
||||||
|
{
|
||||||
|
let mut tmp_file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&tmp)?;
|
||||||
|
write!(tmp_file, "{pid}")?;
|
||||||
|
tmp_file.sync_all()?;
|
||||||
|
}
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
|
||||||
|
let path = session_dir.join(".lock");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alive(&self, pid: u32) -> bool {
|
||||||
|
let pid_signed: i32 = match pid.try_into() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-36
@@ -2,40 +2,18 @@
|
|||||||
//!
|
//!
|
||||||
//! Each session is stored as `<base_dir>/sessions/<id>/session.json`.
|
//! Each session is stored as `<base_dir>/sessions/<id>/session.json`.
|
||||||
//! Writes use a write-then-rename + fsync pattern for crash safety.
|
//! Writes use a write-then-rename + fsync pattern for crash safety.
|
||||||
//!
|
|
||||||
//! # Flow
|
|
||||||
//!
|
|
||||||
//! - **`list_sessions`** — enumerate `<base_dir>/sessions/` subdirectories,
|
|
||||||
//! attempt `load_session` on each (silently skipping failures).
|
|
||||||
//! - **`load_session`** — reads and deserialises `session.json`.
|
|
||||||
//! - **`save_session`** — creates session directory, writes JSON atomically.
|
|
||||||
//! - **`delete_session`** — removes the session directory.
|
|
||||||
//!
|
|
||||||
//! # Security
|
|
||||||
//!
|
|
||||||
//! Session IDs are validated at construction via [`SessionId::new`], so
|
|
||||||
//! directory-traversal attacks are prevented by the type system — no
|
|
||||||
//! per-method checks needed.
|
|
||||||
//!
|
|
||||||
//! # Components
|
|
||||||
//!
|
|
||||||
//! - `FileSystemSessionRepository` — stateless singleton implementing `SessionRepository`
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tracing;
|
|
||||||
|
|
||||||
use zesdex_entities::domain::auth::SessionId;
|
use zesdex_domain::auth::{RepositoryError, Session, SessionId, SessionRepository};
|
||||||
use zesdex_utils::write_json_atomic;
|
|
||||||
|
|
||||||
use crate::domain::error::RepositoryError;
|
use crate::utils::write_json_atomic;
|
||||||
use crate::domain::repository::SessionRepository;
|
|
||||||
use crate::domain::session::Session;
|
|
||||||
|
|
||||||
/// Concrete filesystem session repository.
|
/// Concrete filesystem session repository.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct FileSystemSessionRepository;
|
pub struct FileSystemSessionRepository;
|
||||||
|
|
||||||
impl FileSystemSessionRepository {
|
impl FileSystemSessionRepository {
|
||||||
/// Create a new filesystem session repository.
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
FileSystemSessionRepository
|
FileSystemSessionRepository
|
||||||
}
|
}
|
||||||
@@ -47,7 +25,6 @@ impl SessionRepository for FileSystemSessionRepository {
|
|||||||
let entries = match std::fs::read_dir(&sessions_dir) {
|
let entries = match std::fs::read_dir(&sessions_dir) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
tracing::warn!(path = %sessions_dir.display(), "sessions directory not found");
|
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
Err(e) => return Err(RepositoryError::Io(e)),
|
Err(e) => return Err(RepositoryError::Io(e)),
|
||||||
@@ -58,45 +35,43 @@ impl SessionRepository for FileSystemSessionRepository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
// Directory names from UUIDs are always valid session IDs.
|
|
||||||
if let Ok(sid) = SessionId::new(&name) {
|
if let Ok(sid) = SessionId::new(&name) {
|
||||||
if let Ok(session) = self.load_session(base_dir, &sid) {
|
if let Ok(session) = self.load_session(base_dir, &sid) {
|
||||||
sessions.push(session);
|
sessions.push(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::debug!(count = sessions.len(), "listed sessions");
|
|
||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError> {
|
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError> {
|
||||||
let path = base_dir.join("sessions").join(id.as_str()).join("session.json");
|
let path = base_dir
|
||||||
|
.join("sessions")
|
||||||
|
.join(id.as_str())
|
||||||
|
.join("session.json");
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(RepositoryError::NotFound(format!(
|
return Err(RepositoryError::NotFound(format!(
|
||||||
"session not found: {}",
|
"session not found: {}",
|
||||||
id.as_str()
|
id.as_str()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
tracing::debug!(session_id = %id, path = %path.display(), "loading session");
|
let data = std::fs::read_to_string(&path)?;
|
||||||
let data = std::fs::read_to_string(&path)?; // → RepositoryError
|
let session: Session = serde_json::from_str(&data)?;
|
||||||
let session: Session = serde_json::from_str(&data)?; // → RepositoryError
|
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError> {
|
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError> {
|
||||||
let dir = session.session_dir(base_dir);
|
let dir = session.session_dir(base_dir);
|
||||||
std::fs::create_dir_all(&dir)?; // → RepositoryError
|
std::fs::create_dir_all(&dir)?;
|
||||||
let path = dir.join("session.json");
|
let path = dir.join("session.json");
|
||||||
tracing::debug!(session_id = %session.id, path = %path.display(), "saving session");
|
|
||||||
write_json_atomic(&path, session, None)?;
|
write_json_atomic(&path, session, None)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError> {
|
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError> {
|
||||||
let dir = base_dir.join("sessions").join(id.as_str());
|
let dir = base_dir.join("sessions").join(id.as_str());
|
||||||
tracing::debug!(session_id = %id, path = %dir.display(), "deleting session");
|
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
std::fs::remove_dir_all(&dir)?; // → RepositoryError
|
std::fs::remove_dir_all(&dir)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user