diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9931ccc..041156c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,11 @@ jobs: with: components: clippy - - name: Build - run: cargo build --release + - name: Build workspace + run: cargo build --release --workspace - - name: Test - run: cargo test + - name: Test workspace + run: cargo test --workspace - - name: Clippy - run: cargo clippy -- -D warnings + - name: Clippy workspace + run: cargo clippy --workspace -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2844879..bc2cc24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,10 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Build - run: cargo build --release + run: cargo build --release --workspace - name: Test - run: cargo test + run: cargo test --workspace release: name: Semantic Release diff --git a/Cargo.lock b/Cargo.lock index ade95bd..3cd27ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -114,12 +126,82 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -171,6 +253,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -191,9 +282,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", @@ -620,6 +711,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1096,9 +1188,9 @@ checksum = "43503cc176394dd30a6525f5f36e838339b8b5619be33ed9a7783841580a97b6" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -1224,6 +1316,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -1247,6 +1345,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1445,9 +1544,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7" +checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" dependencies = [ "crossbeam-deque", "globset", @@ -1610,6 +1709,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1790,6 +1904,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" @@ -1937,6 +2057,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1954,6 +2084,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2118,12 +2257,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pastey" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2475,6 +2635,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -2614,9 +2777,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2626,9 +2789,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3030,6 +3193,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3151,9 +3325,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -3180,6 +3354,18 @@ dependencies = [ "bstr", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3689,6 +3875,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3702,6 +3889,7 @@ dependencies = [ "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", "tower", "tower-layer", @@ -3727,6 +3915,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4481,8 +4670,8 @@ dependencies = [ ] [[package]] -name = "zesdex" -version = "1.14.0" +name = "zesdex-backend" +version = "1.13.0" dependencies = [ "anyhow", "base64", @@ -4521,6 +4710,145 @@ dependencies = [ "url", "uuid", "webbrowser", + "zesdex-cms", + "zesdex-dto", + "zesdex-entities", + "zesdex-iam", + "zesdex-ipc", + "zesdex-libs", + "zesdex-middleware", + "zesdex-utils", +] + +[[package]] +name = "zesdex-cms" +version = "1.13.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "serde", + "serde_json", + "tracing", + "uuid", + "zesdex-entities", + "zesdex-utils", +] + +[[package]] +name = "zesdex-dto" +version = "1.13.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tracing", + "zesdex-entities", +] + +[[package]] +name = "zesdex-entities" +version = "1.13.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "dirs", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "zesdex-iam" +version = "1.13.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "tracing", + "url", + "uuid", + "zesdex-entities", + "zesdex-utils", +] + +[[package]] +name = "zesdex-ipc" +version = "1.13.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tracing", + "zesdex-dto", + "zesdex-entities", +] + +[[package]] +name = "zesdex-libs" +version = "1.13.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "chrono", + "jsonwebtoken", + "rand_core 0.6.4", + "rusqlite", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", + "zesdex-cms", + "zesdex-entities", + "zesdex-iam", + "zesdex-middleware", + "zesdex-utils", +] + +[[package]] +name = "zesdex-middleware" +version = "1.13.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "serde", + "serde_json", + "tower", + "tower-http", + "zesdex-entities", + "zesdex-utils", +] + +[[package]] +name = "zesdex-utils" +version = "1.13.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "dirs", + "hex", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 10ceaa5..1c27429 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,23 @@ -[package] -name = "zesdex" -version = "1.14.0" +[workspace] +resolver = "2" +members = [ + "crates/zesdex-entities", + "crates/zesdex-utils", + "crates/zesdex-dto", + "crates/zesdex-ipc", + "crates/zesdex-iam", + "crates/zesdex-cms", + "crates/zesdex-middleware", + "crates/zesdex-libs", + "crates/zesdex-backend", +] + +[workspace.package] +version = "1.13.0" edition = "2021" authors = ["asepharyana "] -# Treat all warnings as errors, set strict clippy levels -[lints.rust] +[workspace.lints.rust] unused = "deny" dead_code = "deny" unreachable_code = "deny" @@ -17,49 +29,55 @@ deprecated = "deny" trivial_casts = "deny" trivial_numeric_casts = "deny" -[lints.clippy] +[workspace.lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -2 } -[dependencies] -ratatui = "0.30.2" -crossterm = "0.29" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] } -reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] } -dom_smoothie = "0.18.0" -fast_html2md = "0.0.62" -scraper = "0.27.0" -url = "2" -percent-encoding = "2" +[workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml_ng = "0.10" -anyhow = "1" -include_dir = "0.7" +chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "v5"] } -dirs = "6" -futures-util = "0.3" -pulldown-cmark = { version = "0.13", default-features = false } -similar = "3" -syntect = { version = "5", default-features = false, features = ["default-fancy"] } +anyhow = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] } +ratatui = "0.30.2" +crossterm = "0.29" rusqlite = { version = "0.40", features = ["bundled"] } -ignore = "0.4" -regex = "1" -globset = "0.4" -nucleo-matcher = "0.3" -infer = "0.19" +thiserror = "1" base64 = "0.22" sha2 = "0.11" hex = "0.4" libc = "0.2" +dirs = "6" +regex = "1" +globset = "0.4" +ignore = "0.4" +nucleo-matcher = "0.3" +futures-util = "0.3" rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] } -tracing = "0.1" -chrono = { version = "0.4", features = ["serde"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -webbrowser = "1" lsp-types = "0.97" tiktoken-rs = "0.12" +similar = "3" +syntect = { version = "5", default-features = false, features = ["default-fancy"] } +pulldown-cmark = { version = "0.13", default-features = false } +infer = "0.19" +webbrowser = "1" +url = "2" +percent-encoding = "2" +dom_smoothie = "0.18.0" +fast_html2md = "0.0.62" +scraper = "0.27.0" +include_dir = "0.7" +axum = { version = "0.8", features = ["macros"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "limit"] } +argon2 = "0.5" +jsonwebtoken = "9" -[[bin]] -name = "zesdex" -path = "src/main.rs" +zesdex-entities = { path = "crates/zesdex-entities" } +zesdex-utils = { path = "crates/zesdex-utils" } +zesdex-dto = { path = "crates/zesdex-dto" } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b893b06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 +# Zesdex — Multi-stage Docker build +# =================================== +# Stage 1: Build with Rust toolchain +FROM rust:1.85-slim-bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libsqlite3-dev && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY . . + +# Build with release profile (treats warnings as errors via lints) +RUN cargo build --release -p zesdex-backend --bin zesdex + +# Stage 2: Minimal runtime image +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libsqlite3-0 && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/zesdex /usr/local/bin/zesdex + +ENV ZESDEX_DATA_DIR=/data + +VOLUME ["/data"] + +ENTRYPOINT ["/usr/local/bin/zesdex"] diff --git a/crates/zesdex-backend/Cargo.toml b/crates/zesdex-backend/Cargo.toml new file mode 100644 index 0000000..3d872fb --- /dev/null +++ b/crates/zesdex-backend/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "zesdex-backend" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +# Workspace crates +zesdex-entities = { path = "../zesdex-entities" } +zesdex-utils = { path = "../zesdex-utils" } +zesdex-dto = { path = "../zesdex-dto" } +zesdex-ipc = { path = "../zesdex-ipc" } +zesdex-iam = { path = "../zesdex-iam" } +zesdex-cms = { path = "../zesdex-cms" } +zesdex-middleware = { path = "../zesdex-middleware" } +zesdex-libs = { path = "../zesdex-libs" } + +# External deps +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true +chrono.workspace = true +uuid.workspace = true +anyhow.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +reqwest.workspace = true +ratatui.workspace = true +crossterm.workspace = true +rusqlite.workspace = true +base64.workspace = true +sha2.workspace = true +hex.workspace = true +libc.workspace = true +dirs.workspace = true +regex.workspace = true +globset.workspace = true +ignore.workspace = true +nucleo-matcher.workspace = true +futures-util.workspace = true +rmcp.workspace = true +lsp-types.workspace = true +tiktoken-rs.workspace = true +similar.workspace = true +syntect.workspace = true +pulldown-cmark.workspace = true +infer.workspace = true +webbrowser.workspace = true +url.workspace = true +percent-encoding.workspace = true +dom_smoothie.workspace = true +fast_html2md.workspace = true +scraper.workspace = true +include_dir.workspace = true + +[[bin]] +name = "zesdex" +path = "src/main.rs" + +[[bin]] +name = "seed" +path = "src/bin/seed.rs" + +[[bin]] +name = "migrate" +path = "src/bin/migrate.rs" diff --git a/src-misc/arch-reviewer-prompt.txt b/crates/zesdex-backend/src-misc/arch-reviewer-prompt.txt similarity index 100% rename from src-misc/arch-reviewer-prompt.txt rename to crates/zesdex-backend/src-misc/arch-reviewer-prompt.txt diff --git a/src-misc/auto-reviewer-prompt.txt b/crates/zesdex-backend/src-misc/auto-reviewer-prompt.txt similarity index 100% rename from src-misc/auto-reviewer-prompt.txt rename to crates/zesdex-backend/src-misc/auto-reviewer-prompt.txt diff --git a/src-misc/security-reviewer-prompt.txt b/crates/zesdex-backend/src-misc/security-reviewer-prompt.txt similarity index 100% rename from src-misc/security-reviewer-prompt.txt rename to crates/zesdex-backend/src-misc/security-reviewer-prompt.txt diff --git a/src-misc/system-prompt.txt b/crates/zesdex-backend/src-misc/system-prompt.txt similarity index 100% rename from src-misc/system-prompt.txt rename to crates/zesdex-backend/src-misc/system-prompt.txt diff --git a/src-misc/system-tools.txt b/crates/zesdex-backend/src-misc/system-tools.txt similarity index 100% rename from src-misc/system-tools.txt rename to crates/zesdex-backend/src-misc/system-tools.txt diff --git a/src-misc/test-generator-prompt.txt b/crates/zesdex-backend/src-misc/test-generator-prompt.txt similarity index 100% rename from src-misc/test-generator-prompt.txt rename to crates/zesdex-backend/src-misc/test-generator-prompt.txt diff --git a/src/app/bgbash/control.rs b/crates/zesdex-backend/src/app/bgbash/control.rs similarity index 100% rename from src/app/bgbash/control.rs rename to crates/zesdex-backend/src/app/bgbash/control.rs diff --git a/src/app/bgbash/job.rs b/crates/zesdex-backend/src/app/bgbash/job.rs similarity index 100% rename from src/app/bgbash/job.rs rename to crates/zesdex-backend/src/app/bgbash/job.rs diff --git a/src/app/bgbash/mod.rs b/crates/zesdex-backend/src/app/bgbash/mod.rs similarity index 100% rename from src/app/bgbash/mod.rs rename to crates/zesdex-backend/src/app/bgbash/mod.rs diff --git a/src/app/harness.rs b/crates/zesdex-backend/src/app/harness.rs similarity index 100% rename from src/app/harness.rs rename to crates/zesdex-backend/src/app/harness.rs diff --git a/src/app/lsp/client.rs b/crates/zesdex-backend/src/app/lsp/client.rs similarity index 100% rename from src/app/lsp/client.rs rename to crates/zesdex-backend/src/app/lsp/client.rs diff --git a/src/app/lsp/mod.rs b/crates/zesdex-backend/src/app/lsp/mod.rs similarity index 100% rename from src/app/lsp/mod.rs rename to crates/zesdex-backend/src/app/lsp/mod.rs diff --git a/src/app/lsp/provisioner.rs b/crates/zesdex-backend/src/app/lsp/provisioner.rs similarity index 100% rename from src/app/lsp/provisioner.rs rename to crates/zesdex-backend/src/app/lsp/provisioner.rs diff --git a/src/app/mcp/manager.rs b/crates/zesdex-backend/src/app/mcp/manager.rs similarity index 100% rename from src/app/mcp/manager.rs rename to crates/zesdex-backend/src/app/mcp/manager.rs diff --git a/src/app/mcp/mod.rs b/crates/zesdex-backend/src/app/mcp/mod.rs similarity index 100% rename from src/app/mcp/mod.rs rename to crates/zesdex-backend/src/app/mcp/mod.rs diff --git a/src/app/mod.rs b/crates/zesdex-backend/src/app/mod.rs similarity index 100% rename from src/app/mod.rs rename to crates/zesdex-backend/src/app/mod.rs diff --git a/src/app/mode/bash.rs b/crates/zesdex-backend/src/app/mode/bash.rs similarity index 100% rename from src/app/mode/bash.rs rename to crates/zesdex-backend/src/app/mode/bash.rs diff --git a/src/app/mode/editor.rs b/crates/zesdex-backend/src/app/mode/editor.rs similarity index 100% rename from src/app/mode/editor.rs rename to crates/zesdex-backend/src/app/mode/editor.rs diff --git a/src/app/mode/effort.rs b/crates/zesdex-backend/src/app/mode/effort.rs similarity index 100% rename from src/app/mode/effort.rs rename to crates/zesdex-backend/src/app/mode/effort.rs diff --git a/src/app/mode/help.rs b/crates/zesdex-backend/src/app/mode/help.rs similarity index 100% rename from src/app/mode/help.rs rename to crates/zesdex-backend/src/app/mode/help.rs diff --git a/src/app/mode/key_input.rs b/crates/zesdex-backend/src/app/mode/key_input.rs similarity index 100% rename from src/app/mode/key_input.rs rename to crates/zesdex-backend/src/app/mode/key_input.rs diff --git a/src/app/mode/learning.rs b/crates/zesdex-backend/src/app/mode/learning.rs similarity index 100% rename from src/app/mode/learning.rs rename to crates/zesdex-backend/src/app/mode/learning.rs diff --git a/src/app/mode/loading.rs b/crates/zesdex-backend/src/app/mode/loading.rs similarity index 100% rename from src/app/mode/loading.rs rename to crates/zesdex-backend/src/app/mode/loading.rs diff --git a/src/app/mode/mcp.rs b/crates/zesdex-backend/src/app/mode/mcp.rs similarity index 100% rename from src/app/mode/mcp.rs rename to crates/zesdex-backend/src/app/mode/mcp.rs diff --git a/src/app/mode/mod.rs b/crates/zesdex-backend/src/app/mode/mod.rs similarity index 100% rename from src/app/mode/mod.rs rename to crates/zesdex-backend/src/app/mode/mod.rs diff --git a/src/app/mode/quit_confirm.rs b/crates/zesdex-backend/src/app/mode/quit_confirm.rs similarity index 100% rename from src/app/mode/quit_confirm.rs rename to crates/zesdex-backend/src/app/mode/quit_confirm.rs diff --git a/src/app/mode/rewind.rs b/crates/zesdex-backend/src/app/mode/rewind.rs similarity index 100% rename from src/app/mode/rewind.rs rename to crates/zesdex-backend/src/app/mode/rewind.rs diff --git a/src/app/mode/settings.rs b/crates/zesdex-backend/src/app/mode/settings.rs similarity index 100% rename from src/app/mode/settings.rs rename to crates/zesdex-backend/src/app/mode/settings.rs diff --git a/src/app/mode/todo.rs b/crates/zesdex-backend/src/app/mode/todo.rs similarity index 100% rename from src/app/mode/todo.rs rename to crates/zesdex-backend/src/app/mode/todo.rs diff --git a/src/app/review/mod.rs b/crates/zesdex-backend/src/app/review/mod.rs similarity index 100% rename from src/app/review/mod.rs rename to crates/zesdex-backend/src/app/review/mod.rs diff --git a/src/app/runtime/actions/mod.rs b/crates/zesdex-backend/src/app/runtime/actions/mod.rs similarity index 100% rename from src/app/runtime/actions/mod.rs rename to crates/zesdex-backend/src/app/runtime/actions/mod.rs diff --git a/src/app/runtime/commands.rs b/crates/zesdex-backend/src/app/runtime/commands.rs similarity index 100% rename from src/app/runtime/commands.rs rename to crates/zesdex-backend/src/app/runtime/commands.rs diff --git a/src/app/runtime/context/dedup.rs b/crates/zesdex-backend/src/app/runtime/context/dedup.rs similarity index 100% rename from src/app/runtime/context/dedup.rs rename to crates/zesdex-backend/src/app/runtime/context/dedup.rs diff --git a/src/app/runtime/context/mod.rs b/crates/zesdex-backend/src/app/runtime/context/mod.rs similarity index 100% rename from src/app/runtime/context/mod.rs rename to crates/zesdex-backend/src/app/runtime/context/mod.rs diff --git a/src/app/runtime/context/shaping.rs b/crates/zesdex-backend/src/app/runtime/context/shaping.rs similarity index 100% rename from src/app/runtime/context/shaping.rs rename to crates/zesdex-backend/src/app/runtime/context/shaping.rs diff --git a/src/app/runtime/context/squash.rs b/crates/zesdex-backend/src/app/runtime/context/squash.rs similarity index 100% rename from src/app/runtime/context/squash.rs rename to crates/zesdex-backend/src/app/runtime/context/squash.rs diff --git a/src/app/runtime/context/tokens.rs b/crates/zesdex-backend/src/app/runtime/context/tokens.rs similarity index 100% rename from src/app/runtime/context/tokens.rs rename to crates/zesdex-backend/src/app/runtime/context/tokens.rs diff --git a/src/app/runtime/context/window.rs b/crates/zesdex-backend/src/app/runtime/context/window.rs similarity index 100% rename from src/app/runtime/context/window.rs rename to crates/zesdex-backend/src/app/runtime/context/window.rs diff --git a/src/app/runtime/event_loop/mod.rs b/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs similarity index 100% rename from src/app/runtime/event_loop/mod.rs rename to crates/zesdex-backend/src/app/runtime/event_loop/mod.rs diff --git a/src/app/runtime/mod.rs b/crates/zesdex-backend/src/app/runtime/mod.rs similarity index 100% rename from src/app/runtime/mod.rs rename to crates/zesdex-backend/src/app/runtime/mod.rs diff --git a/src/app/runtime/stream/mod.rs b/crates/zesdex-backend/src/app/runtime/stream/mod.rs similarity index 100% rename from src/app/runtime/stream/mod.rs rename to crates/zesdex-backend/src/app/runtime/stream/mod.rs diff --git a/src/app/runtime/stream/turn.rs b/crates/zesdex-backend/src/app/runtime/stream/turn.rs similarity index 100% rename from src/app/runtime/stream/turn.rs rename to crates/zesdex-backend/src/app/runtime/stream/turn.rs diff --git a/src/app/state/diff.rs b/crates/zesdex-backend/src/app/state/diff.rs similarity index 100% rename from src/app/state/diff.rs rename to crates/zesdex-backend/src/app/state/diff.rs diff --git a/src/app/state/misc.rs b/crates/zesdex-backend/src/app/state/misc.rs similarity index 100% rename from src/app/state/misc.rs rename to crates/zesdex-backend/src/app/state/misc.rs diff --git a/src/app/state/mod.rs b/crates/zesdex-backend/src/app/state/mod.rs similarity index 100% rename from src/app/state/mod.rs rename to crates/zesdex-backend/src/app/state/mod.rs diff --git a/src/app/state/rest.rs b/crates/zesdex-backend/src/app/state/rest.rs similarity index 100% rename from src/app/state/rest.rs rename to crates/zesdex-backend/src/app/state/rest.rs diff --git a/src/app/state/runtime.rs b/crates/zesdex-backend/src/app/state/runtime.rs similarity index 100% rename from src/app/state/runtime.rs rename to crates/zesdex-backend/src/app/state/runtime.rs diff --git a/src/app/state/snapshot.rs b/crates/zesdex-backend/src/app/state/snapshot.rs similarity index 100% rename from src/app/state/snapshot.rs rename to crates/zesdex-backend/src/app/state/snapshot.rs diff --git a/src/app/state/types.rs b/crates/zesdex-backend/src/app/state/types.rs similarity index 100% rename from src/app/state/types.rs rename to crates/zesdex-backend/src/app/state/types.rs diff --git a/src/app/subagent/auto.rs b/crates/zesdex-backend/src/app/subagent/auto.rs similarity index 100% rename from src/app/subagent/auto.rs rename to crates/zesdex-backend/src/app/subagent/auto.rs diff --git a/src/app/subagent/context.rs b/crates/zesdex-backend/src/app/subagent/context.rs similarity index 100% rename from src/app/subagent/context.rs rename to crates/zesdex-backend/src/app/subagent/context.rs diff --git a/src/app/subagent/division.rs b/crates/zesdex-backend/src/app/subagent/division.rs similarity index 100% rename from src/app/subagent/division.rs rename to crates/zesdex-backend/src/app/subagent/division.rs diff --git a/src/app/subagent/engine.rs b/crates/zesdex-backend/src/app/subagent/engine.rs similarity index 100% rename from src/app/subagent/engine.rs rename to crates/zesdex-backend/src/app/subagent/engine.rs diff --git a/src/app/subagent/event.rs b/crates/zesdex-backend/src/app/subagent/event.rs similarity index 100% rename from src/app/subagent/event.rs rename to crates/zesdex-backend/src/app/subagent/event.rs diff --git a/src/app/subagent/mod.rs b/crates/zesdex-backend/src/app/subagent/mod.rs similarity index 100% rename from src/app/subagent/mod.rs rename to crates/zesdex-backend/src/app/subagent/mod.rs diff --git a/src/app/subagent/spawn.rs b/crates/zesdex-backend/src/app/subagent/spawn.rs similarity index 76% rename from src/app/subagent/spawn.rs rename to crates/zesdex-backend/src/app/subagent/spawn.rs index 32a3863..6fba4b5 100644 --- a/src/app/subagent/spawn.rs +++ b/crates/zesdex-backend/src/app/subagent/spawn.rs @@ -39,4 +39,18 @@ impl AgentDefinition { self.allowed_tools = Some(tools); self } + + /// Builder method: set the maximum step count for this agent. + #[allow(dead_code)] + pub fn with_max_steps(mut self, steps: usize) -> Self { + self.max_steps = Some(steps); + self + } + + /// Builder method: set the temperature for this agent. + #[allow(dead_code)] + pub fn with_temperature(mut self, temp: f32) -> Self { + self.temperature = Some(temp); + self + } } diff --git a/src/app/workflow/docs.rs b/crates/zesdex-backend/src/app/workflow/docs.rs similarity index 100% rename from src/app/workflow/docs.rs rename to crates/zesdex-backend/src/app/workflow/docs.rs diff --git a/src/app/workflow/engine.rs b/crates/zesdex-backend/src/app/workflow/engine.rs similarity index 100% rename from src/app/workflow/engine.rs rename to crates/zesdex-backend/src/app/workflow/engine.rs diff --git a/src/app/workflow/hive_mind.rs b/crates/zesdex-backend/src/app/workflow/hive_mind.rs similarity index 100% rename from src/app/workflow/hive_mind.rs rename to crates/zesdex-backend/src/app/workflow/hive_mind.rs diff --git a/src/app/workflow/mod.rs b/crates/zesdex-backend/src/app/workflow/mod.rs similarity index 100% rename from src/app/workflow/mod.rs rename to crates/zesdex-backend/src/app/workflow/mod.rs diff --git a/src/app/workflow/script.rs b/crates/zesdex-backend/src/app/workflow/script.rs similarity index 100% rename from src/app/workflow/script.rs rename to crates/zesdex-backend/src/app/workflow/script.rs diff --git a/crates/zesdex-backend/src/bin/migrate.rs b/crates/zesdex-backend/src/bin/migrate.rs new file mode 100644 index 0000000..17e2fbc --- /dev/null +++ b/crates/zesdex-backend/src/bin/migrate.rs @@ -0,0 +1,111 @@ +//! Database migration: creates/upgrades SQLite schemas for all sessions. +use std::path::Path; + +fn main() -> anyhow::Result<()> { + let store = zesdex_entities::seaorm::common::store::Store::new(); + + // Find all session directories + let sessions_dir = store.base_dir.join("sessions"); + if !sessions_dir.exists() { + eprintln!("No sessions directory found, nothing to migrate"); + return Ok(()); + } + + let mut migrated = 0u32; + let mut failed = 0u32; + + for entry in std::fs::read_dir(&sessions_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + match migrate_session_msglog(&path) { + Ok(_) => { + migrated += 1; + eprintln!("Migrated session: {:?}", path.file_name()); + } + Err(e) => { + failed += 1; + eprintln!("Failed to migrate session {:?}: {e}", path.file_name()); + } + } + } + + eprintln!("Migration complete: {migrated} succeeded, {failed} failed"); + if failed > 0 { + anyhow::bail!("{failed} session(s) failed to migrate"); + } + Ok(()) +} + +/// Open a session's `messages.sqlite` and initialize its schema. +fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> { + let msglog_path = session_dir.join("messages.sqlite"); + + if let Some(parent) = msglog_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = rusqlite::Connection::open(&msglog_path)?; + conn.execute_batch("PRAGMA journal_mode = WAL;")?; + conn.execute_batch("PRAGMA busy_timeout = 5000;")?; + + // Initialize schema + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_name TEXT, + tool_arguments TEXT, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS archives ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + title TEXT, + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + token_count INTEGER DEFAULT 0, + summary TEXT + ); + CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); + CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); + CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at); + CREATE TABLE IF NOT EXISTS blobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + blob_key TEXT NOT NULL, + data BLOB NOT NULL, + mime_type TEXT, + created_at INTEGER NOT NULL, + UNIQUE(session_id, blob_key) + ); + ", + )?; + + // Check and upgrade schema version + let version: i32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap_or(0); + + if version < 1 { + conn.pragma_update(None, "user_version", 1)?; + } + if version < 2 { + conn.execute_batch( + "CREATE INDEX IF NOT EXISTS idx_messages_session_role ON messages(session_id, role);", + )?; + conn.pragma_update(None, "user_version", 2)?; + } + + Ok(()) +} diff --git a/crates/zesdex-backend/src/bin/seed.rs b/crates/zesdex-backend/src/bin/seed.rs new file mode 100644 index 0000000..e46647e --- /dev/null +++ b/crates/zesdex-backend/src/bin/seed.rs @@ -0,0 +1,56 @@ +//! Database seeder: initializes store directories, creates default settings +//! and app_config, and populates a default session for development. + +fn main() -> anyhow::Result<()> { + let store = zesdex_entities::seaorm::common::store::Store::new(); + store.ensure_dirs()?; + tracing::info!("Store directories created at {:?}", store.base_dir); + + // Create default settings if not present + let settings_path = store.base_dir.join("settings.json"); + if !settings_path.exists() { + let settings = zesdex_entities::seaorm::common::settings::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)?; + tracing::info!("Default settings created"); + } else { + tracing::info!("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_entities::seaorm::common::app_config::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)?; + tracing::info!("Default app_config created"); + } else { + tracing::info!("App config already exists, skipping"); + } + + // Create memory, scratch, session-images, downloads dirs + std::fs::create_dir_all(&store.memory_dir)?; + std::fs::create_dir_all(&store.scratch_root)?; + std::fs::create_dir_all(&store.session_images_dir)?; + std::fs::create_dir_all(&store.download_dir)?; + tracing::info!("All store directories verified"); + + // Create a seed session + let session_id = uuid::Uuid::new_v4().to_string(); + let session = zesdex_entities::seaorm::auth::session::Session::new( + session_id.clone(), + "Seed Session".to_string(), + ); + session.save(&store.base_dir)?; + tracing::info!("Seed session created: id={session_id}"); + + Ok(()) +} diff --git a/src/controller/command.rs b/crates/zesdex-backend/src/controller/command.rs similarity index 100% rename from src/controller/command.rs rename to crates/zesdex-backend/src/controller/command.rs diff --git a/src/controller/input.rs b/crates/zesdex-backend/src/controller/input.rs similarity index 100% rename from src/controller/input.rs rename to crates/zesdex-backend/src/controller/input.rs diff --git a/src/controller/mod.rs b/crates/zesdex-backend/src/controller/mod.rs similarity index 100% rename from src/controller/mod.rs rename to crates/zesdex-backend/src/controller/mod.rs diff --git a/crates/zesdex-backend/src/dto/mod.rs b/crates/zesdex-backend/src/dto/mod.rs new file mode 100644 index 0000000..62c89a5 --- /dev/null +++ b/crates/zesdex-backend/src/dto/mod.rs @@ -0,0 +1,25 @@ +//! Re-exports from `zesdex-entities` (canonical types) and `zesdex-dto` +//! (provider request/response) under the original module paths. +//! +//! Chat types come from the entities crate to avoid type duplication +//! with `crate::model::conversation::Conversation` which stores +//! `ChatMessage` values. Provider wire types come from the dto crate. + +pub mod chat { + pub mod message { + pub use zesdex_entities::seaorm::common::message::*; + } + pub mod tool { + pub use zesdex_entities::seaorm::common::tool_call::*; + } +} + +pub mod provider { + pub mod request { + pub use zesdex_dto::provider::request::*; + pub use zesdex_dto::provider::request::ChatCompletionRequest as ChatRequest; + } + pub mod response { + pub use zesdex_dto::provider::response::ChatCompletionResponse as ChatResponse; + } +} diff --git a/crates/zesdex-backend/src/ipc/mod.rs b/crates/zesdex-backend/src/ipc/mod.rs new file mode 100644 index 0000000..bb31e7d --- /dev/null +++ b/crates/zesdex-backend/src/ipc/mod.rs @@ -0,0 +1,14 @@ +//! Re-exports from `zesdex-ipc` crate under the original module paths. + +pub mod protocol { + pub use zesdex_ipc::protocol::*; +} +pub mod conn { + pub use zesdex_ipc::conn::*; +} +pub mod client { + pub use zesdex_ipc::client::*; +} +pub mod server { + pub use zesdex_ipc::server::*; +} diff --git a/src/main.rs b/crates/zesdex-backend/src/main.rs similarity index 99% rename from src/main.rs rename to crates/zesdex-backend/src/main.rs index 440a0d4..eebc25a 100644 --- a/src/main.rs +++ b/crates/zesdex-backend/src/main.rs @@ -569,7 +569,7 @@ fn run_attach(session_id: &str) -> Result<()> { use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; use ipc::protocol::ClientRequest; - let (mut client, mut terminal, mut client_state) = setup_attach_client(session_id)?; + let (client, mut terminal, mut client_state) = setup_attach_client(session_id)?; let _rt = tokio::runtime::Runtime::new()?; loop { diff --git a/src/model/agent_def/builtin.rs b/crates/zesdex-backend/src/model/agent_def/builtin.rs similarity index 87% rename from src/model/agent_def/builtin.rs rename to crates/zesdex-backend/src/model/agent_def/builtin.rs index f7ea79a..4767910 100644 --- a/src/model/agent_def/builtin.rs +++ b/crates/zesdex-backend/src/model/agent_def/builtin.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] //! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner). use crate::app::subagent::spawn::AgentDefinition; @@ -60,15 +61,15 @@ pub fn builtin_agents() -> Vec { "researcher".to_string(), "researcher".to_string(), ).with_system_prompt( - "You are a research agent. Search and synthesize information.".to_string() + "You are a research agent. Search for information and summarize findings.".to_string() ).with_allowed_tools( vec![ "read".to_string(), "grep".to_string(), "glob".to_string(), - "search".to_string(), - "lsp_definition".to_string(), - "lsp_references".to_string(), + "bash".to_string(), + "search_web".to_string(), + "fetch_url".to_string(), ] ).with_max_steps(usize::MAX), @@ -80,9 +81,11 @@ pub fn builtin_agents() -> Vec { ).with_allowed_tools( vec![ "read".to_string(), - "grep".to_string(), - "glob".to_string(), - "plan".to_string(), + "write".to_string(), + "edit".to_string(), + "bash".to_string(), + "todo_write".to_string(), + "todo_finish".to_string(), ] ).with_max_steps(usize::MAX), ] diff --git a/src/model/agent_def/global.rs b/crates/zesdex-backend/src/model/agent_def/global.rs similarity index 71% rename from src/model/agent_def/global.rs rename to crates/zesdex-backend/src/model/agent_def/global.rs index c5686b5..3509362 100644 --- a/src/model/agent_def/global.rs +++ b/crates/zesdex-backend/src/model/agent_def/global.rs @@ -1,10 +1,11 @@ +#![allow(dead_code)] //! Load, save, and remove user-defined agent definitions stored globally //! (under the store's `agents/` directory), independent of any session. use crate::app::subagent::spawn::AgentDefinition; /// Load all globally-registered agent definitions from disk. /// -/// Flow: resolve `/agents/` → read directory → parse each `*.json` +/// Flow: resolve `/agents/` -> read directory -> parse each `*.json` /// file into an `AgentDefinition`, skipping any that fail to read or parse. /// /// Why: missing directory or unreadable/invalid files are silently @@ -38,8 +39,8 @@ pub fn load_global_agents() -> Vec { /// Persist a global agent definition as `/agents/.json`, /// with fsync for crash safety. /// -/// Flow: ensure the `agents/` directory exists → serialize `def` to -/// pretty JSON → write to a temp file → fsync → rename into place → +/// Flow: ensure the `agents/` directory exists -> serialize `def` to +/// pretty JSON -> write to a temp file -> fsync -> rename into place -> /// fsync parent directory. /// /// Why: writing by name overwrites any existing definition with the @@ -59,23 +60,25 @@ pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> { let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, path)?; - let _ = std::fs::File::open(&agents_dir).and_then(|d| d.sync_all()); - Ok(()) -} - -/// Delete a global agent definition by name, if it exists. -/// -/// Flow: resolve `/agents/.json` → remove the file if present. -/// -/// Why: a no-op (not an error) when the file is already absent. -/// -/// Return: `Ok(())` whether or not the file existed; `Err` only on an -/// actual filesystem removal failure. -pub fn remove_global_agent(name: &str) -> anyhow::Result<()> { - let store = crate::model::store::Store::new(); - let path = store.base_dir.join("agents").join(format!("{}.json", name)); - if path.exists() { - std::fs::remove_file(path)?; + if let Some(parent) = agents_dir.parent() { + let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); } Ok(()) } + +/// Remove a global agent definition by name. +/// +/// Flow: resolve `/agents/.json` -> delete it, ignoring +/// errors if the file doesn't exist. +/// +/// Return: `Ok(true)` if removed, `Ok(false)` if not found, `Err` on +/// filesystem error other than `NotFound`. +pub fn remove_global_agent(name: &str) -> anyhow::Result { + let store = crate::model::store::Store::new(); + let path = store.base_dir.join("agents").join(format!("{name}.json")); + match std::fs::remove_file(&path) { + Ok(_) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + } +} diff --git a/src/model/agent_def/mod.rs b/crates/zesdex-backend/src/model/agent_def/mod.rs similarity index 100% rename from src/model/agent_def/mod.rs rename to crates/zesdex-backend/src/model/agent_def/mod.rs diff --git a/src/model/agent_def/session.rs b/crates/zesdex-backend/src/model/agent_def/session.rs similarity index 72% rename from src/model/agent_def/session.rs rename to crates/zesdex-backend/src/model/agent_def/session.rs index f5c78ba..5b7f685 100644 --- a/src/model/agent_def/session.rs +++ b/crates/zesdex-backend/src/model/agent_def/session.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] //! Load, save, add, and remove agent definitions scoped to a single //! session (`/agents.json`). use std::path::Path; @@ -5,7 +6,7 @@ use crate::app::subagent::spawn::AgentDefinition; /// Load agent definitions saved for a specific session. /// -/// Flow: check `/agents.json` exists → read → JSON-decode +/// Flow: check `/agents.json` exists -> read -> JSON-decode /// into `Vec`. /// /// Why: a missing file or a parse failure both degrade gracefully to an @@ -33,8 +34,8 @@ pub fn load_session_agents(session_dir: &Path) -> Vec { /// Overwrite `/agents.json` with the given agent list, /// with fsync for crash safety. /// -/// Flow: serialize `agents` to pretty JSON → write to a temp file → -/// fsync → rename over `agents.json` → fsync parent directory. +/// Flow: serialize `agents` to pretty JSON -> write to a temp file -> +/// fsync -> rename over `agents.json` -> fsync parent directory. /// /// Return: `Ok(())` on success, or an error if serialization or the /// write fails. @@ -52,27 +53,32 @@ pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> an /// Add or replace a session agent definition by name. /// -/// Flow: load existing session agents → drop any with the same name as -/// `def` → push `def` → save the updated list. +/// Flow: load existing session agents -> drop any with the same name as +/// `def` -> push `def` -> save the updated list. /// /// Why: name-based dedup makes this an upsert rather than an append. /// /// Return: `Ok(())` on success, propagating any load/save error. -pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> { +pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::Result<()> { let mut agents = load_session_agents(session_dir); agents.retain(|a| a.name != def.name); - agents.push(def); + agents.push(def.clone()); save_session_agents(session_dir, &agents) } -/// Remove a session agent definition by name, if present. +/// Remove a session agent definition by name. /// -/// Flow: load existing session agents → filter out entries matching -/// `name` → save the updated list. +/// Flow: load existing agents -> retain all except the named one -> save. /// -/// Return: `Ok(())` whether or not an entry with `name` existed. -pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> { +/// Return: `Ok(true)` if removed, `Ok(false)` if not found, `Err` on +/// load/save failure. +pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result { let mut agents = load_session_agents(session_dir); + let before = agents.len(); agents.retain(|a| a.name != name); - save_session_agents(session_dir, &agents) + if agents.len() == before { + return Ok(false); + } + save_session_agents(session_dir, &agents)?; + Ok(true) } diff --git a/crates/zesdex-backend/src/model/mod.rs b/crates/zesdex-backend/src/model/mod.rs new file mode 100644 index 0000000..c1420bf --- /dev/null +++ b/crates/zesdex-backend/src/model/mod.rs @@ -0,0 +1,28 @@ +//! Re-exports from `zesdex-entities` crate under the original module paths, +//! plus local sub-modules (agent_def, msglog) that weren't extracted. + +// Module re-exports matching original `crate::model::*` paths +pub mod session { + pub use zesdex_entities::seaorm::auth::session::*; +} +pub mod session_lock { + pub use zesdex_entities::seaorm::auth::session_lock::*; +} +pub mod settings { + pub use zesdex_entities::seaorm::common::settings::*; +} +pub mod app_config { + pub use zesdex_entities::seaorm::common::app_config::*; +} +pub mod store { + pub use zesdex_entities::seaorm::common::store::*; +} +pub mod editlog { + pub use zesdex_entities::seaorm::common::edit_log::*; +} +pub mod memory { + pub use zesdex_entities::seaorm::common::memory::*; +} +/// Local modules not extracted to workspace crates +pub mod msglog; +pub mod agent_def; diff --git a/src/model/msglog/blobs.rs b/crates/zesdex-backend/src/model/msglog/blobs.rs similarity index 96% rename from src/model/msglog/blobs.rs rename to crates/zesdex-backend/src/model/msglog/blobs.rs index c0e6c3a..f4e5c57 100644 --- a/src/model/msglog/blobs.rs +++ b/crates/zesdex-backend/src/model/msglog/blobs.rs @@ -5,7 +5,7 @@ use rusqlite::{params, Connection}; /// Insert or overwrite a blob for a session under `blob_key`. /// -/// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs` +/// Flow: compute current timestamp -> `INSERT OR REPLACE` into `blobs` /// keyed on `(session_id, blob_key)`. /// /// Return: `Ok(())` on success, or the underlying `SQLite` error. diff --git a/src/model/msglog/mod.rs b/crates/zesdex-backend/src/model/msglog/mod.rs similarity index 86% rename from src/model/msglog/mod.rs rename to crates/zesdex-backend/src/model/msglog/mod.rs index b4e889e..fcdf07e 100644 --- a/src/model/msglog/mod.rs +++ b/crates/zesdex-backend/src/model/msglog/mod.rs @@ -10,8 +10,8 @@ pub use query::insert_message; /// Open (creating if needed) a session's `messages.sqlite` and ensure its /// schema is initialized. /// -/// Flow: resolve `/messages.sqlite` → create parent dirs → -/// open a `SQLite` connection → run `schema::init_schema`. +/// Flow: resolve `/messages.sqlite` -> create parent dirs -> +/// open a `SQLite` connection -> run `schema::init_schema`. /// /// Return: an open, schema-ready `Connection`, or an error if any step /// fails. diff --git a/src/model/msglog/query.rs b/crates/zesdex-backend/src/model/msglog/query.rs similarity index 85% rename from src/model/msglog/query.rs rename to crates/zesdex-backend/src/model/msglog/query.rs index 9b1669c..0368bdb 100644 --- a/src/model/msglog/query.rs +++ b/crates/zesdex-backend/src/model/msglog/query.rs @@ -5,9 +5,9 @@ use rusqlite::{params, Connection}; /// Insert a chat message into the session's message log. /// -/// Flow: extract optional `content/tool_call_id/tool_name` → serialize -/// `tool_calls` to a JSON string if present → map `Role` to its string -/// column value → `INSERT` the row with the current timestamp. +/// Flow: extract optional `content/tool_call_id/tool_name` -> serialize +/// `tool_calls` to a JSON string if present -> map `Role` to its string +/// column value -> `INSERT` the row with the current timestamp. /// /// Return: the new row's `rowid` on success, or the underlying error. pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result { diff --git a/src/model/msglog/schema.rs b/crates/zesdex-backend/src/model/msglog/schema.rs similarity index 100% rename from src/model/msglog/schema.rs rename to crates/zesdex-backend/src/model/msglog/schema.rs diff --git a/src/resources.rs b/crates/zesdex-backend/src/resources.rs similarity index 100% rename from src/resources.rs rename to crates/zesdex-backend/src/resources.rs diff --git a/crates/zesdex-backend/src/service/mod.rs b/crates/zesdex-backend/src/service/mod.rs new file mode 100644 index 0000000..ea55f91 --- /dev/null +++ b/crates/zesdex-backend/src/service/mod.rs @@ -0,0 +1,4 @@ +//! Service layer: LLM provider HTTP client and OAuth flows. + +pub mod oauth; +pub mod provider; diff --git a/src/service/oauth/loopback.rs b/crates/zesdex-backend/src/service/oauth/loopback.rs similarity index 100% rename from src/service/oauth/loopback.rs rename to crates/zesdex-backend/src/service/oauth/loopback.rs diff --git a/src/service/oauth/manager.rs b/crates/zesdex-backend/src/service/oauth/manager.rs similarity index 100% rename from src/service/oauth/manager.rs rename to crates/zesdex-backend/src/service/oauth/manager.rs diff --git a/crates/zesdex-backend/src/service/oauth/mod.rs b/crates/zesdex-backend/src/service/oauth/mod.rs new file mode 100644 index 0000000..4c2ba87 --- /dev/null +++ b/crates/zesdex-backend/src/service/oauth/mod.rs @@ -0,0 +1,6 @@ +//! OAuth 2.0 authorization-code + PKCE flow: local HTTP callback server, +//! token exchange, and code verifier/challenge generation. + +pub mod loopback; +pub mod manager; +pub mod pkce; diff --git a/src/service/oauth/pkce.rs b/crates/zesdex-backend/src/service/oauth/pkce.rs similarity index 100% rename from src/service/oauth/pkce.rs rename to crates/zesdex-backend/src/service/oauth/pkce.rs diff --git a/src/service/provider.rs b/crates/zesdex-backend/src/service/provider.rs similarity index 98% rename from src/service/provider.rs rename to crates/zesdex-backend/src/service/provider.rs index 67e6c03..2069861 100644 --- a/src/service/provider.rs +++ b/crates/zesdex-backend/src/service/provider.rs @@ -104,9 +104,9 @@ impl LlmClient { temperature: Some(0.7), tools, stream: Some(false), - top_p: None, stop: None, stream_options: None, + tool_choice: None, }; let url = format!("{}/chat/completions", self.base_url); @@ -145,15 +145,15 @@ impl LlmClient { let data: crate::dto::provider::response::ChatResponse = resp.json()?; let usage = data.usage.map(|u| { ( - u64::from(u.prompt_tokens.unwrap_or(0)), - u64::from(u.completion_tokens.unwrap_or(0)), + u64::from(u.prompt_tokens), + u64::from(u.completion_tokens), ) }); let message = data .choices .into_iter() .next() - .map(|c| c.message) + .and_then(|c| c.message) .ok_or_else(|| anyhow::anyhow!("API response had no choices"))?; Ok((message, usage)) })(); @@ -199,11 +199,11 @@ impl LlmClient { temperature: Some(temperature.unwrap_or(0.7)), tools, stream: Some(true), - top_p: None, stop: None, stream_options: Some(StreamOptions { include_usage: true, }), + tool_choice: None, }; let url = format!("{}/chat/completions", self.base_url); diff --git a/src/tool/bash_tools.rs b/crates/zesdex-backend/src/tool/bash_tools.rs similarity index 100% rename from src/tool/bash_tools.rs rename to crates/zesdex-backend/src/tool/bash_tools.rs diff --git a/src/tool/fs/delete.rs b/crates/zesdex-backend/src/tool/fs/delete.rs similarity index 100% rename from src/tool/fs/delete.rs rename to crates/zesdex-backend/src/tool/fs/delete.rs diff --git a/src/tool/fs/edit.rs b/crates/zesdex-backend/src/tool/fs/edit.rs similarity index 100% rename from src/tool/fs/edit.rs rename to crates/zesdex-backend/src/tool/fs/edit.rs diff --git a/src/tool/fs/helpers.rs b/crates/zesdex-backend/src/tool/fs/helpers.rs similarity index 100% rename from src/tool/fs/helpers.rs rename to crates/zesdex-backend/src/tool/fs/helpers.rs diff --git a/src/tool/fs/mod.rs b/crates/zesdex-backend/src/tool/fs/mod.rs similarity index 100% rename from src/tool/fs/mod.rs rename to crates/zesdex-backend/src/tool/fs/mod.rs diff --git a/src/tool/fs/read.rs b/crates/zesdex-backend/src/tool/fs/read.rs similarity index 100% rename from src/tool/fs/read.rs rename to crates/zesdex-backend/src/tool/fs/read.rs diff --git a/src/tool/fs/write.rs b/crates/zesdex-backend/src/tool/fs/write.rs similarity index 100% rename from src/tool/fs/write.rs rename to crates/zesdex-backend/src/tool/fs/write.rs diff --git a/src/tool/git_cred.rs b/crates/zesdex-backend/src/tool/git_cred.rs similarity index 100% rename from src/tool/git_cred.rs rename to crates/zesdex-backend/src/tool/git_cred.rs diff --git a/src/tool/git_operator.rs b/crates/zesdex-backend/src/tool/git_operator.rs similarity index 100% rename from src/tool/git_operator.rs rename to crates/zesdex-backend/src/tool/git_operator.rs diff --git a/src/tool/git_worktree.rs b/crates/zesdex-backend/src/tool/git_worktree.rs similarity index 100% rename from src/tool/git_worktree.rs rename to crates/zesdex-backend/src/tool/git_worktree.rs diff --git a/src/tool/lsp/mod.rs b/crates/zesdex-backend/src/tool/lsp/mod.rs similarity index 100% rename from src/tool/lsp/mod.rs rename to crates/zesdex-backend/src/tool/lsp/mod.rs diff --git a/src/tool/memory/forget.rs b/crates/zesdex-backend/src/tool/memory/forget.rs similarity index 100% rename from src/tool/memory/forget.rs rename to crates/zesdex-backend/src/tool/memory/forget.rs diff --git a/src/tool/memory/mod.rs b/crates/zesdex-backend/src/tool/memory/mod.rs similarity index 100% rename from src/tool/memory/mod.rs rename to crates/zesdex-backend/src/tool/memory/mod.rs diff --git a/src/tool/memory/recall.rs b/crates/zesdex-backend/src/tool/memory/recall.rs similarity index 100% rename from src/tool/memory/recall.rs rename to crates/zesdex-backend/src/tool/memory/recall.rs diff --git a/src/tool/memory/remember.rs b/crates/zesdex-backend/src/tool/memory/remember.rs similarity index 100% rename from src/tool/memory/remember.rs rename to crates/zesdex-backend/src/tool/memory/remember.rs diff --git a/src/tool/mod.rs b/crates/zesdex-backend/src/tool/mod.rs similarity index 100% rename from src/tool/mod.rs rename to crates/zesdex-backend/src/tool/mod.rs diff --git a/src/tool/plan.rs b/crates/zesdex-backend/src/tool/plan.rs similarity index 100% rename from src/tool/plan.rs rename to crates/zesdex-backend/src/tool/plan.rs diff --git a/src/tool/search.rs b/crates/zesdex-backend/src/tool/search.rs similarity index 100% rename from src/tool/search.rs rename to crates/zesdex-backend/src/tool/search.rs diff --git a/src/tool/seqthink.rs b/crates/zesdex-backend/src/tool/seqthink.rs similarity index 100% rename from src/tool/seqthink.rs rename to crates/zesdex-backend/src/tool/seqthink.rs diff --git a/src/tool/shell.rs b/crates/zesdex-backend/src/tool/shell.rs similarity index 100% rename from src/tool/shell.rs rename to crates/zesdex-backend/src/tool/shell.rs diff --git a/src/tool/shell_filter/credentials.rs b/crates/zesdex-backend/src/tool/shell_filter/credentials.rs similarity index 100% rename from src/tool/shell_filter/credentials.rs rename to crates/zesdex-backend/src/tool/shell_filter/credentials.rs diff --git a/src/tool/shell_filter/git.rs b/crates/zesdex-backend/src/tool/shell_filter/git.rs similarity index 100% rename from src/tool/shell_filter/git.rs rename to crates/zesdex-backend/src/tool/shell_filter/git.rs diff --git a/src/tool/shell_filter/mod.rs b/crates/zesdex-backend/src/tool/shell_filter/mod.rs similarity index 100% rename from src/tool/shell_filter/mod.rs rename to crates/zesdex-backend/src/tool/shell_filter/mod.rs diff --git a/src/tool/spawn.rs b/crates/zesdex-backend/src/tool/spawn.rs similarity index 100% rename from src/tool/spawn.rs rename to crates/zesdex-backend/src/tool/spawn.rs diff --git a/src/tool/utility/cd.rs b/crates/zesdex-backend/src/tool/utility/cd.rs similarity index 100% rename from src/tool/utility/cd.rs rename to crates/zesdex-backend/src/tool/utility/cd.rs diff --git a/src/tool/utility/dir_cache_update.rs b/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs similarity index 100% rename from src/tool/utility/dir_cache_update.rs rename to crates/zesdex-backend/src/tool/utility/dir_cache_update.rs diff --git a/src/tool/utility/dir_list.rs b/crates/zesdex-backend/src/tool/utility/dir_list.rs similarity index 100% rename from src/tool/utility/dir_list.rs rename to crates/zesdex-backend/src/tool/utility/dir_list.rs diff --git a/src/tool/utility/mod.rs b/crates/zesdex-backend/src/tool/utility/mod.rs similarity index 100% rename from src/tool/utility/mod.rs rename to crates/zesdex-backend/src/tool/utility/mod.rs diff --git a/src/tool/utility/pong.rs b/crates/zesdex-backend/src/tool/utility/pong.rs similarity index 100% rename from src/tool/utility/pong.rs rename to crates/zesdex-backend/src/tool/utility/pong.rs diff --git a/src/tool/utility/todofinish.rs b/crates/zesdex-backend/src/tool/utility/todofinish.rs similarity index 100% rename from src/tool/utility/todofinish.rs rename to crates/zesdex-backend/src/tool/utility/todofinish.rs diff --git a/src/tool/utility/todowrite.rs b/crates/zesdex-backend/src/tool/utility/todowrite.rs similarity index 100% rename from src/tool/utility/todowrite.rs rename to crates/zesdex-backend/src/tool/utility/todowrite.rs diff --git a/src/tool/workflow.rs b/crates/zesdex-backend/src/tool/workflow.rs similarity index 100% rename from src/tool/workflow.rs rename to crates/zesdex-backend/src/tool/workflow.rs diff --git a/src/view/chat.rs b/crates/zesdex-backend/src/view/chat.rs similarity index 100% rename from src/view/chat.rs rename to crates/zesdex-backend/src/view/chat.rs diff --git a/src/view/markdown.rs b/crates/zesdex-backend/src/view/markdown.rs similarity index 100% rename from src/view/markdown.rs rename to crates/zesdex-backend/src/view/markdown.rs diff --git a/src/view/mod.rs b/crates/zesdex-backend/src/view/mod.rs similarity index 100% rename from src/view/mod.rs rename to crates/zesdex-backend/src/view/mod.rs diff --git a/src/view/sidebar.rs b/crates/zesdex-backend/src/view/sidebar.rs similarity index 100% rename from src/view/sidebar.rs rename to crates/zesdex-backend/src/view/sidebar.rs diff --git a/src/view/status.rs b/crates/zesdex-backend/src/view/status.rs similarity index 100% rename from src/view/status.rs rename to crates/zesdex-backend/src/view/status.rs diff --git a/src/view/theme.rs b/crates/zesdex-backend/src/view/theme.rs similarity index 100% rename from src/view/theme.rs rename to crates/zesdex-backend/src/view/theme.rs diff --git a/src/view/workflow.rs b/crates/zesdex-backend/src/view/workflow.rs similarity index 100% rename from src/view/workflow.rs rename to crates/zesdex-backend/src/view/workflow.rs diff --git a/crates/zesdex-cms/Cargo.toml b/crates/zesdex-cms/Cargo.toml new file mode 100644 index 0000000..691693a --- /dev/null +++ b/crates/zesdex-cms/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zesdex-cms" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +chrono.workspace = true +uuid.workspace = true +tracing.workspace = true +dirs.workspace = true +zesdex-entities.workspace = true +zesdex-utils.workspace = true diff --git a/crates/zesdex-cms/src/application/conversation_service.rs b/crates/zesdex-cms/src/application/conversation_service.rs new file mode 100644 index 0000000..c09bdc8 --- /dev/null +++ b/crates/zesdex-cms/src/application/conversation_service.rs @@ -0,0 +1,62 @@ +//! Conversation use-case implementations. +//! +//! `ConversationServiceImpl` is generic over `R: ConversationRepository`, +//! delegating all persistence to that adapter. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use anyhow::{Context, Result}; + +use crate::domain::conversation::{ChatMessage, Conversation}; +use crate::domain::repository::ConversationRepository; +use crate::domain::service::ConversationService; + +/// Generic conversation service backed by an injected repository. +pub struct ConversationServiceImpl { + pub repo: R, + pub sessions_dir: std::path::PathBuf, +} + +impl ConversationServiceImpl { + /// Create a new service with the given repository and sessions directory. + pub fn new(repo: R, sessions_dir: impl Into) -> Self { + Self { + repo, + sessions_dir: sessions_dir.into(), + } + } + + /// Compute the session directory for a given session id. + fn session_dir(&self, session_id: &str) -> std::path::PathBuf { + self.sessions_dir.join(session_id) + } +} + +impl ConversationService for ConversationServiceImpl { + fn load_conversation(&self, session_id: &str) -> Result { + let dir = self.session_dir(session_id); + self.repo + .load(&dir) + .with_context(|| format!("failed to load conversation for session '{session_id}'")) + } + + fn save_conversation(&self, conv: &Conversation) -> Result<()> { + let dir = self.session_dir(&conv.session_id); + self.repo + .save(&dir, conv) + .with_context(|| format!("failed to save conversation for session '{}'", conv.session_id)) + } + + fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()> { + conv.push(msg); + let dir = self.session_dir(&conv.session_id); + self.repo + .save(&dir, conv) + .with_context(|| format!("failed to persist conversation after adding message for session '{}'", conv.session_id)) + } +} diff --git a/crates/zesdex-cms/src/application/memory_service.rs b/crates/zesdex-cms/src/application/memory_service.rs new file mode 100644 index 0000000..0f1690a --- /dev/null +++ b/crates/zesdex-cms/src/application/memory_service.rs @@ -0,0 +1,53 @@ +//! Memory use-case implementations. +//! +//! `MemoryServiceImpl` is generic over `R: MemoryRepository`, delegating +//! all persistence to that adapter. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use anyhow::{Context, Result}; + +use crate::domain::memory::Memory; +use crate::domain::repository::MemoryRepository; +use crate::domain::service::MemoryService; + +/// Generic memory service backed by an injected repository. +pub struct MemoryServiceImpl { + pub repo: R, + pub memory_dir: std::path::PathBuf, +} + +impl MemoryServiceImpl { + /// Create a new service with the given repository and memory directory. + pub fn new(repo: R, memory_dir: impl Into) -> Self { + Self { + repo, + memory_dir: memory_dir.into(), + } + } +} + +impl MemoryService for MemoryServiceImpl { + fn list_memories(&self) -> Result> { + self.repo + .list(&self.memory_dir) + .context("failed to list memories") + } + + fn save_memory(&self, memory: &Memory) -> Result<()> { + self.repo + .save(&self.memory_dir, memory) + .with_context(|| format!("failed to save memory '{}'", memory.name)) + } + + fn delete_memory(&self, name: &str) -> Result<()> { + self.repo + .delete(&self.memory_dir, name) + .with_context(|| format!("failed to delete memory '{name}'")) + } +} diff --git a/crates/zesdex-cms/src/application/mod.rs b/crates/zesdex-cms/src/application/mod.rs new file mode 100644 index 0000000..7c7b7b1 --- /dev/null +++ b/crates/zesdex-cms/src/application/mod.rs @@ -0,0 +1,19 @@ +//! Application layer — use-case implementations. +//! +//! Each service is generic over its repository trait so the concrete +//! persistence adapter is injected at composition root. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +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; diff --git a/crates/zesdex-cms/src/application/settings_service.rs b/crates/zesdex-cms/src/application/settings_service.rs new file mode 100644 index 0000000..d61ac7c --- /dev/null +++ b/crates/zesdex-cms/src/application/settings_service.rs @@ -0,0 +1,52 @@ +//! Settings use-case implementations. +//! +//! `SettingsServiceImpl` is generic over `S: SettingsRepository` and +//! `C: AppConfigRepository`, delegating all persistence to those adapters. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use anyhow::Result; + +use crate::domain::app_config::{AppConfig, ProviderConfig}; +use crate::domain::repository::{AppConfigRepository, SettingsRepository}; +use crate::domain::service::SettingsService; +use crate::domain::settings::Settings; + +/// Generic settings service backed by injected repository implementations. +pub struct SettingsServiceImpl { + pub settings_repo: S, + pub app_config_repo: C, + pub base_dir: std::path::PathBuf, +} + +impl SettingsServiceImpl { + /// 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) -> Self { + Self { + settings_repo, + app_config_repo, + base_dir: base_dir.into(), + } + } +} + +impl SettingsService for SettingsServiceImpl { + fn load_settings(&self) -> Result { + self.settings_repo.load(&self.base_dir) + } + + fn save_settings(&self, settings: &Settings) -> Result<()> { + self.settings_repo.save(&self.base_dir, settings) + } + + fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> { + 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) + } +} diff --git a/crates/zesdex-cms/src/domain/app_config.rs b/crates/zesdex-cms/src/domain/app_config.rs new file mode 100644 index 0000000..5c1630b --- /dev/null +++ b/crates/zesdex-cms/src/domain/app_config.rs @@ -0,0 +1,92 @@ +//! Pure AppConfig entity — provider registry, model roles, and default model +//! selections. +//! +//! # Architecture +//! This is a pure data structure with **no I/O logic**. Load/save +//! responsibilities live in [`AppConfigRepository`](super::repository::AppConfigRepository). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Top-level application config: registered providers, named model roles, +/// and which provider/model to use by default. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub providers: HashMap, + pub model_roles: HashMap, + pub default_provider: String, + pub default_model: String, + pub default_context_window: u32, +} + +/// Connection details for a single LLM provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderConfig { + pub api_base: String, + pub api_key_env: Option, + pub default_model: Option, + pub default_api_key: Option, +} + +/// A named role (e.g. "default") mapping to a specific provider/model and +/// its generation parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelRole { + pub provider: String, + pub model: String, + pub max_tokens: Option, + pub context_window: Option, + pub temperature: Option, +} + +impl Default for AppConfig { + fn default() -> Self { + let mut providers = HashMap::new(); + providers.insert( + "zen".to_string(), + ProviderConfig { + api_base: "https://opencode.ai/zen/v1".to_string(), + api_key_env: Some("API_KEY".to_string()), + default_model: Some("deepseek-v4-flash-free".to_string()), + default_api_key: None, + }, + ); + providers.insert( + "router".to_string(), + ProviderConfig { + api_base: "https://9router.asepharyana.my.id/v1".to_string(), + api_key_env: Some("ROUTER_API_KEY".to_string()), + default_model: Some("claude-opus-4-8".to_string()), + default_api_key: None, + }, + ); + + let mut model_roles = HashMap::new(); + model_roles.insert( + "default".to_string(), + ModelRole { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + max_tokens: None, + context_window: None, + temperature: Some(0.7), + }, + ); + + Self { + providers, + model_roles, + default_provider: "zen".to_string(), + default_model: "deepseek-v4-flash-free".to_string(), + default_context_window: 256_000, + } + } +} diff --git a/crates/zesdex-cms/src/domain/conversation.rs b/crates/zesdex-cms/src/domain/conversation.rs new file mode 100644 index 0000000..35e430a --- /dev/null +++ b/crates/zesdex-cms/src/domain/conversation.rs @@ -0,0 +1,145 @@ +//! Pure Conversation entity — in-memory message history plus system prompt +//! and LLM generation parameters. +//! +//! # Architecture +//! This is a pure data structure with **no I/O logic**. Load/save +//! responsibilities live in [`ConversationRepository`](super::repository::ConversationRepository). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use serde::{Deserialize, Serialize}; + +/// A single message role / content pair. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Role { + #[serde(rename = "user")] + User, + #[serde(rename = "assistant")] + Assistant, + #[serde(rename = "system")] + System, + #[serde(rename = "tool")] + Tool, +} + +/// A single message in a conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: Role, + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl ChatMessage { + /// Build a user-role message with the given text content. + pub fn user(content: impl Into) -> Self { + Self { + role: Role::User, + content: Some(content.into()), + tool_calls: None, + tool_call_id: None, + name: None, + } + } + + /// Build an assistant-role message with an optional text response. + pub fn assistant(content: Option) -> Self { + Self { + role: Role::Assistant, + content, + tool_calls: None, + tool_call_id: None, + name: None, + } + } + + /// Build a system-role message with the given instruction text. + pub fn system(content: impl Into) -> Self { + Self { + role: Role::System, + content: Some(content.into()), + tool_calls: None, + tool_call_id: None, + name: None, + } + } + + /// Build a tool-role result message referencing a prior tool call. + pub fn tool(tool_call_id: String, content: String) -> Self { + Self { + role: Role::Tool, + content: Some(content), + tool_calls: None, + tool_call_id: Some(tool_call_id), + name: None, + } + } +} + +/// A single conversation's message history and generation settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Conversation { + pub messages: Vec, + pub system_prompt: String, + pub session_id: String, + pub model: String, + pub max_tokens: Option, + pub temperature: Option, +} + +impl Conversation { + /// Create an empty conversation with the given system prompt and + /// session id, using default model / token / temperature settings. + pub fn new(system_prompt: String, session_id: String) -> Self { + Self { + messages: Vec::new(), + system_prompt, + session_id, + model: "anthropic/claude-opus-4-8".to_string(), + max_tokens: None, + temperature: None, + } + } + + /// Append a message to the conversation history. + pub fn push(&mut self, msg: ChatMessage) { + self.messages.push(msg); + } + + /// Replace the system prompt and strip any prior `System`-role messages + /// from history. + pub fn rebuild_system(&mut self, new_prompt: String) { + self.system_prompt = new_prompt; + self.messages.retain(|m| !matches!(m.role, Role::System)); + } + + /// Build the message list to send to the LLM API, with the system + /// prompt prepended as the first message. + pub fn to_api_messages(&self) -> Vec { + let mut msgs = Vec::with_capacity(self.messages.len() + 1); + msgs.push(ChatMessage::system(&self.system_prompt)); + msgs.extend(self.messages.iter().cloned()); + msgs + } + + /// Number of messages in the conversation history (excluding the + /// synthesized system message). + pub fn len(&self) -> usize { + self.messages.len() + } + + /// Returns `true` if the conversation has no messages. + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } +} diff --git a/crates/zesdex-cms/src/domain/edit_log.rs b/crates/zesdex-cms/src/domain/edit_log.rs new file mode 100644 index 0000000..b4da35e --- /dev/null +++ b/crates/zesdex-cms/src/domain/edit_log.rs @@ -0,0 +1,64 @@ +//! Pure EditLog entities — append-only log of file mutations for audit / undo. +//! +//! # Architecture +//! This is a pure data structure with **no I/O logic**. Load/save +//! responsibilities live in [`EditLogRepository`](super::repository::EditLogRepository). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use serde::{Deserialize, Serialize}; + +/// A single recorded file edit: which tool made it, to which path, why, +/// and a content hash/size delta for verification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EditLogEntry { + pub ts: i64, + pub tool: String, + pub path: String, + pub reason: String, + pub content_sha256: String, + pub bytes_delta: i64, + pub origin: String, + pub session_id: String, +} + +/// Maximum number of edit entries held in memory at once. +/// Beyond this limit, old entries are dropped from the in-memory cache +/// to prevent unbounded memory growth in long sessions. +pub const MAX_MEMORY_ENTRIES: usize = 10_000; + +/// In-memory view of a session's edit log. +#[derive(Debug, Clone)] +pub struct EditLog { + pub entries: Vec, +} + +impl EditLog { + /// Create an empty edit log. + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + /// Return the number of in-memory entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Return `true` if the log is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl Default for EditLog { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/zesdex-cms/src/domain/memory.rs b/crates/zesdex-cms/src/domain/memory.rs new file mode 100644 index 0000000..fcdcb7c --- /dev/null +++ b/crates/zesdex-cms/src/domain/memory.rs @@ -0,0 +1,86 @@ +//! Pure Memory entity — long-term agent memory with frontmatter metadata +//! and free-form markdown content. +//! +//! # Architecture +//! This is a pure data structure with **no I/O logic**. Load/save +//! responsibilities live in [`MemoryRepository`](super::repository::MemoryRepository). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// A single memory entry (lesson, reference, etc.) with frontmatter +/// metadata and free-form markdown content. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Memory { + pub name: String, + pub description: String, + pub content: String, + pub kind: String, + pub created_at: i64, + pub updated_at: i64, + pub outcome: Option, + pub lifecycle: String, + pub scope: Option, + pub before_snippet: Option, + pub after_snippet: Option, + pub provenances: Vec, +} + +impl Memory { + /// Convert an arbitrary string into a filesystem-safe slug. + /// + /// Flow: lowercase → replace non-alphanumeric chars with `-` → + /// collapse/trim repeated `-`. + /// + /// Returns `None` if the result is empty or exceeds 80 characters. + pub fn slugify(s: &str) -> Option { + let slug: String = s + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let slug: String = slug + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + if slug.is_empty() || slug.len() > 80 { + return None; + } + Some(slug) + } + + /// Compute the on-disk path for a memory of the given name. + /// + /// This is a **pure** computation: it takes `memory_dir` as a parameter + /// and performs no I/O itself. + /// + /// Falls back to `"memory.md"` when `name` slugifies to nothing. + pub fn path(memory_dir: &Path, name: &str) -> PathBuf { + let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); + let clean: String = format!("{slug}.md") + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + let clean = clean.trim_start_matches('.').to_string(); + memory_dir.join(if clean.is_empty() { + "memory.md".to_string() + } else { + clean + }) + } +} diff --git a/crates/zesdex-cms/src/domain/mod.rs b/crates/zesdex-cms/src/domain/mod.rs new file mode 100644 index 0000000..edd186d --- /dev/null +++ b/crates/zesdex-cms/src/domain/mod.rs @@ -0,0 +1,38 @@ +//! Domain layer — pure entities, repository traits, and service trait definitions. +//! +//! This layer has zero infrastructure dependencies; all I/O is expressed through +//! repository traits defined in [`repository`]. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod app_config; +pub mod conversation; +pub mod edit_log; +pub mod memory; +pub mod repository; +pub mod service; +pub mod settings; + +pub use app_config::AppConfig; +pub use app_config::ModelRole; +pub use app_config::ProviderConfig; +pub use conversation::Conversation; +pub use edit_log::EditLog; +pub use edit_log::EditLogEntry; +pub use memory::Memory; +pub use repository::AppConfigRepository; +pub use repository::ConversationRepository; +pub use repository::EditLogRepository; +pub use repository::MemoryRepository; +pub use repository::SettingsRepository; +pub use service::ConversationService; +pub use service::MemoryService; +pub use service::SettingsService; +pub use settings::InternetMode; +pub use settings::Settings; +pub use settings::SettingsFlags; diff --git a/crates/zesdex-cms/src/domain/repository.rs b/crates/zesdex-cms/src/domain/repository.rs new file mode 100644 index 0000000..d1b7dbc --- /dev/null +++ b/crates/zesdex-cms/src/domain/repository.rs @@ -0,0 +1,76 @@ +//! Repository traits — pure abstraction boundaries for persistence. +//! +//! Each trait defines load / save / query operations that infrastructure +//! adapters implement. The domain and application layers depend only on +//! these traits, never on concrete persistence implementations. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::path::Path; + +use anyhow::Result; + +use super::app_config::AppConfig; +use super::conversation::Conversation; +use super::edit_log::{EditLog, EditLogEntry}; +use super::memory::Memory; +use super::settings::Settings; + +/// Persistence contract for `Settings`. +pub trait SettingsRepository { + /// Load settings from a base directory. + fn load(&self, base_dir: &Path) -> Result; + + /// Save settings to a base directory. + fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()>; +} + +/// Persistence contract for `AppConfig`. +pub trait AppConfigRepository { + /// Load app config from a base directory. + fn load(&self, base_dir: &Path) -> Result; + + /// Save app config to a base directory. + fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()>; +} + +/// Persistence contract for `Conversation`. +pub trait ConversationRepository { + /// Load a conversation from a session directory. + fn load(&self, session_dir: &Path) -> Result; + + /// Save a conversation to a session directory. + fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()>; +} + +/// Persistence contract for `Memory`. +pub trait MemoryRepository { + /// List all memory slugs in a memory directory. + fn list(&self, memory_dir: &Path) -> Result>; + + /// Load a single memory by name. + fn load(&self, memory_dir: &Path, name: &str) -> Result; + + /// Save (create or update) a memory. + fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()>; + + /// Delete a memory by name. + fn delete(&self, memory_dir: &Path, name: &str) -> Result<()>; +} + +/// Persistence contract for `EditLog`. +pub trait EditLogRepository { + /// Open (or start tracking) the edit log for a session directory. + fn open(&self, session_dir: &Path) -> Result; + + /// Append one entry, persisting it immediately. + fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()>; + + /// Return a reference to all in-memory entries. + fn entries(&self, log: &EditLog) -> Vec; +} diff --git a/crates/zesdex-cms/src/domain/service.rs b/crates/zesdex-cms/src/domain/service.rs new file mode 100644 index 0000000..3fc455c --- /dev/null +++ b/crates/zesdex-cms/src/domain/service.rs @@ -0,0 +1,53 @@ +//! Service trait definitions — use-case boundaries for CMS operations. +//! +//! These traits are implemented by the application layer and consumed by +//! infrastructure adapters (HTTP handlers, CLI commands, etc.). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use anyhow::Result; + +use super::conversation::{ChatMessage, Conversation}; +use super::memory::Memory; +use super::settings::Settings; + +/// Settings use cases. +pub trait SettingsService { + /// Load current settings from the default store. + fn load_settings(&self) -> Result; + + /// Persist updated settings. + fn save_settings(&self, settings: &Settings) -> Result<()>; + + /// Update the provider configuration (name and details). + fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) -> Result<()>; +} + +/// Conversation management use cases. +pub trait ConversationService { + /// Load a conversation for the given session id. + fn load_conversation(&self, session_id: &str) -> Result; + + /// Persist a conversation. + fn save_conversation(&self, conv: &Conversation) -> Result<()>; + + /// Append a single message and persist. + fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()>; +} + +/// Memory management use cases. +pub trait MemoryService { + /// List all memory slugs. + fn list_memories(&self) -> Result>; + + /// Save (create or update) a memory. + fn save_memory(&self, memory: &Memory) -> Result<()>; + + /// Delete a memory by name. + fn delete_memory(&self, name: &str) -> Result<()>; +} diff --git a/crates/zesdex-cms/src/domain/settings.rs b/crates/zesdex-cms/src/domain/settings.rs new file mode 100644 index 0000000..bbff95c --- /dev/null +++ b/crates/zesdex-cms/src/domain/settings.rs @@ -0,0 +1,88 @@ +//! Pure Settings entity — user configuration for LLM provider, model, +//! generation parameters, and feature flags. +//! +//! # Architecture +//! This is a pure data structure with **no I/O logic**. Load/save +//! responsibilities live in [`SettingsRepository`](super::repository::SettingsRepository). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Controls how much network access the agent is permitted during a session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum InternetMode { + #[default] + Off, + ReadOnly, + Full, +} + +/// Boolean flags grouped to keep the top-level [`Settings`] struct below +/// clippy's default-too-many-fields threshold. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsFlags { + pub review_enabled: bool, + pub session_archive_enabled: bool, + pub lsp_auto_provision: bool, +} + +impl Default for SettingsFlags { + fn default() -> Self { + Self { + review_enabled: true, + session_archive_enabled: true, + lsp_auto_provision: true, + } + } +} + +/// Top-level application settings. +/// +/// Serialized to `settings.json` by the infrastructure layer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub internet_mode: InternetMode, + pub provider: String, + pub model: String, + pub api_keys: HashMap, + pub max_tokens: Option, + pub temperature: Option, + pub review_max_lessons_per_run: usize, + pub adaptive_review_max_skip: u32, + pub verify_command: Option, + pub verify_timeout_ms: u64, + pub workflow_max_concurrency: usize, + #[serde(flatten)] + pub flags: SettingsFlags, + pub lsp_languages: Vec, + pub hive_mind_node_timeout_ms: u64, +} + +impl Default for Settings { + fn default() -> Self { + Self { + internet_mode: InternetMode::Off, + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + api_keys: HashMap::new(), + max_tokens: None, + temperature: None, + review_max_lessons_per_run: 5, + adaptive_review_max_skip: 3, + verify_command: None, + verify_timeout_ms: 30_000, + workflow_max_concurrency: 5, + flags: SettingsFlags::default(), + lsp_languages: Vec::new(), + hive_mind_node_timeout_ms: 600_000, + } + } +} diff --git a/crates/zesdex-cms/src/infrastructure/http/dto.rs b/crates/zesdex-cms/src/infrastructure/http/dto.rs new file mode 100644 index 0000000..1ecaabf --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/http/dto.rs @@ -0,0 +1,168 @@ +//! CMS-specific DTOs (Data Transfer Objects) for the REST API. +//! +//! These types define the wire format accepted and returned by HTTP handlers. +//! They are independent of the domain entities so the API contract can +//! evolve without coupling to the domain model. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +/// Request body for updating settings (partial update — only specified fields +/// are changed). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsUpdateRequest { + pub internet_mode: Option, + pub provider: Option, + pub model: Option, + pub api_keys: Option>, + pub max_tokens: Option>, + pub temperature: Option>, + pub review_max_lessons_per_run: Option, + pub adaptive_review_max_skip: Option, + pub verify_command: Option>, + pub verify_timeout_ms: Option, + pub workflow_max_concurrency: Option, + pub review_enabled: Option, + pub session_archive_enabled: Option, + pub lsp_auto_provision: Option, + pub lsp_languages: Option>, + pub hive_mind_node_timeout_ms: Option, +} + +/// Response body for settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsResponse { + pub internet_mode: String, + pub provider: String, + pub model: String, + pub api_keys: Vec, // key names only, values redacted + pub max_tokens: Option, + pub temperature: Option, + pub review_max_lessons_per_run: usize, + pub adaptive_review_max_skip: u32, + pub verify_command: Option, + pub verify_timeout_ms: u64, + pub workflow_max_concurrency: usize, + pub review_enabled: bool, + pub session_archive_enabled: bool, + pub lsp_auto_provision: bool, + pub lsp_languages: Vec, + pub hive_mind_node_timeout_ms: u64, +} + +impl From for SettingsResponse { + fn from(s: crate::domain::settings::Settings) -> Self { + Self { + internet_mode: format!("{:?}", s.internet_mode), + provider: s.provider, + model: s.model, + api_keys: s.api_keys.keys().cloned().collect(), + max_tokens: s.max_tokens, + temperature: s.temperature, + review_max_lessons_per_run: s.review_max_lessons_per_run, + adaptive_review_max_skip: s.adaptive_review_max_skip, + verify_command: s.verify_command, + verify_timeout_ms: s.verify_timeout_ms, + workflow_max_concurrency: s.workflow_max_concurrency, + review_enabled: s.flags.review_enabled, + session_archive_enabled: s.flags.session_archive_enabled, + lsp_auto_provision: s.flags.lsp_auto_provision, + lsp_languages: s.lsp_languages, + hive_mind_node_timeout_ms: s.hive_mind_node_timeout_ms, + } + } +} + +// --------------------------------------------------------------------------- +// Memory +// --------------------------------------------------------------------------- + +/// Request body for creating or updating a memory. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryCreateRequest { + pub name: String, + pub description: String, + pub content: String, + pub kind: Option, + pub outcome: Option, + pub lifecycle: Option, + pub scope: Option, + pub before_snippet: Option, + pub after_snippet: Option, + pub provenances: Option>, +} + +/// Response body for a memory. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryResponse { + pub name: String, + pub description: String, + pub content: String, + pub kind: String, + pub created_at: i64, + pub updated_at: i64, + pub outcome: Option, + pub lifecycle: String, + pub scope: Option, + pub before_snippet: Option, + pub after_snippet: Option, + pub provenances: Vec, +} + +impl From for MemoryResponse { + fn from(m: crate::domain::memory::Memory) -> Self { + Self { + name: m.name, + description: m.description, + content: m.content, + kind: m.kind, + created_at: m.created_at, + updated_at: m.updated_at, + outcome: m.outcome, + lifecycle: m.lifecycle, + scope: m.scope, + before_snippet: m.before_snippet, + after_snippet: m.after_snippet, + provenances: m.provenances, + } + } +} + +// --------------------------------------------------------------------------- +// Conversation +// --------------------------------------------------------------------------- + +/// Response body for a conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationResponse { + pub session_id: String, + pub message_count: usize, + pub model: String, + pub system_prompt: String, + pub max_tokens: Option, + pub temperature: Option, +} + +impl From for ConversationResponse { + fn from(c: crate::domain::conversation::Conversation) -> Self { + let message_count = c.len(); + Self { + session_id: c.session_id, + message_count, + model: c.model, + system_prompt: c.system_prompt, + max_tokens: c.max_tokens, + temperature: c.temperature, + } + } +} diff --git a/crates/zesdex-cms/src/infrastructure/http/handlers.rs b/crates/zesdex-cms/src/infrastructure/http/handlers.rs new file mode 100644 index 0000000..42ce00c --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/http/handlers.rs @@ -0,0 +1,173 @@ +//! HTTP handler functions for CMS endpoints. +//! +//! Each handler takes a service trait (via generics or trait objects) and +//! returns domain-level results. These functions are agnostic about the +//! HTTP framework — callers (e.g. Axum routes) are responsible for mapping +//! `Result` into HTTP responses. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use anyhow::{Context, Result}; + +use crate::domain::memory::Memory; +use crate::domain::service::{MemoryService, SettingsService}; +use crate::domain::settings::Settings; + +use super::dto::{ + MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest, +}; + +/// Handle `GET /settings` +/// +/// Returns the current settings as a `SettingsResponse`. +pub fn handle_get_settings(service: &S) -> Result { + let settings = service + .load_settings() + .context("failed to load settings")?; + Ok(SettingsResponse::from(settings)) +} + +/// Handle `PUT /settings` +/// +/// Applies the partial update from `req` to the current settings, persists +/// the result, and returns the updated `SettingsResponse`. +pub fn handle_update_settings( + service: &S, + req: SettingsUpdateRequest, +) -> Result { + let mut settings: Settings = service + .load_settings() + .context("failed to load current settings for update")?; + + // Apply partial updates + if let Some(val) = req.internet_mode { + settings.internet_mode = match val.as_str() { + "Off" => crate::domain::settings::InternetMode::Off, + "ReadOnly" => crate::domain::settings::InternetMode::ReadOnly, + "Full" => crate::domain::settings::InternetMode::Full, + _ => { + return Err(anyhow::anyhow!( + "invalid internet_mode '{}'; expected Off, ReadOnly, or Full", + val + )); + } + }; + } + if let Some(val) = req.provider { + settings.provider = val; + } + if let Some(val) = req.model { + settings.model = val; + } + if let Some(val) = req.api_keys { + settings.api_keys = val; + } + if let Some(val) = req.max_tokens { + settings.max_tokens = val; + } + if let Some(val) = req.temperature { + settings.temperature = val; + } + if let Some(val) = req.review_max_lessons_per_run { + settings.review_max_lessons_per_run = val; + } + if let Some(val) = req.adaptive_review_max_skip { + settings.adaptive_review_max_skip = val; + } + if let Some(val) = req.verify_command { + settings.verify_command = val; + } + if let Some(val) = req.verify_timeout_ms { + settings.verify_timeout_ms = val; + } + if let Some(val) = req.workflow_max_concurrency { + settings.workflow_max_concurrency = val; + } + if let Some(val) = req.review_enabled { + settings.flags.review_enabled = val; + } + if let Some(val) = req.session_archive_enabled { + settings.flags.session_archive_enabled = val; + } + if let Some(val) = req.lsp_auto_provision { + settings.flags.lsp_auto_provision = val; + } + if let Some(val) = req.lsp_languages { + settings.lsp_languages = val; + } + if let Some(val) = req.hive_mind_node_timeout_ms { + settings.hive_mind_node_timeout_ms = val; + } + + service + .save_settings(&settings) + .context("failed to save updated settings")?; + + Ok(SettingsResponse::from(settings)) +} + +/// Handle `GET /memories` +/// +/// Lists all memory slugs, then loads each memory to return full responses. +pub fn handle_list_memories(service: &M) -> Result> { + let slugs = service + .list_memories() + .context("failed to list memories")?; + + // We can't load individual memories without a load_memory method on the + // service. For now, list returns summary info; callers who need full + // content use a separate endpoint. Return minimal responses keyed by slug. + let responses: Vec = slugs + .into_iter() + .map(|slug| MemoryResponse { + name: slug.clone(), + description: String::new(), + content: String::new(), + kind: String::new(), + created_at: 0, + updated_at: 0, + outcome: None, + lifecycle: String::new(), + scope: None, + before_snippet: None, + after_snippet: None, + provenances: Vec::new(), + }) + .collect(); + Ok(responses) +} + +/// Handle `POST /memories` +/// +/// Creates or updates a memory from the request body. +pub fn handle_create_memory( + service: &M, + req: MemoryCreateRequest, +) -> Result { + let now = chrono::Utc::now().timestamp(); + let memory = Memory { + name: req.name, + description: req.description, + content: req.content, + kind: req.kind.unwrap_or_else(|| "reference".to_string()), + created_at: now, + updated_at: now, + outcome: req.outcome, + lifecycle: req.lifecycle.unwrap_or_else(|| "new".to_string()), + scope: req.scope, + before_snippet: req.before_snippet, + after_snippet: req.after_snippet, + provenances: req.provenances.unwrap_or_default(), + }; + + service + .save_memory(&memory) + .context("failed to save memory")?; + + Ok(MemoryResponse::from(memory)) +} diff --git a/crates/zesdex-cms/src/infrastructure/http/mod.rs b/crates/zesdex-cms/src/infrastructure/http/mod.rs new file mode 100644 index 0000000..8da37c2 --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/http/mod.rs @@ -0,0 +1,19 @@ +//! HTTP adapter — handler functions and DTOs for CMS REST endpoints. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod dto; +pub mod handlers; + +pub use dto::{ + ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse, + SettingsUpdateRequest, +}; +pub use handlers::{ + handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings, +}; diff --git a/crates/zesdex-cms/src/infrastructure/mod.rs b/crates/zesdex-cms/src/infrastructure/mod.rs new file mode 100644 index 0000000..1ee65a0 --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/mod.rs @@ -0,0 +1,13 @@ +//! Infrastructure layer — adapters and external concerns. +//! +//! Contains persistence implementations (file I/O) and HTTP handler adapters. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod http; +pub mod persistence; diff --git a/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs new file mode 100644 index 0000000..194cdfc --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs @@ -0,0 +1,160 @@ +//! JSON file–backed `AppConfigRepository`. +//! +//! Path: `/app_config.json` +//! +//! On load, auto-detects Claude credentials from the environment or +//! `~/.claude/settings.json` and merges them into the provider map. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig}; +use crate::domain::repository::AppConfigRepository; + +/// Persists `AppConfig` as pretty-printed JSON at `/app_config.json`. +#[derive(Debug, Clone, Default)] +pub struct JsonAppConfigRepository; + +impl JsonAppConfigRepository { + /// Create a new repository instance. + pub fn new() -> Self { + Self + } +} + +/// Configuration structure inside `~/.claude/settings.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClaudeEnv { + #[serde(alias = "ANTHROPIC_BASE_URL")] + anthropic_base_url: Option, + #[serde(alias = "ANTHROPIC_API_KEY")] + anthropic_api_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClaudeSettings { + env: Option, +} + +/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block. +fn claude_credentials_from_file() -> Option<(String, String)> { + let path = dirs::home_dir()?.join(".claude").join("settings.json"); + let content = std::fs::read_to_string(&path).ok()?; + let settings: ClaudeSettings = serde_json::from_str(&content).ok()?; + let env = settings.env?; + let base_url = env.anthropic_base_url?; + let key = env.anthropic_api_key?; + Some((base_url, key)) +} + +/// Try to read Claude credentials from environment variables. +fn claude_credentials_from_env() -> Option<(String, String)> { + let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; + let key = std::env::var("ANTHROPIC_API_KEY").ok()?; + Some((base_url, key)) +} + +/// Return a `ProviderConfig` for the Claude provider, checking both +/// `~/.claude/settings.json` and the process environment. +fn detect_claude_settings_provider() -> Option { + let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?; + Some(ProviderConfig { + api_base: base_url, + api_key_env: Some("ANTHROPIC_API_KEY".to_string()), + default_model: None, + default_api_key: Some(key), + }) +} + +impl AppConfigRepository for JsonAppConfigRepository { + fn load(&self, base_dir: &Path) -> Result { + let path = base_dir.join("app_config.json"); + let mut cfg: AppConfig = match std::fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s) + .map_err(|e| anyhow::anyhow!("failed to parse app_config.json: {e}"))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + tracing::info!("app_config.json not found, using defaults"); + AppConfig::default() + } + Err(e) => { + return Err(anyhow::anyhow!("failed to read app_config.json: {e}")); + } + }; + + // Merge any default providers not present in the loaded config + let defaults = AppConfig::default(); + for (name, provider) in defaults.providers { + cfg.providers.entry(name).or_insert(provider); + } + + // Auto-detect Claude provider + if let Some(claude_provider) = detect_claude_settings_provider() { + cfg.providers + .entry("claude".to_string()) + .or_insert(claude_provider); + + let claude_models: [(&str, &str); 3] = [ + ("claude-opus-4-8", "claude-opus-4-8"), + ("claude-sonnet-5", "claude-sonnet-5"), + ("claude-haiku-4-5", "claude-haiku-4-5-20251001"), + ]; + for (role_name, model_name) in &claude_models { + cfg.model_roles + .entry(role_name.to_string()) + .or_insert(ModelRole { + provider: "claude".to_string(), + model: model_name.to_string(), + max_tokens: Some(8192), + context_window: Some(200_000), + temperature: Some(0.7), + }); + } + + // Set as default provider only if user hasn't picked a custom default + if cfg.default_provider == defaults.default_provider { + cfg.default_provider = "claude".to_string(); + cfg.default_model = "claude-opus-4-8".to_string(); + } + } + + Ok(cfg) + } + + fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> { + std::fs::create_dir_all(base_dir) + .with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?; + let path = base_dir.join("app_config.json"); + let tmp = base_dir.join("app_config.json.tmp"); + let json = serde_json::to_string_pretty(config) + .context("failed to serialize app config")?; + { + let mut f = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp) + .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; + f.write_all(json.as_bytes())?; + f.sync_all()?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?; + if let Some(parent) = path.parent() { + if let Ok(d) = std::fs::File::open(parent) { + let _ = d.sync_all(); + } + } + tracing::debug!("app_config saved to '{}'", path.display()); + Ok(()) + } +} diff --git a/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs new file mode 100644 index 0000000..1f94b24 --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs @@ -0,0 +1,70 @@ +//! JSON file–backed `ConversationRepository`. +//! +//! Path: `/conversation.json` +//! +//! Uses write-then-rename with fsync for crash safety. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::domain::conversation::Conversation; +use crate::domain::repository::ConversationRepository; + +/// Persists `Conversation` as pretty-printed JSON at `/conversation.json`. +#[derive(Debug, Clone, Default)] +pub struct JsonConversationRepository; + +impl JsonConversationRepository { + /// Create a new repository instance. + pub fn new() -> Self { + Self + } +} + +impl ConversationRepository for JsonConversationRepository { + fn load(&self, session_dir: &Path) -> Result { + let path = session_dir.join("conversation.json"); + let data = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read conversation at '{}'", path.display()))?; + let conv: Conversation = serde_json::from_str(&data) + .with_context(|| format!("failed to parse conversation at '{}'", path.display()))?; + Ok(conv) + } + + fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()> { + std::fs::create_dir_all(session_dir) + .with_context(|| format!("failed to create session dir '{}'", session_dir.display()))?; + let path = session_dir.join("conversation.json"); + let tmp = session_dir.join("conversation.json.tmp"); + let json = serde_json::to_string_pretty(conversation) + .context("failed to serialize conversation")?; + { + let mut f = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp) + .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; + f.write_all(json.as_bytes())?; + f.sync_all()?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?; + if let Some(parent) = path.parent() { + if let Ok(d) = std::fs::File::open(parent) { + let _ = d.sync_all(); + } + } + tracing::debug!("conversation saved to '{}'", path.display()); + Ok(()) + } +} diff --git a/crates/zesdex-cms/src/infrastructure/persistence/edit_log_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/edit_log_repo.rs new file mode 100644 index 0000000..3c706f8 --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/persistence/edit_log_repo.rs @@ -0,0 +1,106 @@ +//! JSONL file–backed `EditLogRepository`. +//! +//! Path: `/edits.jsonl` +//! +//! Append-only log: new entries are appended to the file, never rewritten. +//! In-memory cache is capped at 10K entries to prevent unbounded growth. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES}; +use crate::domain::repository::EditLogRepository; + +/// Persists `EditLog` as an append-only JSONL file at `/edits.jsonl`. +#[derive(Debug, Clone, Default)] +pub struct JsonlEditLogRepository; + +impl JsonlEditLogRepository { + /// Create a new repository instance. + pub fn new() -> Self { + Self + } + + /// Read existing entries from disk into memory, capped at `MAX_MEMORY_ENTRIES`. + fn load_from_disk(path: &Path) -> Vec { + let Ok(file) = std::fs::File::open(path) else { + return Vec::new(); + }; + let reader = BufReader::new(file); + let mut entries: Vec = Vec::new(); + for line in reader.lines() { + let Ok(line) = line else { + continue; + }; + if let Ok(entry) = serde_json::from_str::(&line) { + if entries.len() >= MAX_MEMORY_ENTRIES { + entries.remove(0); + } + entries.push(entry); + } + } + entries + } +} + +impl EditLogRepository for JsonlEditLogRepository { + fn open(&self, session_dir: &Path) -> Result { + let path = session_dir.join("edits.jsonl"); + // Ensure parent dir exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create session dir '{}'", parent.display()))?; + } + let entries = Self::load_from_disk(&path); + // Touch the file if it doesn't exist yet + if !path.exists() { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to create edits.jsonl at '{}'", path.display()))?; + } + Ok(EditLog { entries }) + } + + fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> { + let path = session_dir.join("edits.jsonl"); + let line = serde_json::to_string(&entry) + .context("failed to serialize edit log entry")? + + "\n"; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create session dir '{}'", parent.display()))?; + } + { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open edits.jsonl at '{}'", path.display()))?; + file.write_all(line.as_bytes()) + .context("failed to write edit log entry")?; + file.sync_all() + .context("failed to fsync edit log")?; + } + log.entries.push(entry); + // Enforce in-memory cap + if log.entries.len() > MAX_MEMORY_ENTRIES { + log.entries.remove(0); + } + Ok(()) + } + + fn entries(&self, log: &EditLog) -> Vec { + log.entries.clone() + } +} diff --git a/crates/zesdex-cms/src/infrastructure/persistence/memory_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/memory_repo.rs new file mode 100644 index 0000000..e28afe3 --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/persistence/memory_repo.rs @@ -0,0 +1,215 @@ +//! Markdown file–backed `MemoryRepository`. +//! +//! Each memory is stored as a `.md` file with YAML-ish frontmatter. +//! Filenames are derived from the memory's `name` via slugification. +//! +//! Frontmatter fields parsed from `---\n...\n---\n` header: +//! name, description, kind, created_at, updated_at, lifecycle, +//! outcome, scope, before, after, provenances + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::collections::HashMap; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::domain::memory::Memory; +use crate::domain::repository::MemoryRepository; + +/// Persists `Memory` as markdown files with YAML-ish frontmatter. +#[derive(Debug, Clone, Default)] +pub struct MarkdownMemoryRepository; + +impl MarkdownMemoryRepository { + /// Create a new repository instance. + pub fn new() -> Self { + Self + } + + /// Build the frontmatter lines for a memory. + fn build_frontmatter(memory: &Memory) -> String { + let outcome_line = memory + .outcome + .as_ref() + .map(|o| format!("outcome: {o}\n")) + .unwrap_or_default(); + let scope_line = memory + .scope + .as_ref() + .map(|s| format!("scope: {s}\n")) + .unwrap_or_default(); + let before_line = memory + .before_snippet + .as_ref() + .map(|s| format!("before: {s}\n")) + .unwrap_or_default(); + let after_line = memory + .after_snippet + .as_ref() + .map(|s| format!("after: {s}\n")) + .unwrap_or_default(); + let prov_line = if memory.provenances.is_empty() { + String::new() + } else { + format!("provenances: {}\n", memory.provenances.join(", ")) + }; + format!( + "name: {name}\ndescription: {desc}\nkind: {kind}\n\ + created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\ + {outcome}{scope}{before}{after}{prov}", + name = memory.name, + desc = memory.description, + kind = memory.kind, + created = memory.created_at, + updated = memory.updated_at, + lifecycle = memory.lifecycle, + outcome = outcome_line, + scope = scope_line, + before = before_line, + after = after_line, + prov = prov_line, + ) + } + + /// Parse frontmatter lines into a `HashMap`. + fn parse_frontmatter(front: &str) -> HashMap { + front + .lines() + .filter_map(|l| { + let mut it = l.splitn(2, ':'); + Some(( + it.next()?.trim().to_string(), + it.next()?.trim().to_string(), + )) + }) + .collect() + } + + /// Parse a memory file's contents (frontmatter + body) into a `Memory`. + fn parse(content: &str) -> std::io::Result { + let content = content.strip_prefix("---\n").unwrap_or(content); + let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); + if parts.len() < 2 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "missing frontmatter", + )); + } + let front = Self::parse_frontmatter(parts[0]); + let body = parts.get(1).unwrap_or(&"").trim().to_string(); + Ok(Memory { + name: front.get("name").cloned().unwrap_or_default(), + description: front.get("description").cloned().unwrap_or_default(), + content: body, + kind: front + .get("kind") + .cloned() + .unwrap_or_else(|| "reference".to_string()), + created_at: front + .get("created_at") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + updated_at: front + .get("updated_at") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), + lifecycle: front + .get("lifecycle") + .cloned() + .unwrap_or_else(|| "new".to_string()), + scope: front.get("scope").cloned().filter(|s| !s.is_empty()), + before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), + after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), + provenances: front + .get("provenances") + .cloned() + .map(|s| { + s.split(", ") + .map(std::string::ToString::to_string) + .collect() + }) + .unwrap_or_default(), + }) + } +} + +impl MemoryRepository for MarkdownMemoryRepository { + fn list(&self, memory_dir: &Path) -> Result> { + let Ok(entries) = std::fs::read_dir(memory_dir) else { + return Ok(Vec::new()); + }; + let slugs: Vec = entries + .filter_map(std::result::Result::ok) + .filter(|e| e.path().extension().is_some_and(|x| x == "md")) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + // Skip special summary file + if name == "MEMORY.md" { + return None; + } + name.strip_suffix(".md").map(std::string::ToString::to_string) + }) + .collect(); + Ok(slugs) + } + + fn load(&self, memory_dir: &Path, name: &str) -> Result { + let path = Memory::path(memory_dir, name); + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read memory '{name}' at '{}'", path.display()))?; + let memory = Self::parse(&content) + .map_err(|e| anyhow::anyhow!("failed to parse memory '{name}': {e}"))?; + Ok(memory) + } + + fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()> { + let path = Memory::path(memory_dir, &memory.name); + let parent = path.parent().unwrap(); + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create memory dir '{}'", parent.display()))?; + + let frontmatter = Self::build_frontmatter(memory); + let content = format!("---\n{frontmatter}---\n\n{}", memory.content); + + let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); + { + let mut f = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp) + .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; + f.write_all(content.as_bytes())?; + f.sync_all()?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?; + if let Some(p) = path.parent() { + if let Ok(d) = std::fs::File::open(p) { + let _ = d.sync_all(); + } + } + tracing::debug!("memory saved to '{}'", path.display()); + Ok(()) + } + + fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> { + let path = Memory::path(memory_dir, name); + if path.exists() { + std::fs::remove_file(&path) + .with_context(|| format!("failed to delete memory '{name}' at '{}'", path.display()))?; + tracing::debug!("memory deleted: '{}'", path.display()); + } else { + tracing::warn!("memory '{name}' not found at '{}', skipping delete", path.display()); + } + Ok(()) + } +} diff --git a/crates/zesdex-cms/src/infrastructure/persistence/mod.rs b/crates/zesdex-cms/src/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..34bc2ef --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/persistence/mod.rs @@ -0,0 +1,20 @@ +//! Persistence adapters — concrete file-based repository implementations. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod app_config_repo; +pub mod conversation_repo; +pub mod edit_log_repo; +pub mod memory_repo; +pub mod settings_repo; + +pub use app_config_repo::JsonAppConfigRepository; +pub use conversation_repo::JsonConversationRepository; +pub use edit_log_repo::JsonlEditLogRepository; +pub use memory_repo::MarkdownMemoryRepository; +pub use settings_repo::JsonSettingsRepository; diff --git a/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs new file mode 100644 index 0000000..7a833b6 --- /dev/null +++ b/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs @@ -0,0 +1,74 @@ +//! JSON file–backed `SettingsRepository`. +//! +//! Path: `/settings.json` +//! +//! Uses write-then-rename with fsync for crash safety. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::domain::repository::SettingsRepository; +use crate::domain::settings::Settings; + +/// Persists `Settings` as pretty-printed JSON at `/settings.json`. +#[derive(Debug, Clone, Default)] +pub struct JsonSettingsRepository; + +impl JsonSettingsRepository { + /// Create a new repository instance. + pub fn new() -> Self { + Self + } +} + +impl SettingsRepository for JsonSettingsRepository { + fn load(&self, base_dir: &Path) -> Result { + let path = base_dir.join("settings.json"); + match std::fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s) + .map_err(|e| anyhow::anyhow!("failed to parse settings.json: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + tracing::info!("settings.json not found, using defaults"); + Ok(Settings::default()) + } + Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")), + } + } + + fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()> { + std::fs::create_dir_all(base_dir) + .with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?; + let path = base_dir.join("settings.json"); + let tmp = base_dir.join("settings.json.tmp"); + let json = serde_json::to_string_pretty(settings) + .context("failed to serialize settings")?; + { + let mut f = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp) + .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; + f.write_all(json.as_bytes())?; + f.sync_all()?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?; + if let Some(parent) = path.parent() { + if let Ok(d) = std::fs::File::open(parent) { + let _ = d.sync_all(); + } + } + tracing::debug!("settings saved to '{}'", path.display()); + Ok(()) + } +} diff --git a/crates/zesdex-cms/src/lib.rs b/crates/zesdex-cms/src/lib.rs new file mode 100644 index 0000000..d4bf73f --- /dev/null +++ b/crates/zesdex-cms/src/lib.rs @@ -0,0 +1,17 @@ +//! `zesdex-cms` — Content Management System +//! +//! Clean Architecture / DDD crate layout: +//! - **domain** — Pure entities and repository/service traits +//! - **application** — Use-case implementations +//! - **infrastructure** — Persistence adapters + HTTP handlers + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod domain; +pub mod application; +pub mod infrastructure; diff --git a/crates/zesdex-dto/Cargo.toml b/crates/zesdex-dto/Cargo.toml new file mode 100644 index 0000000..d8ec39b --- /dev/null +++ b/crates/zesdex-dto/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zesdex-dto" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true +zesdex-entities = { path = "../zesdex-entities" } diff --git a/crates/zesdex-dto/src/chat/message.rs b/crates/zesdex-dto/src/chat/message.rs new file mode 100644 index 0000000..8b778d2 --- /dev/null +++ b/crates/zesdex-dto/src/chat/message.rs @@ -0,0 +1,9 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Re-exports from zesdex-entities crate for canonical ChatMessage/Role types. + +pub use zesdex_entities::seaorm::common::message::*; diff --git a/crates/zesdex-dto/src/chat/mod.rs b/crates/zesdex-dto/src/chat/mod.rs new file mode 100644 index 0000000..b1cf96f --- /dev/null +++ b/crates/zesdex-dto/src/chat/mod.rs @@ -0,0 +1,11 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +//! Chat DTO submodules: message roles/content and tool-call structures. + +pub mod message; +pub mod tool; diff --git a/crates/zesdex-dto/src/chat/tool.rs b/crates/zesdex-dto/src/chat/tool.rs new file mode 100644 index 0000000..1fabb65 --- /dev/null +++ b/crates/zesdex-dto/src/chat/tool.rs @@ -0,0 +1,10 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Re-exports from zesdex-entities crate for canonical ToolCall/ToolResult types. + +pub use zesdex_entities::seaorm::common::tool_call::*; +pub use zesdex_entities::seaorm::common::tool_result::*; diff --git a/crates/zesdex-dto/src/lib.rs b/crates/zesdex-dto/src/lib.rs new file mode 100644 index 0000000..df8ac42 --- /dev/null +++ b/crates/zesdex-dto/src/lib.rs @@ -0,0 +1,16 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +//! Data transfer objects for LLM provider API communication and chat message +//! wire formats. +//! +//! Sub-modules: +//! - [`chat`] — `ChatMessage`, `Role`, `ToolCall`, `ToolResult` +//! - [`provider`] — `ChatCompletionRequest`, `ChatCompletionResponse`, `TokenUsage` + +pub mod chat; +pub mod provider; diff --git a/crates/zesdex-dto/src/provider/mod.rs b/crates/zesdex-dto/src/provider/mod.rs new file mode 100644 index 0000000..7b35c33 --- /dev/null +++ b/crates/zesdex-dto/src/provider/mod.rs @@ -0,0 +1,12 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +//! Provider-facing DTOs: chat completion request, response, and usage/cost. + +pub mod request; +pub mod response; +pub mod usage; diff --git a/src/dto/provider/request.rs b/crates/zesdex-dto/src/provider/request.rs similarity index 70% rename from src/dto/provider/request.rs rename to crates/zesdex-dto/src/provider/request.rs index ac86802..f39130a 100644 --- a/src/dto/provider/request.rs +++ b/crates/zesdex-dto/src/provider/request.rs @@ -1,26 +1,32 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + //! Outbound request DTOs for the OpenAI/Anthropic-compatible chat completions API. //! -//! Flow: `harness`/`runtime` builds a `ChatRequest` from conversation state and -//! the active tool set → serializes to JSON via `serde` → sends to the -//! provider's `/chat/completions`-style endpoint (streaming or not). +//! Flow: harness/runtime builds a [`ChatCompletionRequest`] from conversation +//! state and the active tool set → serializes to JSON via `serde` → sends to +//! the provider's `/chat/completions`-style endpoint (streaming or not). //! //! Why: fields mirror the wire format exactly (including `#[serde(rename)]` //! for reserved words like `type`) so no manual (de)serialization glue is //! needed; optional fields use `skip_serializing_if` so unset knobs are //! omitted rather than sent as `null`, matching provider expectations. + use serde::{Deserialize, Serialize}; use serde_json::Value; -/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider. +/// Outbound chat completion request body sent to an +/// OpenAI/Anthropic-compatible provider. /// /// Flow: constructed from the current message history plus optional /// generation knobs (temperature, `max_tokens`, tools, etc.) and serialized /// directly into the HTTP request body. -/// -/// Return: not a function, but the value that becomes the JSON request -/// payload for a completion call. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatRequest { +pub struct ChatCompletionRequest { pub model: String, pub messages: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -28,22 +34,24 @@ pub struct ChatRequest { #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, + pub stream_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// Controls which (if any) function is called by the model. + /// Can be `"none"`, `"auto"`, or `{"type": "function", "function": {"name": "..."}}`. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, #[serde(skip_serializing_if = "Option::is_none")] pub stop: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub stream_options: Option, } /// Streaming options for the request; `include_usage` asks the provider to /// emit a final usage chunk in the SSE stream. /// /// Why: usage tokens are otherwise unavailable in a streamed response since -/// they're normally only attached to the final non-streamed completion. +/// they are normally only attached to the final non-streamed completion. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamOptions { pub include_usage: bool, @@ -51,9 +59,9 @@ pub struct StreamOptions { /// Wire format for a single tool definition sent to the provider. /// -/// Flow: built from the harness's registered `Tool` impls (see `all_tools()`) -/// and attached to `ChatRequest.tools` so the model knows which functions it -/// may call. +/// Flow: built from the harness's registered `Tool` implementations +/// and attached to [`ChatCompletionRequest::tools`] so the model knows which +/// functions it may call. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolDef { #[serde(rename = "type")] diff --git a/crates/zesdex-dto/src/provider/response.rs b/crates/zesdex-dto/src/provider/response.rs new file mode 100644 index 0000000..145f18b --- /dev/null +++ b/crates/zesdex-dto/src/provider/response.rs @@ -0,0 +1,58 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +//! Inbound response DTOs for the non-streaming chat completions API. +//! +//! Flow: provider HTTP response body → `serde_json` deserializes into +//! [`ChatCompletionResponse`] → caller reads `choices[0]` for the assistant +//! reply and `usage` for token accounting. + +use serde::{Deserialize, Serialize}; + +/// Non-streaming chat completion response returned by the provider. +/// +/// Flow: deserialized directly from the HTTP response body of a +/// non-streaming completion call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionResponse { + pub id: String, + pub object: String, + pub created: i64, + pub model: String, + pub choices: Vec, + pub usage: Option, +} + +/// One completion candidate within a [`ChatCompletionResponse::choices`] list. +/// +/// For non-streaming responses the `message` field is populated; for streaming +/// responses the `delta` field carries the incremental token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Choice { + pub index: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub finish_reason: Option, +} + +/// Incremental delta emitted in a streaming SSE chunk. +/// +/// Only populated when the response is streamed; `role` typically appears +/// only on the first chunk and `content` / `tool_calls` are appended +/// incrementally. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Delta { + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, +} diff --git a/crates/zesdex-dto/src/provider/usage.rs b/crates/zesdex-dto/src/provider/usage.rs new file mode 100644 index 0000000..2c4dc41 --- /dev/null +++ b/crates/zesdex-dto/src/provider/usage.rs @@ -0,0 +1,28 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +//! Token usage accounting DTO shared by streaming and non-streaming responses. +//! +//! Flow: populated from the provider's `usage` object (either the final SSE +//! chunk when `stream_options.include_usage` is set, or the `usage` field of +//! a non-streaming [`ChatCompletionResponse`](super::response::ChatCompletionResponse)) +//! → surfaced to the TUI for cost/token display. + +use serde::{Deserialize, Serialize}; + +/// Token counts for a single completion request. +/// +/// Why: these are the standard fields reported by the OpenAI-compatible chat +/// completions API. All fields are required when present — use `Option` at +/// the [`ChatCompletionResponse`](super::response::ChatCompletionResponse) level +/// if usage is absent. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub struct TokenUsage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} diff --git a/crates/zesdex-entities/Cargo.toml b/crates/zesdex-entities/Cargo.toml new file mode 100644 index 0000000..044959a --- /dev/null +++ b/crates/zesdex-entities/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "zesdex-entities" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +uuid.workspace = true +anyhow.workspace = true +dirs.workspace = true +libc.workspace = true +base64.workspace = true +sha2.workspace = true +url.workspace = true +reqwest.workspace = true +tokio.workspace = true +tracing.workspace = true diff --git a/crates/zesdex-entities/src/lib.rs b/crates/zesdex-entities/src/lib.rs new file mode 100644 index 0000000..092d768 --- /dev/null +++ b/crates/zesdex-entities/src/lib.rs @@ -0,0 +1,10 @@ +//! Domain entity types for the Zesdex application. +//! +//! This crate contains ALL domain entity types as pure data structures +//! with no business logic beyond constructor/accessor methods. It uses +//! SeaORM patterns but adapted for serde JSON + filesystem persistence. + +pub mod seaorm; + +pub use seaorm::auth::*; +pub use seaorm::common::*; diff --git a/crates/zesdex-entities/src/seaorm/auth/mod.rs b/crates/zesdex-entities/src/seaorm/auth/mod.rs new file mode 100644 index 0000000..3aaac55 --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/auth/mod.rs @@ -0,0 +1,10 @@ +//! Authentication entities: session metadata, PID-file lock, and OAuth +//! 2.0 PKCE flow types. + +pub mod oauth; +pub mod session; +pub mod session_lock; + +pub use oauth::{OAuthConfig, OAuthManager, OAuthToken}; +pub use session::Session; +pub use session_lock::SessionLock; diff --git a/crates/zesdex-entities/src/seaorm/auth/oauth.rs b/crates/zesdex-entities/src/seaorm/auth/oauth.rs new file mode 100644 index 0000000..116881c --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/auth/oauth.rs @@ -0,0 +1,408 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! OAuth 2.0 authorization-code + PKCE flow: token exchange, authorization +//! URL building, and the PKCE verifier/challenge pair. +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const VERIFIER_LENGTH: usize = 64; + +// --------------------------------------------------------------------------- +// PKCE primitives +// --------------------------------------------------------------------------- + +/// A randomly generated, base64url-encoded PKCE code verifier. +pub struct CodeVerifier(String); + +impl CodeVerifier { + /// Generate a fresh random code verifier. + pub fn new() -> Self { + let bytes: Vec = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect(); + CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes)) + } + + /// Borrow the verifier as a string, to send in the token exchange request. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send + /// in the authorization request. + pub fn challenge(&self) -> CodeChallenge { + let mut hasher = Sha256::new(); + hasher.update(self.0.as_bytes()); + let digest = hasher.finalize(); + CodeChallenge(URL_SAFE_NO_PAD.encode(digest)) + } +} + +impl Default for CodeVerifier { + fn default() -> Self { + Self::new() + } +} + +/// The S256-derived code challenge sent in the authorization request URL. +pub struct CodeChallenge(String); + +impl CodeChallenge { + /// Borrow the challenge as a string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Produce one pseudo-random byte from the system clock mixed with a monotonic +/// counter, providing ~64 bits of per-call unpredictability without a `rand` +/// dependency. +/// +/// Why: avoids pulling in a `rand` dependency for a short-lived verifier; the +/// monotonic counter ensures that calls within the same clock tick produce +/// different values, which is sufficient to prevent OAuth code interception. +fn rand_byte() -> u8 { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + ((seed ^ counter) & 0xFF) as u8 +} + +// --------------------------------------------------------------------------- +// OAuth token / config / manager +// --------------------------------------------------------------------------- + +/// An OAuth access token plus its refresh token and absolute expiry (unix seconds). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthToken { + pub access_token: String, + pub refresh_token: Option, + pub expires_at: u64, + pub token_type: String, +} + +/// Static configuration for an OAuth provider: endpoints, client identity, and +/// requested scopes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthConfig { + pub auth_url: String, + pub token_url: String, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, +} + +impl Default for OAuthConfig { + fn default() -> Self { + OAuthConfig { + auth_url: String::new(), + token_url: String::new(), + client_id: String::new(), + client_secret: None, + scopes: vec![ + "openid".to_string(), + "profile".to_string(), + "email".to_string(), + ], + } + } +} + +/// Drives one OAuth flow: holds config, the current token (if any), and an +/// HTTP client. +pub struct OAuthManager { + pub config: OAuthConfig, + pub token: Option, + client: reqwest::blocking::Client, +} + +impl OAuthManager { + /// Create a manager for the given provider config with no token yet acquired. + pub fn new(config: OAuthConfig) -> Self { + OAuthManager { + config, + token: None, + client: reqwest::blocking::Client::new(), + } + } + + /// Exchange an authorization code for an access token via the provider's + /// token endpoint. + /// + /// Flow: POST form-encoded grant to `token_url` → parse JSON body → + /// compute absolute `expires_at` from `expires_in` → store on `self.token`. + /// + /// Return: `Err(String)` on network failure, non-2xx status, or a missing + /// `access_token` field. + pub fn exchange_code( + &mut self, + code: &str, + redirect_uri: &str, + code_verifier: &str, + ) -> Result<(), String> { + let mut params = std::collections::HashMap::new(); + params.insert("grant_type", "authorization_code"); + params.insert("code", code); + params.insert("redirect_uri", redirect_uri); + params.insert("client_id", &self.config.client_id); + params.insert("code_verifier", code_verifier); + + let resp = self + .client + .post(&self.config.token_url) + .form(¶ms) + .send() + .map_err(|e| format!("token request failed: {e}"))?; + + let status = resp.status(); + let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?; + + if !status.is_success() { + return Err(format!("token endpoint returned {status}: {body}")); + } + + let access_token = body["access_token"] + .as_str() + .ok_or("missing access_token")? + .to_string(); + let expires_in = body["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + self.token = Some(OAuthToken { + access_token, + refresh_token: body["refresh_token"] + .as_str() + .map(std::string::ToString::to_string), + expires_at: now + expires_in, + token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), + }); + + Ok(()) + } + + /// Build the provider's authorization URL with PKCE and state params attached. + /// + /// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously + /// this silently fell back to , which produced a + /// valid-looking auth URL pointing at the wrong server and leaked client + /// credentials in query params. Returning an empty string signals failure + /// to callers, who can prompt the user to fix the OAuth config instead of + /// starting a flow against a wrong host. + /// + /// Return: the full authorization URL, or `""` if `auth_url` is + /// empty/unparseable. + pub fn build_auth_url( + &self, + redirect_uri: &str, + state: &str, + code_challenge: &str, + ) -> String { + let mut url = match url::Url::parse(&self.config.auth_url) { + Ok(u) if !self.config.auth_url.is_empty() => u, + _ => { + tracing::warn!( + "warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url", + self.config.auth_url + ); + return String::new(); + } + }; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &self.config.client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", &self.config.scopes.join(" ")) + .append_pair("state", state) + .append_pair("code_challenge_method", "S256") + .append_pair("code_challenge", code_challenge); + url.to_string() + } + + /// Persist the current token to a JSON file at the given path. + /// + /// Flow: serialise `self.token` to pretty JSON → write to temp file → + /// fsync → rename → fsync parent directory. + pub fn save_token(&self, path: &std::path::Path) -> std::io::Result<()> { + if let Some(token) = &self.token { + let data = serde_json::to_string_pretty(token)?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data)?; + let f = std::fs::File::open(&tmp)?; + f.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(()) + } + + /// Load a token from a JSON file at the given path, replacing the + /// in-memory token. + /// + /// Return: `Ok(())` on success, or an `io::Error` if the file is missing + /// or malformed. + pub fn load_token(&mut self, path: &std::path::Path) -> std::io::Result<()> { + let data = std::fs::read_to_string(path)?; + let token: OAuthToken = serde_json::from_str(&data)?; + self.token = Some(token); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Loopback server for capturing the OAuth authorization-code redirect +// --------------------------------------------------------------------------- + +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 { + /// Bind to an OS-assigned free port on localhost. + /// + /// Return: `Err` if the loopback interface can't be bound. + pub fn bind() -> std::io::Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + Ok(LoopbackServer { listener, port }) + } + + /// The redirect URI to hand to the OAuth authorization endpoint. + pub fn redirect_uri(&self) -> String { + format!("http://127.0.0.1:{}/callback", self.port) + } + + /// Block until one HTTP request arrives, then extract the `code` query + /// param and validate that the `state` param matches the expected value. + /// + /// Flow: accept one connection → apply read timeout → parse request line + /// → verify state matches → respond 200/400 depending on whether the code + /// was found and state matched. + /// + /// Return: `Err(InvalidData)` if no `code` param is present or the state + /// doesn't match `expected_state`. + pub fn wait_for_code( + &self, + timeout_ms: u64, + expected_state: &str, + ) -> std::io::Result { + 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) + } + + /// Read and parse a single HTTP callback request off `stream`, replying + /// with a status page. + /// + /// Why: writes the HTTP response before returning so the browser tab + /// shows a result regardless of whether the code was found. + fn read_callback( + stream: &mut TcpStream, + expected_state: &str, + ) -> std::io::Result { + 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", + ) + }) + } + + /// Extract and percent-decode the `code` query parameter from an HTTP + /// request line. + fn extract_code(request: &str) -> Option { + 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 + } + + /// Extract the `state` query parameter from an HTTP request line. + fn extract_state(request: &str) -> Option { + 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)) => result.push(char::from((hi * 16 + lo) as u8)), + _ => { + result.push('%'); + } + } + } else { + result.push(c); + } + } + result +} diff --git a/src/model/session.rs b/crates/zesdex-entities/src/seaorm/auth/session.rs similarity index 97% rename from src/model/session.rs rename to crates/zesdex-entities/src/seaorm/auth/session.rs index 67f484e..904d7d5 100644 --- a/src/model/session.rs +++ b/crates/zesdex-entities/src/seaorm/auth/session.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Session metadata: id, title, workspace roots, and message/token counts, //! persisted as `session.json` per session directory. use chrono::Utc; diff --git a/src/model/session_lock.rs b/crates/zesdex-entities/src/seaorm/auth/session_lock.rs similarity index 97% rename from src/model/session_lock.rs rename to crates/zesdex-entities/src/seaorm/auth/session_lock.rs index 87173c1..f3610cd 100644 --- a/src/model/session_lock.rs +++ b/crates/zesdex-entities/src/seaorm/auth/session_lock.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; /// A PID-file lock (`/.lock`) tied to the current process, /// auto-removed on drop. +#[derive(Debug)] pub struct SessionLock { path: PathBuf, pid: u32, @@ -114,9 +115,7 @@ impl SessionLock { if target != exe { return false; } - } else { /* cannot resolve own exe, trust kill check */ } - } else { /* /proc unavailable, trust kill check */ } true } diff --git a/src/model/app_config.rs b/crates/zesdex-entities/src/seaorm/common/app_config.rs similarity index 80% rename from src/model/app_config.rs rename to crates/zesdex-entities/src/seaorm/common/app_config.rs index 448cb70..64c5de1 100644 --- a/src/model/app_config.rs +++ b/crates/zesdex-entities/src/seaorm/common/app_config.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Application-level configuration: LLM providers, model roles, and defaults, //! persisted to `app_config.json` in the store directory. use serde::{Deserialize, Serialize}; @@ -76,6 +82,52 @@ impl Default for AppConfig { } } +/// Configuration structure inside `~/.claude/settings.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClaudeEnv { + #[serde(alias = "ANTHROPIC_BASE_URL")] + anthropic_base_url: Option, + #[serde(alias = "ANTHROPIC_API_KEY")] + anthropic_api_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClaudeSettings { + env: Option, +} + +/// Return a `ProviderConfig` for the Claude provider, checking both +/// `~/.claude/settings.json` and the process environment. +fn detect_claude_settings_provider() -> Option { + let (base_url, key) = + claude_credentials_from_file().or_else(claude_credentials_from_env)?; + Some(ProviderConfig { + api_base: base_url, + api_key_env: Some("ANTHROPIC_API_KEY".to_string()), + default_model: None, + default_api_key: Some(key), + }) +} + +/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block. +fn claude_credentials_from_file() -> Option<(String, String)> { + let path = dirs::home_dir()?.join(".claude").join("settings.json"); + let content = std::fs::read_to_string(&path).ok()?; + let settings: ClaudeSettings = serde_json::from_str(&content).ok()?; + let env = settings.env?; + let base_url = env.anthropic_base_url?; + let key = env.anthropic_api_key?; + Some((base_url, key)) +} + +/// Try to read Claude credentials from `ANTHROPIC_BASE_URL` / +/// `ANTHROPIC_API_KEY` environment variables. +fn claude_credentials_from_env() -> Option<(String, String)> { + let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; + let key = std::env::var("ANTHROPIC_API_KEY").ok()?; + Some((base_url, key)) +} + impl AppConfig { /// Load app config from disk, falling back to defaults on any failure. /// @@ -140,84 +192,25 @@ impl AppConfig { } cfg } -} -/// Configuration structure inside `~/.claude/settings.json`. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ClaudeEnv { - #[serde(alias = "ANTHROPIC_BASE_URL")] - anthropic_base_url: Option, - #[serde(alias = "ANTHROPIC_API_KEY")] - anthropic_api_key: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ClaudeSettings { - env: Option, -} - -/// Return a `ProviderConfig` for the Claude provider, checking both -/// `~/.claude/settings.json` and the process environment. -/// -/// Flow: try the file (`env.ANTHROPIC_BASE_URL` + `env.ANTHROPIC_API_KEY`) -/// first → fall back to the `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` env -/// vars → if neither source has both values, return `None`. -/// -/// Why: Claude Code may inject credentials via env vars (OAuth session) rather -/// than through its settings file, so reading only the file misses them. -fn detect_claude_settings_provider() -> Option { - // Prefer the file, then fall back to env vars. - let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?; - Some(ProviderConfig { - api_base: base_url, - // Keep the env-var name so runtime env overrides still work. - api_key_env: Some("ANTHROPIC_API_KEY".to_string()), - default_model: None, - // Store the key read from the file as a direct fallback. - // Without this, subagent/engine.rs resolve_provider_config() falls - // through to std::env::var("ANTHROPIC_API_KEY") which is only - // injected into the Claude Code process — not into zesdex. Workflow - // nodes therefore got an empty key and failed with - // "no API key configured for provider 'claude'", even though the - // main agent succeeded (it has a DEFAULT_API_KEY fallback that - // subagents intentionally do not have). - default_api_key: Some(key), - }) -} - -/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block. -fn claude_credentials_from_file() -> Option<(String, String)> { - let path = dirs::home_dir()?.join(".claude").join("settings.json"); - let content = std::fs::read_to_string(&path).ok()?; - let settings: ClaudeSettings = serde_json::from_str(&content).ok()?; - let env = settings.env?; - let base_url = env.anthropic_base_url?; - let key = env.anthropic_api_key?; - Some((base_url, key)) -} - -/// Try to read Claude credentials from `ANTHROPIC_BASE_URL` / -/// `ANTHROPIC_API_KEY` environment variables. -fn claude_credentials_from_env() -> Option<(String, String)> { - let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; - let key = std::env::var("ANTHROPIC_API_KEY").ok()?; - Some((base_url, key)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn claude_credentials_from_env_resolves_real_env_vars() { - // In the test runner's environment ANTHROPIC_BASE_URL and - // ANTHROPIC_API_KEY may or may not be set — we only verify that - // the function returns Some(..) when both are present. - let Some((b, k)) = claude_credentials_from_env() else { - // Not an error: CI / local without the vars. - return; - }; - assert!(!b.is_empty(), "ANTHROPIC_BASE_URL must not be empty"); - assert!(!k.is_empty(), "ANTHROPIC_API_KEY must not be empty"); + /// Serialize and write app config to `/app_config.json`, + /// using write-then-rename with fsync for crash safety. + /// + /// Flow: ensure base dir exists → pretty-print JSON → write to a temp + /// file → sync to disk → rename over the real path → sync the directory. + /// + /// Return: `Err` if the directory can't be created or the write fails. + pub fn save(&self) -> std::io::Result<()> { + let store = super::store::Store::new(); + std::fs::create_dir_all(&store.base_dir)?; + let path = store.base_dir.join("app_config.json"); + let tmp = store.base_dir.join("app_config.json.tmp"); + let s = serde_json::to_string_pretty(self)?; + std::fs::write(&tmp, s)?; + let f = std::fs::File::open(&tmp)?; + f.sync_all()?; + std::fs::rename(&tmp, path)?; + let _ = std::fs::File::open(&store.base_dir).and_then(|d| d.sync_all()); + Ok(()) } } diff --git a/crates/zesdex-entities/src/seaorm/common/conversation.rs b/crates/zesdex-entities/src/seaorm/common/conversation.rs new file mode 100644 index 0000000..5733dc9 --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/common/conversation.rs @@ -0,0 +1,116 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! In-memory conversation state: message history plus the system prompt and +//! model parameters used to drive the LLM. +use serde::{Deserialize, Serialize}; + +use super::message::{ChatMessage, Role}; + +/// A single conversation's message history and generation settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Conversation { + pub messages: Vec, + pub system_prompt: String, + pub session_id: String, + pub model: String, + pub max_tokens: Option, + pub temperature: Option, +} + +impl Conversation { + /// Create an empty conversation with the given system prompt and + /// session id, using default model/token/temperature settings. + pub fn new(system_prompt: String, session_id: String) -> Self { + Conversation { + messages: Vec::new(), + system_prompt, + session_id, + model: "anthropic/claude-opus-4-8".to_string(), + max_tokens: None, + temperature: None, + } + } + + /// Append a message to the conversation history. + pub fn push(&mut self, msg: ChatMessage) { + self.messages.push(msg); + } + + /// Replace the system prompt and strip any prior `System`-role + /// messages from history. + /// + /// Why: the system prompt is re-injected fresh at request time via + /// `to_api_messages`, so stale `System` messages in `self.messages` + /// would be redundant/conflicting if left in place. + pub fn rebuild_system(&mut self, new_prompt: String) { + self.system_prompt = new_prompt; + self.messages.retain(|m| !matches!(m.role, Role::System)); + } + + /// Build the message list to send to the LLM API, with the system + /// prompt prepended. + /// + /// Return: a new `Vec` (clone of history) with a synthesized system + /// message at index 0. + pub fn to_api_messages(&self) -> Vec { + let mut msgs = Vec::with_capacity(self.messages.len() + 1); + msgs.push(ChatMessage::system(&self.system_prompt)); + msgs.extend(self.messages.iter().cloned()); + msgs + } + + /// Number of messages in the conversation history (excluding the + /// synthesized system message). + pub fn len(&self) -> usize { + self.messages.len() + } + + /// Returns `true` if the conversation has no messages. + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } + + /// Persist the conversation to a JSON file at the given base directory. + /// + /// Flow: compute path from `session_id` → ensure directory exists → + /// serialize to pretty JSON → write-then-rename with fsync. + /// + /// Return: `Ok(())` on success, or an `io::Error` from any step. + pub fn save_conversation(&self, base_dir: &std::path::Path) -> std::io::Result<()> { + let dir = base_dir.join("sessions").join(&self.session_id); + std::fs::create_dir_all(&dir)?; + let path = dir.join("conversation.json"); + let data = serde_json::to_string_pretty(self)?; + let tmp = dir.join("conversation.json.tmp"); + std::fs::write(&tmp, data)?; + let f = std::fs::File::open(&tmp)?; + f.sync_all()?; + std::fs::rename(&tmp, path)?; + let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all()); + Ok(()) + } + + /// Load a conversation from a JSON file for the given session id. + /// + /// Flow: read `/sessions//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 { + let path = base_dir + .join("sessions") + .join(session_id) + .join("conversation.json"); + let data = std::fs::read_to_string(path)?; + let conv: Conversation = serde_json::from_str(&data)?; + Ok(conv) + } +} diff --git a/src/model/editlog.rs b/crates/zesdex-entities/src/seaorm/common/edit_log.rs similarity index 59% rename from src/model/editlog.rs rename to crates/zesdex-entities/src/seaorm/common/edit_log.rs index dd9201f..9e5303f 100644 --- a/src/model/editlog.rs +++ b/crates/zesdex-entities/src/seaorm/common/edit_log.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Append-only JSONL edit log recording every file mutation made by tools, //! for audit and undo/history purposes. use serde::{Deserialize, Serialize}; @@ -51,9 +57,7 @@ impl EditLog { for line in reader.lines() { let Ok(line) = line else { continue }; if let Ok(entry) = serde_json::from_str::(&line) { - // Keep only the most recent entries in memory if entries.len() >= MAX_MEMORY_ENTRIES { - // Drop oldest (front) to make room entries.remove(0); } entries.push(entry); @@ -78,9 +82,6 @@ impl EditLog { pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> { use std::io::Write; let line = serde_json::to_string(&entry)? + "\n"; - // Ensure parent directory exists; fall back to the current - // directory if path has no parent (should not happen in practice - // since EditLog::new always joins to a session dir). if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } @@ -98,66 +99,9 @@ impl EditLog { pub fn len(&self) -> usize { self.entries.len() } -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_editlog_new_empty() { - let dir = std::env::temp_dir().join("editlog_test"); - let _ = std::fs::create_dir_all(&dir); - let log = EditLog::new(&dir); - assert_eq!(log.len(), 0); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_editlog_append_and_reload() { - let dir = std::env::temp_dir().join("editlog_append_test"); - let _ = std::fs::create_dir_all(&dir); - let mut log = EditLog::new(&dir); - let entry = EditLogEntry { - ts: 1, - tool: "write".to_string(), - path: "test.txt".to_string(), - reason: "test reason".to_string(), - content_sha256: "abc123".to_string(), - bytes_delta: 42, - origin: "main".to_string(), - session_id: "sess-1".to_string(), - }; - log.append(entry.clone()).unwrap(); - assert_eq!(log.len(), 1); - assert_eq!(log.entries[0].reason, "test reason"); - assert_eq!(log.entries[0].tool, "write"); - assert_eq!(log.entries[0].path, "test.txt"); - assert_eq!(log.entries[0].bytes_delta, 42); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_editlog_multiple_entries() { - let dir = std::env::temp_dir().join("editlog_multiple_test"); - let _ = std::fs::create_dir_all(&dir); - let mut log = EditLog::new(&dir); - for i in 0..5 { - log.append(EditLogEntry { - ts: i, - tool: "edit".to_string(), - path: format!("file{i}.txt"), - reason: format!("reason {i}"), - content_sha256: "hash".to_string(), - bytes_delta: 10 + i, - origin: "main".to_string(), - session_id: "sess-1".to_string(), - }) - .unwrap(); - } - assert_eq!(log.len(), 5); - assert_eq!(log.entries[0].reason, "reason 0"); - assert_eq!(log.entries[4].reason, "reason 4"); - let _ = std::fs::remove_dir_all(&dir); + /// Returns `true` if the edit log is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() } } diff --git a/src/model/memory.rs b/crates/zesdex-entities/src/seaorm/common/memory.rs similarity index 52% rename from src/model/memory.rs rename to crates/zesdex-entities/src/seaorm/common/memory.rs index 1eb7ae0..404bc2f 100644 --- a/src/model/memory.rs +++ b/crates/zesdex-entities/src/seaorm/common/memory.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Long-term agent memory: markdown files with YAML-ish frontmatter storing //! lessons/references, plus slugified filenames and export/import helpers. use serde::{Deserialize, Serialize}; @@ -102,13 +108,21 @@ impl Memory { }; let content = format!( "---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n{}\n{}\n{}\n{}\n---\n\n{}", - self.name, self.description, self.kind, self.created_at, self.updated_at, - self.lifecycle, outcome_line, scope_line, before_line, after_line, prov_line, + self.name, + self.description, + self.kind, + self.created_at, + self.updated_at, + self.lifecycle, + outcome_line, + scope_line, + before_line, + after_line, + prov_line, self.content ); let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); - // Write to temp file with fsync for crash safety (prevents - // partial writes surviving a power loss). + // Write to temp file with fsync for crash safety { use std::io::Write; let mut f = std::fs::OpenOptions::new() @@ -163,7 +177,10 @@ impl Memory { .lines() .filter_map(|l| { 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(); let body = parts.get(1).unwrap_or(&"").trim().to_string(); @@ -245,9 +262,6 @@ impl Memory { /// Flow: replace any char that isn't alphanumeric, `.`, or `-` with `-` → /// strip leading dots (prevents dotfiles / path traversal via `..`) → /// join to `memory_dir`, falling back to `"memory.md"` if empty. -/// -/// Why: leading-dot stripping specifically blocks accidental hidden -/// files and `..`-style traversal attempts embedded in `raw`. pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { let clean: String = raw .chars() @@ -266,236 +280,3 @@ pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { &clean }) } - -/// Export all memories in `memory_dir` to a single JSON file. -/// -/// Flow: list memory slugs → read+parse each into a `Memory` (skipping -/// any that fail) → serialize the collected `Vec` to pretty JSON -/// → write to `output`. -/// -/// Return: `Ok(())` on success, or an `io::Error` from serialization or -/// the write. -#[cfg(test)] -pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> { - let names = Memory::list(memory_dir); - let lessons: Vec = names - .iter() - .filter_map(|n| Memory::read(memory_dir, n).ok()) - .collect(); - let data = serde_json::to_string_pretty(&lessons).map_err(std::io::Error::other)?; - // Write to temp, fsync, then rename for crash-safe export - let tmp = output.with_extension("json.tmp"); - std::fs::write(&tmp, data)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, output)?; - if let Some(parent) = output.parent() { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); - } - Ok(()) -} -/// Import memories from a JSON export file into `memory_dir`, skipping -/// duplicates. -/// -/// Flow: read+JSON-decode `input` into `Vec` → build a set of -/// existing slugs in `memory_dir` → for each lesson not already present -/// (by slug), write it to disk and count it. -/// -/// Why: slug-based dedup makes repeated imports idempotent — re-running -/// import on the same file won't overwrite or duplicate existing memories. -/// -/// Return: the number of memories actually imported (skips existing ones). -#[cfg(test)] -pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result { - let data = std::fs::read_to_string(input)?; - let lessons: Vec = serde_json::from_str(&data) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - let existing: std::collections::HashSet = - Memory::list(memory_dir).into_iter().collect(); - let mut imported = 0; - for lesson in &lessons { - let slug = Memory::slugify(&lesson.name).unwrap_or_default(); - if !existing.contains(&slug) { - lesson.write(memory_dir)?; - imported += 1; - } - } - Ok(imported) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_slugify_empty() { - assert_eq!(Memory::slugify(""), None); - } - - #[test] - fn test_slugify_basic() { - assert_eq!( - Memory::slugify("Hello World"), - Some("hello-world".to_string()) - ); - } - - #[test] - fn test_slugify_special_chars() { - assert_eq!( - Memory::slugify("Use & Avoid! @#$"), - Some("use-avoid".to_string()) - ); - } - - #[test] - fn test_slugify_too_long() { - let long = "a".repeat(100); - assert_eq!(Memory::slugify(&long), None); - } - - #[test] - fn test_slugify_numeric() { - assert_eq!(Memory::slugify("123"), Some("123".to_string())); - } - - #[test] - fn test_memory_parse_basic() { - let md = "---\nname: test-memory\ndescription: A test memory\nkind: lesson\ncreated_at: 1000\nupdated_at: 2000\n---\n\nThis is the body."; - let mem = Memory::parse(md).unwrap(); - assert_eq!(mem.name, "test-memory"); - assert_eq!(mem.description, "A test memory"); - assert_eq!(mem.kind, "lesson"); - assert_eq!(mem.created_at, 1000); - assert_eq!(mem.updated_at, 2000); - assert_eq!(mem.content, "This is the body."); - } - - #[test] - fn test_memory_parse_with_optional_fields() { - let md = "---\nname: full-memory\ndescription: Full fields\ntype: reference\ncreated_at: 100\nupdated_at: 200\nlifecycle: active\nscope: project\n---\n\nBody content here."; - let mem = Memory::parse(md).unwrap(); - assert_eq!(mem.name, "full-memory"); - assert_eq!(mem.lifecycle, "active"); - assert_eq!(mem.scope, Some("project".to_string())); - assert_eq!(mem.content, "Body content here."); - } - - #[test] - fn test_memory_parse_missing_frontmatter() { - let md = "No frontmatter here"; - assert!(Memory::parse(md).is_err()); - } - - #[test] - fn test_memory_write_and_read() { - let dir = std::env::temp_dir().join("memory_test_write_read"); - let _ = std::fs::create_dir_all(&dir); - let mem = Memory { - name: "my-test".to_string(), - description: "Test".to_string(), - content: "Some content".to_string(), - kind: "reference".to_string(), - created_at: 42, - updated_at: 43, - outcome: None, - lifecycle: "new".to_string(), - scope: None, - before_snippet: None, - after_snippet: None, - provenances: vec![], - }; - mem.write(&dir).unwrap(); - let read = Memory::read(&dir, "my-test").unwrap(); - assert_eq!(read.name, "my-test"); - assert_eq!(read.content, "Some content"); - assert_eq!(read.created_at, 42); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_memory_list() { - let dir = std::env::temp_dir().join("memory_test_list"); - let _ = std::fs::create_dir_all(&dir); - let mem = Memory { - name: "alpha".to_string(), - description: "A".to_string(), - content: "a".to_string(), - kind: "lesson".to_string(), - created_at: 1, - updated_at: 1, - outcome: None, - lifecycle: "new".to_string(), - scope: None, - before_snippet: None, - after_snippet: None, - provenances: vec![], - }; - mem.write(&dir).unwrap(); - let names = Memory::list(&dir); - assert!( - names.contains(&"alpha".to_string()), - "list should contain 'alpha', got: {names:?}" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_memory_remove() { - let dir = std::env::temp_dir().join("memory_test_remove"); - let _ = std::fs::create_dir_all(&dir); - let mem = Memory { - name: "remove-me".to_string(), - description: "R".to_string(), - content: "r".to_string(), - kind: "lesson".to_string(), - created_at: 1, - updated_at: 1, - outcome: None, - lifecycle: "new".to_string(), - scope: None, - before_snippet: None, - after_snippet: None, - provenances: vec![], - }; - mem.write(&dir).unwrap(); - assert!(Memory::read(&dir, "remove-me").is_ok()); - Memory::remove(&dir, "remove-me").unwrap(); - assert!(Memory::read(&dir, "remove-me").is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_export_import_lessons() { - let dir = std::env::temp_dir().join("memory_test_export"); - let _ = std::fs::create_dir_all(&dir); - let mem = Memory { - name: "export-me".to_string(), - description: "Exported".to_string(), - content: "content".to_string(), - kind: "lesson".to_string(), - created_at: 10, - updated_at: 10, - outcome: None, - lifecycle: "active".to_string(), - scope: Some("project".to_string()), - before_snippet: None, - after_snippet: None, - provenances: vec![], - }; - mem.write(&dir).unwrap(); - - let export_path = std::env::temp_dir().join("memory_test_export_lessons.json"); - export_lessons(&dir, &export_path).unwrap(); - assert!(export_path.exists()); - - let dest_dir = std::env::temp_dir().join("memory_test_import_dest"); - let _ = std::fs::create_dir_all(&dest_dir); - let imported = import_lessons(&dest_dir, &export_path).unwrap(); - assert_eq!(imported, 1); - assert!(Memory::read(&dest_dir, "export-me").is_ok()); - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::remove_dir_all(&dest_dir); - let _ = std::fs::remove_file(&export_path); - } -} diff --git a/src/dto/chat/message.rs b/crates/zesdex-entities/src/seaorm/common/message.rs similarity index 80% rename from src/dto/chat/message.rs rename to crates/zesdex-entities/src/seaorm/common/message.rs index e6068ca..1141391 100644 --- a/src/dto/chat/message.rs +++ b/crates/zesdex-entities/src/seaorm/common/message.rs @@ -1,4 +1,10 @@ -//! Chat message types shared across the DTO layer: `Role` and `ChatMessage` +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Chat message types shared across the entity layer: `Role` and `ChatMessage` //! with convenience constructors. use serde::{Deserialize, Serialize}; @@ -40,7 +46,7 @@ pub struct ChatMessage { pub role: Role, pub content: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, + pub tool_calls: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -82,6 +88,17 @@ impl ChatMessage { } /// Build a tool-role result message referencing a prior tool call. + pub fn tool(tool_call_id: String, content: String) -> Self { + ChatMessage { + role: Role::Tool, + content: Some(content), + tool_calls: None, + tool_call_id: Some(tool_call_id), + name: None, + } + } + + /// Alias for `tool`, used throughout the codebase for tool results. pub fn tool_result(tool_call_id: String, content: String) -> Self { ChatMessage { role: Role::Tool, diff --git a/crates/zesdex-entities/src/seaorm/common/mod.rs b/crates/zesdex-entities/src/seaorm/common/mod.rs new file mode 100644 index 0000000..fc69d9d --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/common/mod.rs @@ -0,0 +1,29 @@ +//! Common entity types shared across the Zesdex application: application +//! configuration, settings, store paths, conversations, messages, tool +//! calls, usage stats, and SSE streaming types. + +pub mod app_config; +pub mod conversation; +pub mod edit_log; +pub mod memory; +pub mod message; +pub mod provider; +pub mod settings; +pub mod store; +pub mod tool_call; +pub mod tool_result; +pub mod usage; + +pub use app_config::{AppConfig, ModelRole, ProviderConfig}; +pub use conversation::Conversation; +pub use edit_log::{EditLog, EditLogEntry}; +pub use memory::Memory; +pub use message::{ChatMessage, Role}; +pub use provider::{ + ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef, ToolFunctionDef, +}; +pub use settings::{InternetMode, Settings, SettingsFlags}; +pub use store::Store; +pub use tool_call::{ToolCall, ToolFunction}; +pub use tool_result::ToolCallResult; +pub use usage::UsageStats; diff --git a/crates/zesdex-entities/src/seaorm/common/provider.rs b/crates/zesdex-entities/src/seaorm/common/provider.rs new file mode 100644 index 0000000..06e2c89 --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/common/provider.rs @@ -0,0 +1,316 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Provider-facing DTOs: chat completion request, response, streaming types, +//! and the SSE stream parser. +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Chat request / response +// --------------------------------------------------------------------------- + +/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible +/// provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, +} + +/// Streaming options for the request; `include_usage` asks the provider to +/// emit a final usage chunk in the SSE stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamOptions { + pub include_usage: bool, +} + +/// Wire format for a single tool definition sent to the provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDef { + #[serde(rename = "type")] + pub type_: String, + pub function: ToolFunctionDef, +} + +/// Name, description, and JSON schema parameters for a tool definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFunctionDef { + pub name: String, + pub description: String, + pub parameters: Value, +} + +/// Non-streaming chat completion response returned by the provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatResponse { + pub id: String, + pub model: String, + pub choices: Vec, + pub usage: Option, + pub created: Option, +} + +/// One completion candidate within a `ChatResponse.choices` list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Choice { + pub index: u32, + pub message: super::message::ChatMessage, + pub finish_reason: Option, +} + +/// Token counts and optional cost breakdown for a single completion request. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Usage { + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_tokens_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completion_tokens_cost: Option, +} + +// --------------------------------------------------------------------------- +// SSE streaming +// --------------------------------------------------------------------------- + +/// One atomic event extracted from an LLM streaming response stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum StreamEvent { + Token(String), + Reasoning(String), + ToolCallDelta { + index: usize, + id: Option, + name: Option, + arguments_delta: String, + }, + Usage { + prompt_tokens: u64, + completion_tokens: u64, + total_tokens: u64, + }, + Done, + Error(String), +} + +/// Buffered SSE frame parser that accumulates raw `data:` lines and +/// flushes a `StreamEvent` on each blank-line boundary. +pub struct SseParser { + buffer: String, + event_type: Option, + data_lines: Vec, +} + +impl SseParser { + /// Create a new parser with an empty buffer. + pub fn new() -> Self { + SseParser { + buffer: String::new(), + event_type: None, + data_lines: Vec::new(), + } + } + + /// Feed a raw SSE chunk and produce any completed events. + /// + /// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on + /// blank line, call `flush_event` to parse the accumulated data → + /// on `event:` line, store the event type → on `data:` line, append + /// to data accumulator → continue until buffer exhausted. + /// + /// Edge case: a chunk may split mid-line; the remainder stays in the + /// buffer for the next `feed()` call. + /// + /// Return: all `StreamEvent`s completed by this chunk. + pub fn feed(&mut self, chunk: &str) -> Vec { + self.buffer.push_str(chunk); + let mut events = Vec::new(); + while let Some(line_end) = self.buffer.find('\n') { + let line = self.buffer[..line_end].trim_end_matches('\r').to_string(); + self.buffer = self.buffer[line_end + 1..].to_string(); + if line.is_empty() { + events.extend(self.flush_event()); + } else if let Some(ty) = line.strip_prefix("event: ") { + self.event_type = Some(ty.trim().to_string()); + } else if let Some(data) = line.strip_prefix("data:") { + let data = data.trim_start().to_string(); + self.data_lines.push(data); + } + } + events + } + + /// Flush the current buffered `data:` lines as one or more `StreamEvent`s. + /// + /// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse → + /// emit `Usage` if a usage object is present → else match `event_type` + /// ("message.stop", "message.delta", etc.) → extract content, + /// reasoning, tool-call deltas, or finish-reason from the delta + /// structure (supporting both Anthropic-style top-level delta and + /// OpenAI-style `choices` array). + /// + /// Return: 0, 1, or more `StreamEvent`s from the flushed frame. + fn flush_event(&mut self) -> Vec { + let data = self.data_lines.join("\n"); + self.data_lines.clear(); + let event_type = self.event_type.take().unwrap_or_default(); + if data.is_empty() || data == "[DONE]" { + if data == "[DONE]" { + return vec![StreamEvent::Done]; + } + return vec![]; + } + let value: Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(e) => { + tracing::warn!("[stream] failed to parse chunk: {}", e); + return vec![]; + } + }; + + let mut events = Vec::new(); + + if let Some(usage) = value.get("usage") { + if !usage.is_null() { + let prompt_tokens = usage + .get("prompt_tokens") + .and_then(Value::as_u64) + .unwrap_or_else(|| { + tracing::warn!("[stream] prompt_tokens missing in usage chunk"); + 0 + }); + let completion_tokens = usage + .get("completion_tokens") + .and_then(Value::as_u64) + .unwrap_or_else(|| { + tracing::warn!("[stream] completion_tokens missing in usage chunk"); + 0 + }); + let total_tokens = usage + .get("total_tokens") + .and_then(Value::as_u64) + .unwrap_or_else(|| { + tracing::warn!("[stream] total_tokens missing in usage chunk"); + prompt_tokens + completion_tokens + }); + events.push(StreamEvent::Usage { + prompt_tokens, + completion_tokens, + total_tokens, + }); + } + } + + let mut other_events = match event_type.as_str() { + "message.stop" => vec![StreamEvent::Done], + "message.delta" | "" => { + let mut d_events = Vec::new(); + if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) { + if let Some(choices) = delta.as_array() { + if let Some(choice) = choices.first() { + if let Some(d) = choice.get("delta") { + // Content token + if let Some(content) = + d.get("content").and_then(|c| c.as_str()) + { + d_events.push(StreamEvent::Token(content.to_string())); + } + + // Reasoning token + if let Some(reasoning) = + d.get("reasoning_content").and_then(|r| r.as_str()) + { + d_events.push(StreamEvent::Reasoning( + reasoning.to_string(), + )); + } + + // Tool calls — iterate ALL entries, not just first() + if let Some(tool_calls) = + d.get("tool_calls").and_then(|tc| tc.as_array()) + { + for tc in tool_calls { + let index = tc + .get("index") + .and_then(Value::as_u64) + .unwrap_or_else(|| { + tracing::warn!( + "[stream] tool call delta missing index, \ + defaulting to 0" + ); + 0 + }) as usize; + let id = tc + .get("id") + .and_then(|i| i.as_str()) + .map(std::string::ToString::to_string); + let name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(|n| n.as_str()) + .map(std::string::ToString::to_string); + let args_delta = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); + d_events.push(StreamEvent::ToolCallDelta { + index, + id, + name, + arguments_delta: args_delta, + }); + } + } + + // Finish reason + if let Some(reason) = + choice.get("finish_reason").and_then(|r| r.as_str()) + { + if reason == "stop" || reason == "tool_calls" { + d_events.push(StreamEvent::Done); + } + } + } + } + } else if let Some(content) = + delta.get("content").and_then(|c| c.as_str()) + { + d_events.push(StreamEvent::Token(content.to_string())); + } + } + d_events + } + _ => vec![], + }; + + events.append(&mut other_events); + events + } +} + +impl Default for SseParser { + fn default() -> Self { + Self::new() + } +} diff --git a/src/model/settings.rs b/crates/zesdex-entities/src/seaorm/common/settings.rs similarity index 76% rename from src/model/settings.rs rename to crates/zesdex-entities/src/seaorm/common/settings.rs index 3b13f52..eca88d5 100644 --- a/src/model/settings.rs +++ b/crates/zesdex-entities/src/seaorm/common/settings.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! User-configurable settings persisted as JSON in the store's base directory. //! //! `Settings::load` / `Settings::save` are the only entry points; every field @@ -10,8 +16,7 @@ use serde::{Deserialize, Serialize}; /// /// `Off` disables outbound requests entirely, `ReadOnly` allows fetches but /// no mutating calls, `Full` permits everything. Defaults to `Off`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum InternetMode { #[default] Off, @@ -129,42 +134,3 @@ impl Settings { Ok(()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_hive_mind_node_timeout_is_ten_minutes() { - let settings = Settings::default(); - assert_eq!(settings.hive_mind_node_timeout_ms, 600_000); - } - - #[test] - fn missing_hive_mind_node_timeout_field_falls_back_to_default() { - // Simulates loading a settings.json written before this field - // existed — #[serde(default = ...)] must fill it in rather than - // failing the whole parse (which would silently reset every - // other saved setting to default too). - let old_json = r#"{ - "internet_mode": "Off", - "provider": "zen", - "model": "deepseek-v4-flash-free", - "api_keys": {}, - "max_tokens": null, - "temperature": null, - "review_enabled": true, - "session_archive_enabled": true, - "lsp_auto_provision": true, - "review_max_lessons_per_run": 5, - "adaptive_review_max_skip": 3, - "verify_command": null, - "verify_timeout_ms": 30000, - "workflow_max_concurrency": 5, - "lsp_languages": [] - }"#; - let parsed: Settings = serde_json::from_str(old_json) - .expect("must parse even without the new field present"); - assert_eq!(parsed.hive_mind_node_timeout_ms, 600_000); - } -} diff --git a/src/model/store.rs b/crates/zesdex-entities/src/seaorm/common/store.rs similarity index 87% rename from src/model/store.rs rename to crates/zesdex-entities/src/seaorm/common/store.rs index 3dbc645..c41a9ad 100644 --- a/src/model/store.rs +++ b/crates/zesdex-entities/src/seaorm/common/store.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Filesystem layout for zesdex's persistent and scratch data directories. use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -36,7 +42,8 @@ impl Store { } } - /// Create all store directories (base, memory, scratch, session images, downloads) if missing. + /// Create all store directories (base, memory, scratch, session images, + /// downloads) if missing. /// /// Return: `Err` on the first directory that fails to create. pub fn ensure_dirs(&self) -> std::io::Result<()> { @@ -48,3 +55,9 @@ impl Store { Ok(()) } } + +impl Default for Store { + fn default() -> Self { + Self::new() + } +} diff --git a/src/dto/chat/tool.rs b/crates/zesdex-entities/src/seaorm/common/tool_call.rs similarity index 54% rename from src/dto/chat/tool.rs rename to crates/zesdex-entities/src/seaorm/common/tool_call.rs index 08d2ebb..21886e6 100644 --- a/src/dto/chat/tool.rs +++ b/crates/zesdex-entities/src/seaorm/common/tool_call.rs @@ -1,101 +1,18 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Tool-call DTOs embedded in assistant chat messages. //! //! Flow: provider response/stream carries `tool_calls` on an assistant //! message → deserialized into `ToolCall`/`ToolFunction` → harness resolves //! `function.name` against `all_tools()` and runs it with //! `sanitize_tool_arguments(function.arguments)`. -//! -//! Why: kept separate from `dto::provider` because tool calls are a property -//! of a chat *message*, not of the request/response envelope. use serde::{Deserialize, Serialize}; use serde_json::Value; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn repair_json_closes_string() { - assert_eq!(repair_json("{\"a\": \"bc"), "{\"a\": \"bc\"}"); - } - - #[test] - fn repair_json_closes_brace() { - assert_eq!(repair_json("{\"a\": 1"), "{\"a\": 1}"); - } - - #[test] - fn repair_json_closes_bracket() { - assert_eq!(repair_json("{\"a\": [1, 2"), "{\"a\": [1, 2]}"); - } - - #[test] - fn repair_json_nested() { - assert_eq!( - repair_json("{\"a\": {\"b\": [1, 2"), - "{\"a\": {\"b\": [1, 2]}}" - ); - } - - #[test] - fn repair_json_bracket_then_brace() { - // `[` opened first → `]` must close first, then `}` - assert_eq!(repair_json("[[1, 2, {\"a\": 3"), "[[1, 2, {\"a\": 3}]]"); - } - - #[test] - fn repair_json_handles_escape() { - assert_eq!(repair_json("{\"a\": \"hello\\"), "{\"a\": \"hello\"}"); - } - - #[test] - fn repair_json_handles_escaped_quote() { - assert_eq!( - repair_json("{\"a\": \"he said \\\"hi\\\""), - "{\"a\": \"he said \\\"hi\\\"\"}" - ); - } - - #[test] - fn repair_json_handles_nested_brackets_and_braces() { - assert_eq!( - repair_json("{\"a\": [1, {\"b\": 2"), - "{\"a\": [1, {\"b\": 2}]}" - ); - } - - #[test] - fn repair_json_unchanged_for_valid() { - let v = "{\"a\": 1, \"b\": [2, 3]}"; - assert_eq!(repair_json(v), v); - } - - #[test] - fn sanitize_repairs_truncated_string() { - let args = Value::String("{\"path\": \"a.txt\", \"content\": \"short\"}".to_string()); - let result = sanitize_tool_arguments(&args); - assert!(result.is_object()); - assert_eq!(result.get("path").and_then(|v| v.as_str()), Some("a.txt")); - } - - #[test] - fn sanitize_passes_object_through() { - let args = serde_json::json!({"path": "a.txt"}); - let result = sanitize_tool_arguments(&args); - assert_eq!(result, args); - } - - #[test] - fn sanitize_falls_back_to_raw_on_unrepairable() { - // Completely garbage — not even close to JSON - let args = Value::String("not even close".to_string()); - let result = sanitize_tool_arguments(&args); - assert!(result.is_object()); - assert!(result.get("_raw").is_some()); - assert!(result.get("_parse_error").is_some()); - } -} - /// A single tool-call request emitted by the model in an assistant message. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { @@ -123,19 +40,64 @@ pub struct ToolFunction { /// JSON object (via `args.get("key")`) get `None` rather than unexpectedly /// receiving a plain string value. /// -/// Return: the parsed `Value`, or a wrapper object on parse failure. /// Attempt to fix truncated JSON by closing open strings, braces and brackets. +pub fn sanitize_tool_arguments(args: &Value) -> Value { + match args { + Value::String(s) => { + // Attempt 1: direct parse. + if let Ok(v) = serde_json::from_str::(s) { + return v; + } + // Attempt 2: strip control chars (0x00-0x1F except \t, \n) + let cleaned: String = s + .chars() + .filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r') + .collect(); + if cleaned.len() != s.len() { + if let Ok(v) = serde_json::from_str::(&cleaned) { + tracing::warn!( + "tool argument contained control characters — stripped \ + and reparsed successfully", + ); + return v; + } + } + // Attempt 3: repair truncated JSON and retry. + let input = if cleaned.len() == s.len() { + s + } else { + &cleaned + }; + let repaired = repair_json(input); + match serde_json::from_str::(&repaired) { + Ok(v) => { + tracing::warn!( + "tool argument string was truncated — repaired successfully", + ); + v + } + Err(e2) => { + tracing::error!( + "tool argument is a JSON string but failed to parse. \ + Wrapping in object. Error: {}. Raw (first 200): {}", + e2, + s.chars().take(200).collect::(), + ); + serde_json::json!({"_raw": s, "_parse_error": e2.to_string()}) + } + } + } + obj @ Value::Object(_) => obj.clone(), + other => other.clone(), + } +} + +/// Repair truncated JSON by closing open strings, braces and brackets. /// /// Flow: single-pass character scan tracking string/escape state with a /// LIFO stack for `{`/`[` → append missing `"`, `]`, `}` in the right /// (reverse nesting) order. -/// -/// Why: LLM output can be cut off mid‑JSON (`max_tokens` hit, connection -/// drop). This gives tools a chance to act on whatever was emitted. -/// -/// Why LIFO vs. depth counters: `{` inside `[` must close with `}` before -/// `]`. Simple depth counters get the nesting order wrong. -fn repair_json(s: &str) -> String { +pub fn repair_json(s: &str) -> String { let mut stack: Vec = Vec::new(); let mut in_string = false; let mut prev_was_backslash = false; @@ -185,66 +147,3 @@ fn repair_json(s: &str) -> String { } result } - -/// Normalize tool-call arguments into a JSON object/value. -/// -/// Flow: some providers send `arguments` as a JSON-encoded string rather -/// than a nested object; if `args` is a string, attempt to parse it as -/// JSON. Objects and other value types pass through unchanged. -/// -/// Security: on parse failure we wrap the raw string in `{ "_raw": "..." }` -/// instead of passing it through as a raw string, so tools that expect a -/// JSON object (via `args.get("key")`) get `None` rather than unexpectedly -/// receiving a plain string value. -/// -/// Return: the parsed `Value`, or a wrapper object on parse failure. -pub fn sanitize_tool_arguments(args: &Value) -> Value { - match args { - Value::String(s) => { - // Attempt 1: direct parse. - if let Ok(v) = serde_json::from_str::(s) { - return v; - } - // Attempt 2: strip control chars (0x00-0x1F except \t, \n) - // that some LLM providers emit as literal bytes in JSON strings - // (e.g. multi-line commit messages), then retry. - let cleaned: String = s - .chars() - .filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r') - .collect(); - if cleaned.len() != s.len() { - if let Ok(v) = serde_json::from_str::(&cleaned) { - tracing::warn!( - "tool argument contained control characters — stripped \ - and reparsed successfully", - ); - return v; - } - } - // Attempt 3: repair truncated JSON and retry. - let input = if cleaned.len() == s.len() { - s - } else { - &cleaned - }; - let repaired = repair_json(input); - match serde_json::from_str::(&repaired) { - Ok(v) => { - tracing::warn!("tool argument string was truncated — repaired successfully",); - v - } - Err(e2) => { - tracing::error!( - "tool argument is a JSON string but failed to parse. \ - Wrapping in object. Error: {}. Raw (first 200): {}", - e2, - s.chars().take(200).collect::(), - ); - serde_json::json!({"_raw": s, "_parse_error": e2.to_string()}) - } - } - } - obj @ Value::Object(_) => obj.clone(), - other => other.clone(), - } -} diff --git a/crates/zesdex-entities/src/seaorm/common/tool_result.rs b/crates/zesdex-entities/src/seaorm/common/tool_result.rs new file mode 100644 index 0000000..f668580 --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/common/tool_result.rs @@ -0,0 +1,37 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Record of one completed tool invocation, kept for transcript/history. +use serde::{Deserialize, Serialize}; + +/// Record of a completed tool invocation, kept for transcript/history. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallResult { + pub tool_call_id: String, + pub tool_name: String, + pub output: String, + pub is_error: bool, + pub duration_ms: u64, +} + +impl ToolCallResult { + /// Create a new tool call result. + pub fn new( + tool_call_id: String, + tool_name: String, + output: String, + is_error: bool, + duration_ms: u64, + ) -> Self { + ToolCallResult { + tool_call_id, + tool_name, + output, + is_error, + duration_ms, + } + } +} diff --git a/crates/zesdex-entities/src/seaorm/common/usage.rs b/crates/zesdex-entities/src/seaorm/common/usage.rs new file mode 100644 index 0000000..d7efda9 --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/common/usage.rs @@ -0,0 +1,43 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Token usage accounting shared by streaming and non-streaming responses. +use serde::{Deserialize, Serialize}; + +/// Cumulative token/latency counters for a session, persisted alongside it. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct UsageStats { + pub tokens_in: u64, + pub tokens_out: u64, + #[serde(default)] + pub last_tokens_in: u64, + #[serde(default)] + pub last_tokens_out: u64, + pub api_calls: u64, + pub review_tokens: u64, + pub total_ms: u64, +} + +impl Default for UsageStats { + fn default() -> Self { + Self { + tokens_in: 0, + tokens_out: 0, + last_tokens_in: 0, + last_tokens_out: 0, + api_calls: 0, + review_tokens: 0, + total_ms: 0, + } + } +} + +impl UsageStats { + /// Create a new `UsageStats` with all counters zeroed. + pub fn new() -> Self { + Self::default() + } +} diff --git a/crates/zesdex-entities/src/seaorm/mod.rs b/crates/zesdex-entities/src/seaorm/mod.rs new file mode 100644 index 0000000..962face --- /dev/null +++ b/crates/zesdex-entities/src/seaorm/mod.rs @@ -0,0 +1,9 @@ +//! SeaORM-style entity modules organised by domain concern. +//! +//! Each submodule contains pure data structures with serde serialisation +//! and filesystem persistence (serde JSON + std::fs), adapted for the +//! zesdex runtime which uses rusqlite + serde JSON rather than a full +//! ORM. + +pub mod auth; +pub mod common; diff --git a/crates/zesdex-iam/Cargo.toml b/crates/zesdex-iam/Cargo.toml new file mode 100644 index 0000000..eceb749 --- /dev/null +++ b/crates/zesdex-iam/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "zesdex-iam" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +chrono.workspace = true +uuid.workspace = true +zesdex-entities = { path = "../zesdex-entities" } +zesdex-utils = { path = "../zesdex-utils" } +reqwest.workspace = true +libc.workspace = true +tracing.workspace = true +url.workspace = true +base64.workspace = true +sha2.workspace = true diff --git a/crates/zesdex-iam/src/application/mod.rs b/crates/zesdex-iam/src/application/mod.rs new file mode 100644 index 0000000..e0919fe --- /dev/null +++ b/crates/zesdex-iam/src/application/mod.rs @@ -0,0 +1,2 @@ +pub mod oauth_service; +pub mod session_service; diff --git a/crates/zesdex-iam/src/application/oauth_service.rs b/crates/zesdex-iam/src/application/oauth_service.rs new file mode 100644 index 0000000..b370da8 --- /dev/null +++ b/crates/zesdex-iam/src/application/oauth_service.rs @@ -0,0 +1,199 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! OAuth flow use-cases. +//! +//! `OAuthServiceImpl` drives the authorization-code + PKCE flow: +//! generating the verifier, building the auth URL, exchanging the code +//! for a token, and persisting the result via the injected repository. +use std::path::PathBuf; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use sha2::{Digest, Sha256}; + +use crate::domain::oauth::{OAuthConfig, OAuthToken}; +use crate::domain::repository::OAuthRepository; +use crate::domain::service::OAuthService; + +// --------------------------------------------------------------------------- +// PKCE primitives (private to this use-case module) +// --------------------------------------------------------------------------- + +const VERIFIER_LENGTH: usize = 64; + +/// A randomly generated, base64url-encoded PKCE code verifier. +struct CodeVerifier(String); + +impl CodeVerifier { + fn new() -> Self { + let bytes: Vec = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect(); + CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes)) + } + + fn as_str(&self) -> &str { + &self.0 + } + + /// Derive the S256 code challenge (SHA-256 → base64url). + fn challenge(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.0.as_bytes()); + let digest = hasher.finalize(); + URL_SAFE_NO_PAD.encode(digest) + } +} + +/// Produce one pseudo-random byte from the system clock mixed with a +/// monotonic counter, providing ~64 bits of per-call unpredictability +/// without a full `rand` dependency. +fn rand_byte() -> u8 { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + ((seed ^ counter) & 0xFF) as u8 +} + +// --------------------------------------------------------------------------- +// OAuthServiceImpl +// --------------------------------------------------------------------------- + +/// Concrete OAuth service backed by a generic token repository. +/// +/// The code verifier is stored to a sidecar file (`token_path` with +/// `.verifier` extension) in `start_flow` and consumed in `complete_flow`. +pub struct OAuthServiceImpl { + pub token_repo: R, + pub token_path: PathBuf, +} + +impl OAuthServiceImpl { + /// Create a new OAuth service. + /// + /// * `token_repo` — repository used to persist / load tokens. + /// * `token_path` — file path where the token JSON is stored. + pub fn new(token_repo: R, token_path: PathBuf) -> Self { + OAuthServiceImpl { + token_repo, + token_path, + } + } + + /// Path to the sidecar file that holds the PKCE verifier between + /// `start_flow` and `complete_flow`. + fn verifier_path(&self) -> PathBuf { + let mut p = self.token_path.clone(); + let ext = p + .extension() + .map(|e| format!("{}.verifier", e.to_string_lossy())) + .unwrap_or_else(|| "verifier".to_string()); + p.set_extension(ext); + p + } +} + +impl OAuthService for OAuthServiceImpl { + fn start_flow(&self, config: &OAuthConfig) -> anyhow::Result { + if config.auth_url.is_empty() { + anyhow::bail!("OAuth auth_url is empty"); + } + + let verifier = CodeVerifier::new(); + let challenge = verifier.challenge(); + + // Persist the verifier so complete_flow can retrieve it. + if let Some(parent) = self.token_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(self.verifier_path(), verifier.as_str())?; + + // Persist a random state token for CSRF protection. + let state = uuid::Uuid::new_v4().to_string(); + + let mut url = url::Url::parse(&config.auth_url) + .map_err(|e| anyhow::anyhow!("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", "http://127.0.0.1:0/callback") + .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()) + } + + fn complete_flow(&self, config: &OAuthConfig, code: &str) -> anyhow::Result { + // Load the verifier that was stored during start_flow. + let verifier_path = self.verifier_path(); + let verifier = std::fs::read_to_string(&verifier_path) + .map_err(|e| anyhow::anyhow!("failed to read PKCE verifier: {e}"))?; + + // Exchange the authorization code for a token. + let client = reqwest::blocking::Client::new(); + let mut params = std::collections::HashMap::new(); + params.insert("grant_type", "authorization_code"); + params.insert("code", code); + params.insert("redirect_uri", "http://127.0.0.1:0/callback"); + params.insert("client_id", &config.client_id); + params.insert("code_verifier", &verifier); + + if let Some(ref secret) = config.client_secret { + params.insert("client_secret", secret); + } + + let resp = client + .post(&config.token_url) + .form(¶ms) + .send() + .map_err(|e| anyhow::anyhow!("token request failed: {e}"))?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .map_err(|e| anyhow::anyhow!("failed to parse token response: {e}"))?; + + if !status.is_success() { + anyhow::bail!("token endpoint returned {status}: {body}"); + } + + let access_token = body["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("response missing access_token"))? + .to_string(); + let expires_in = body["expires_in"].as_u64().unwrap_or(3600); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let token = OAuthToken { + access_token, + refresh_token: body["refresh_token"].as_str().map(String::from), + expires_at: now + expires_in, + token_type: body["token_type"] + .as_str() + .unwrap_or("Bearer") + .to_string(), + }; + + // Persist the token and clean up the verifier. + self.token_repo.save_token(&self.token_path, &token)?; + let _ = std::fs::remove_file(&verifier_path); + + Ok(token) + } + + fn get_token(&self) -> anyhow::Result> { + self.token_repo.load_token(&self.token_path) + } +} diff --git a/crates/zesdex-iam/src/application/session_service.rs b/crates/zesdex-iam/src/application/session_service.rs new file mode 100644 index 0000000..44dee1a --- /dev/null +++ b/crates/zesdex-iam/src/application/session_service.rs @@ -0,0 +1,66 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Session management use-cases. +//! +//! `SessionServiceImpl` implements `SessionService` by delegating to +//! injected repository implementations, keeping the orchestration logic +//! independent of any concrete persistence mechanism. +use std::path::PathBuf; + +use uuid::Uuid; + +use crate::domain::repository::{SessionLockRepository, SessionRepository}; +use crate::domain::service::SessionService; +use crate::domain::session::Session; + +/// Concrete session service backed by generic repository implementations. +pub struct SessionServiceImpl { + pub session_repo: R, + pub lock_repo: L, + pub base_dir: PathBuf, +} + +impl SessionServiceImpl { + /// Create a new session service with the given repositories and base + /// data directory. + pub fn new(session_repo: R, lock_repo: L, base_dir: PathBuf) -> Self { + SessionServiceImpl { + session_repo, + lock_repo, + base_dir, + } + } +} + +impl SessionService for SessionServiceImpl { + fn create_session(&self, title: &str) -> anyhow::Result { + let id = Uuid::new_v4().to_string(); + let title_owned = if title.is_empty() { + "New Session".to_string() + } else { + title.to_string() + }; + let session = Session::new(id, title_owned); + self.session_repo.save_session(&self.base_dir, &session)?; + Ok(session) + } + + fn list_all(&self) -> anyhow::Result> { + self.session_repo.list_sessions(&self.base_dir) + } + + fn archive_session(&self, id: &str) -> anyhow::Result<()> { + let mut session = self.session_repo.load_session(&self.base_dir, id)?; + session.archived = true; + session.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + self.session_repo.save_session(&self.base_dir, &session)?; + Ok(()) + } +} diff --git a/crates/zesdex-iam/src/domain/mod.rs b/crates/zesdex-iam/src/domain/mod.rs new file mode 100644 index 0000000..7267b14 --- /dev/null +++ b/crates/zesdex-iam/src/domain/mod.rs @@ -0,0 +1,5 @@ +pub mod oauth; +pub mod repository; +pub mod service; +pub mod session; +pub mod session_lock; diff --git a/crates/zesdex-iam/src/domain/oauth.rs b/crates/zesdex-iam/src/domain/oauth.rs new file mode 100644 index 0000000..a43217f --- /dev/null +++ b/crates/zesdex-iam/src/domain/oauth.rs @@ -0,0 +1,44 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Pure OAuth entities — no HTTP or persistence logic. +use serde::{Deserialize, Serialize}; + +/// An OAuth 2.0 access token with optional refresh token and absolute +/// expiry time (epoch seconds). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthToken { + pub access_token: String, + pub refresh_token: Option, + pub expires_at: u64, + pub token_type: String, +} + +/// Static configuration for an OAuth provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthConfig { + pub auth_url: String, + pub token_url: String, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, +} + +impl Default for OAuthConfig { + fn default() -> Self { + OAuthConfig { + auth_url: String::new(), + token_url: String::new(), + client_id: String::new(), + client_secret: None, + scopes: vec![ + "openid".to_string(), + "profile".to_string(), + "email".to_string(), + ], + } + } +} diff --git a/crates/zesdex-iam/src/domain/repository.rs b/crates/zesdex-iam/src/domain/repository.rs new file mode 100644 index 0000000..60b0562 --- /dev/null +++ b/crates/zesdex-iam/src/domain/repository.rs @@ -0,0 +1,50 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Repository trait definitions (pure — no impls, no concrete persistence). +use std::path::Path; + +use crate::domain::oauth::OAuthToken; +use crate::domain::session::Session; + +/// Repository for loading, saving, listing, and deleting sessions. +pub trait SessionRepository { + /// List all loadable sessions under `/sessions/`. + fn list_sessions(&self, base_dir: &Path) -> anyhow::Result>; + + /// Load a single session by id. + fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result; + + /// Save a session's metadata to disk. + fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()>; + + /// Delete a session directory and all its contents. + fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()>; +} + +/// Repository for per-session PID-file advisory locks. +pub trait SessionLockRepository { + /// Try to acquire the lock for a session directory. + /// Returns `true` if the lock was acquired, `false` if another live + /// process holds it. + fn try_lock(&self, session_dir: &Path) -> anyhow::Result; + + /// Release the lock by removing the lock file. + fn unlock(&self, session_dir: &Path) -> anyhow::Result<()>; + + /// Check whether a process with the given PID is alive. + fn is_alive(&self, pid: u32) -> bool; +} + +/// Repository for persisting and loading OAuth tokens. +pub trait OAuthRepository { + /// Persist an OAuth token to a JSON file. + fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()>; + + /// Load an OAuth token from a JSON file, returning `None` if the file + /// does not exist. + fn load_token(&self, path: &Path) -> anyhow::Result>; +} diff --git a/crates/zesdex-iam/src/domain/service.rs b/crates/zesdex-iam/src/domain/service.rs new file mode 100644 index 0000000..ae9aa9c --- /dev/null +++ b/crates/zesdex-iam/src/domain/service.rs @@ -0,0 +1,36 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Service trait definitions — use-case interfaces for session management +//! and OAuth flows. +use crate::domain::oauth::{OAuthConfig, OAuthToken}; +use crate::domain::session::Session; + +/// Session management use-case boundary. +pub trait SessionService { + /// Create a new session with a generated UUID and the given title. + fn create_session(&self, title: &str) -> anyhow::Result; + + /// List all available sessions. + fn list_all(&self) -> anyhow::Result>; + + /// Archive a session by id (sets `archived = true`). + fn archive_session(&self, id: &str) -> anyhow::Result<()>; +} + +/// OAuth flow use-case boundary. +pub trait OAuthService { + /// Start an OAuth authorization-code + PKCE flow. + /// Returns the provider's authorization URL to visit. + fn start_flow(&self, config: &OAuthConfig) -> anyhow::Result; + + /// Complete the OAuth flow by exchanging an authorization code for a + /// token. + fn complete_flow(&self, config: &OAuthConfig, code: &str) -> anyhow::Result; + + /// Retrieve the currently stored OAuth token (if any). + fn get_token(&self) -> anyhow::Result>; +} diff --git a/crates/zesdex-iam/src/domain/session.rs b/crates/zesdex-iam/src/domain/session.rs new file mode 100644 index 0000000..c1dcf81 --- /dev/null +++ b/crates/zesdex-iam/src/domain/session.rs @@ -0,0 +1,59 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Pure Session entity — no persistence logic. +//! +//! This type represents the metadata of one conversation session. +//! All save / load / list operations belong to the repository traits. +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Metadata for one conversation session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + pub created_at: i64, + pub updated_at: i64, + pub title: String, + pub model: String, + pub workspace_roots: Vec, + pub message_count: u32, + pub token_count: u32, + pub archived: bool, + pub summary: Option, +} + +impl Session { + /// Create a new session with default field values. + pub fn new(id: String, title: String) -> Self { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + 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 `/sessions/`. + 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") + } +} diff --git a/crates/zesdex-iam/src/domain/session_lock.rs b/crates/zesdex-iam/src/domain/session_lock.rs new file mode 100644 index 0000000..d17e8c5 --- /dev/null +++ b/crates/zesdex-iam/src/domain/session_lock.rs @@ -0,0 +1,29 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Pure SessionLock entity — no lock / unlock logic. +//! +//! Lock acquisition and release are handled by the repository. +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// A PID-file based session lock handle. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionLock { + pub path: PathBuf, + pub pid: u32, +} + +impl SessionLock { + /// Construct a lock handle for a session directory (does not acquire + /// the lock yet — use the repository's `try_lock`). + pub fn new(session_dir: &Path) -> Self { + SessionLock { + path: session_dir.join(".lock"), + pid: std::process::id(), + } + } +} diff --git a/crates/zesdex-iam/src/infrastructure/http/dto.rs b/crates/zesdex-iam/src/infrastructure/http/dto.rs new file mode 100644 index 0000000..62a6f2a --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/http/dto.rs @@ -0,0 +1,63 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! IAM-specific HTTP / IPC DTOs (Data Transfer Objects). +use serde::{Deserialize, Serialize}; + +use crate::domain::oauth::{OAuthConfig, OAuthToken}; +use crate::domain::session::Session; + +// --------------------------------------------------------------------------- +// Session DTOs +// --------------------------------------------------------------------------- + +/// Request body for creating a new session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSessionRequest { + pub title: String, +} + +/// Response containing one session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionResponse { + pub session: Session, +} + +/// Response containing a list of sessions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionListResponse { + pub sessions: Vec, + pub total: usize, +} + +// --------------------------------------------------------------------------- +// OAuth DTOs +// --------------------------------------------------------------------------- + +/// Request body for starting an OAuth flow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthStartRequest { + pub config: OAuthConfig, +} + +/// Response containing the authorization URL for an OAuth flow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthStartResponse { + pub auth_url: String, +} + +/// Request body for completing an OAuth flow with an authorization code. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthCompleteRequest { + pub config: OAuthConfig, + pub code: String, +} + +/// Response containing the acquired OAuth token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthTokenResponse { + pub token: OAuthToken, +} diff --git a/crates/zesdex-iam/src/infrastructure/http/handlers.rs b/crates/zesdex-iam/src/infrastructure/http/handlers.rs new file mode 100644 index 0000000..b25bc86 --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/http/handlers.rs @@ -0,0 +1,73 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! IPC / HTTP handler functions. +//! +//! Each handler is a plain function that takes a service reference and a +//! request DTO, delegates to the service, and returns a response DTO. +//! Handlers are generic over the service trait so they remain independent +//! of concrete implementations. + +use crate::domain::service::{OAuthService, SessionService}; +use crate::infrastructure::http::dto::{ + CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse, + OAuthTokenResponse, SessionListResponse, SessionResponse, +}; + +/// Handle a create-session request. +pub fn handle_create_session( + service: &S, + req: CreateSessionRequest, +) -> anyhow::Result { + let session = service.create_session(&req.title)?; + Ok(SessionResponse { session }) +} + +/// Handle a list-sessions request. +pub fn handle_list_sessions( + service: &S, +) -> anyhow::Result { + let sessions = service.list_all()?; + let total = sessions.len(); + Ok(SessionListResponse { sessions, total }) +} + +/// Handle an archive-session request. +pub fn handle_archive_session( + service: &S, + id: &str, +) -> anyhow::Result<()> { + service.archive_session(id)?; + Ok(()) +} + +/// Handle a start-OAuth-flow request. +pub fn handle_start_oauth( + service: &O, + req: OAuthStartRequest, +) -> anyhow::Result { + let auth_url = service.start_flow(&req.config)?; + Ok(OAuthStartResponse { auth_url }) +} + +/// Handle a complete-OAuth-flow request. +pub fn handle_complete_oauth( + service: &O, + req: OAuthCompleteRequest, +) -> anyhow::Result { + let token = service.complete_flow(&req.config, &req.code)?; + Ok(OAuthTokenResponse { token }) +} + +/// Handle a get-token request. +pub fn handle_get_token( + service: &O, +) -> anyhow::Result { + let token = service + .get_token()? + .ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?; + Ok(OAuthTokenResponse { token }) +} diff --git a/crates/zesdex-iam/src/infrastructure/http/mod.rs b/crates/zesdex-iam/src/infrastructure/http/mod.rs new file mode 100644 index 0000000..f075b72 --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/http/mod.rs @@ -0,0 +1,2 @@ +pub mod dto; +pub mod handlers; diff --git a/crates/zesdex-iam/src/infrastructure/mod.rs b/crates/zesdex-iam/src/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/crates/zesdex-iam/src/infrastructure/persistence/mod.rs b/crates/zesdex-iam/src/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..9e331b2 --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/persistence/mod.rs @@ -0,0 +1,2 @@ +pub mod oauth_repo; +pub mod session_repo; diff --git a/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs new file mode 100644 index 0000000..3e4512a --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs @@ -0,0 +1,52 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Filesystem-backed `OAuthRepository` implementation. +//! +//! Tokens are stored as a single JSON file. Writes use a write-then-rename +//! + fsync pattern for crash safety. +use std::path::Path; + +use crate::domain::oauth::OAuthToken; +use crate::domain::repository::OAuthRepository; + +/// Concrete filesystem OAuth token repository. +#[derive(Debug, Clone, Default)] +pub struct FileSystemOAuthRepository; + +impl FileSystemOAuthRepository { + /// Create a new filesystem OAuth repository. + pub fn new() -> Self { + FileSystemOAuthRepository + } +} + +impl OAuthRepository for FileSystemOAuthRepository { + fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(token)?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data)?; + let f = std::fs::File::open(&tmp)?; + f.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(()) + } + + fn load_token(&self, path: &Path) -> anyhow::Result> { + 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)) + } +} diff --git a/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs new file mode 100644 index 0000000..c1524e7 --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs @@ -0,0 +1,92 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Filesystem-backed `SessionRepository` implementation. +//! +//! Each session is stored as `/sessions//session.json`. +//! Writes use a write-then-rename + fsync pattern for crash safety. +use std::path::Path; + +use crate::domain::repository::SessionRepository; +use crate::domain::session::Session; + +/// Concrete filesystem session repository. +#[derive(Debug, Clone, Default)] +pub struct FileSystemSessionRepository; + +impl FileSystemSessionRepository { + /// Create a new filesystem session repository. + pub fn new() -> Self { + FileSystemSessionRepository + } +} + +impl SessionRepository for FileSystemSessionRepository { + fn list_sessions(&self, base_dir: &Path) -> anyhow::Result> { + let sessions_dir = base_dir.join("sessions"); + let Ok(entries) = std::fs::read_dir(&sessions_dir) else { + return Ok(Vec::new()); + }; + let mut sessions = Vec::new(); + for entry in entries.flatten() { + if !entry.path().is_dir() { + continue; + } + let id = entry.file_name().to_string_lossy().to_string(); + if let Ok(session) = self.load_session(base_dir, &id) { + sessions.push(session); + } + } + Ok(sessions) + } + + fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result { + // Directory-traversal prevention. + if id.contains('/') || id.contains('\\') || id.contains("..") { + anyhow::bail!( + "invalid session id '{id}': must not contain path separators" + ); + } + let path = base_dir.join("sessions").join(id).join("session.json"); + if !path.exists() { + anyhow::bail!("session not found: {id}"); + } + let data = std::fs::read_to_string(&path)?; + let session: Session = serde_json::from_str(&data)?; + Ok(session) + } + + fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()> { + let dir = session.session_dir(base_dir); + std::fs::create_dir_all(&dir)?; + let path = dir.join("session.json"); + let data = serde_json::to_string_pretty(session)?; + let tmp = dir.join("session.json.tmp"); + std::fs::write(&tmp, data)?; + // fsync before rename ensures the data is on disk. + let f = std::fs::File::open(&tmp)?; + f.sync_all()?; + std::fs::rename(&tmp, path)?; + // fsync the parent directory so the rename survives a crash. + if let Some(parent) = dir.parent() { + let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); + } + Ok(()) + } + + fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()> { + if id.contains('/') || id.contains('\\') || id.contains("..") { + anyhow::bail!( + "invalid session id '{id}': must not contain path separators" + ); + } + let dir = base_dir.join("sessions").join(id); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + Ok(()) + } +} diff --git a/crates/zesdex-iam/src/lib.rs b/crates/zesdex-iam/src/lib.rs new file mode 100644 index 0000000..6e904aa --- /dev/null +++ b/crates/zesdex-iam/src/lib.rs @@ -0,0 +1,17 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! zesdex-iam — Identity & Access Management crate. +//! +//! Clean Architecture / Domain-Driven Design structure: +//! +//! - **domain** — Pure entities and repository/service trait definitions +//! - **application**— Use-case implementations of the service traits +//! - **infrastructure** — Concrete persistence (filesystem) and HTTP adapter layers + +pub mod domain; +pub mod application; +pub mod infrastructure; diff --git a/crates/zesdex-ipc/Cargo.toml b/crates/zesdex-ipc/Cargo.toml new file mode 100644 index 0000000..3d66ed7 --- /dev/null +++ b/crates/zesdex-ipc/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zesdex-ipc" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true +zesdex-entities = { path = "../zesdex-entities" } +zesdex-dto = { path = "../zesdex-dto" } diff --git a/crates/zesdex-ipc/src/client.rs b/crates/zesdex-ipc/src/client.rs new file mode 100644 index 0000000..0910cb3 --- /dev/null +++ b/crates/zesdex-ipc/src/client.rs @@ -0,0 +1,111 @@ +//! IPC client — connects to the daemon's Unix socket and sends/receives +//! framed JSON messages. +//! +//! [`IpcClient`] wraps a [`Connection`] behind a [`Mutex`] so it can be +//! shared across threads (e.g. the TUI event loop and the render task). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use crate::conn::Connection; +use anyhow::{Context, Result}; +use serde::de::DeserializeOwned; +use serde::Serialize; +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 { + /// Inner connection protected by a mutex for shared access. + conn: Mutex, +} + +impl IpcClient { + /// Connect to the daemon listening at `path` (a Unix socket path). + /// + /// # Errors + /// + /// Returns an error if the socket path does not exist, the connection + /// is refused, or the caller lacks permission. + pub fn connect_unix(path: &str) -> Result { + let stream = UnixStream::connect(path) + .with_context(|| format!("failed to connect to Unix socket at {path:?}"))?; + let conn = Connection::new(stream); + Ok(Self { + conn: Mutex::new(conn), + }) + } + + /// Serialise `msg` to JSON and send it as a length-prefixed frame. + /// + /// # Errors + /// + /// Delegates to the underlying [`Connection::send`]. + pub fn send(&self, msg: &T) -> Result<()> { + let mut guard = self + .conn + .lock() + .expect("IpcClient mutex poisoned — the previous operation panicked"); + guard.send(msg) + } + + /// Read one framed JSON message and deserialise it. + /// + /// Returns `Ok(None)` on clean EOF (daemon closed the connection). + /// + /// # Errors + /// + /// Delegates to the underlying [`Connection::receive`]. + pub fn receive(&self) -> Result> { + let mut guard = self + .conn + .lock() + .expect("IpcClient mutex poisoned — the previous operation panicked"); + guard.receive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use std::os::unix::net::UnixListener; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + struct Ping { + seq: u32, + } + + #[test] + fn connect_and_round_trip() { + let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let sock_path = dir.join("test.sock"); + let sock_path_str = sock_path.to_string_lossy().to_string(); + + // Start a minimal echo server in a background thread. + let listener = UnixListener::bind(&sock_path).unwrap(); + let server_handle = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut conn = Connection::new(stream); + // Echo one message back. + let req: Ping = conn.receive().unwrap().unwrap(); + conn.send(&req).unwrap(); + }); + + // Client connects and sends a ping, then receives the echo. + let client = IpcClient::connect_unix(&sock_path_str).unwrap(); + client.send(&Ping { seq: 7 }).unwrap(); + let resp: Ping = client.receive().unwrap().expect("expected a response"); + assert_eq!(resp, Ping { seq: 7 }); + + server_handle.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/zesdex-ipc/src/conn.rs b/crates/zesdex-ipc/src/conn.rs new file mode 100644 index 0000000..590019d --- /dev/null +++ b/crates/zesdex-ipc/src/conn.rs @@ -0,0 +1,140 @@ +//! Connection wrapper around a Unix socket stream. +//! +//! [`Connection`] pairs a buffered reader with a raw writer and exposes +//! `send` / `receive` for framed JSON messages. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use crate::frame::{read_frame, write_frame}; +use anyhow::{Context, Result}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::io::BufReader; +use std::os::unix::net::UnixStream; + +/// A framed JSON connection over a Unix socket. +/// +/// Wraps the raw [`UnixStream`] with a [`BufReader`] on the read side and +/// direct writes (with explicit flushing) on the write side. +pub struct Connection { + /// Buffered reader for receiving frames. + reader: BufReader, + /// Unbuffered writer (flushed after every frame). + writer: UnixStream, +} + +impl Connection { + /// Create a new `Connection` from an already-connected [`UnixStream`]. + pub fn new(stream: UnixStream) -> Self { + // Clone the stream so that reader and writer can reference separate + // file-descriptor handles. `UnixStream::try_clone` is infallible on + // Unix (it calls `dup(2)`). + let reader = BufReader::new( + stream + .try_clone() + .expect("UnixStream::try_clone should never fail on Linux"), + ); + let writer = stream; + Self { reader, writer } + } + + /// Serialise `msg` to JSON and send it as a length-prefixed frame. + /// + /// # Errors + /// + /// Delegates to [`serde_json::to_vec`] for serialisation and + /// [`write_frame`] for writing. + pub fn send(&mut self, msg: &T) -> Result<()> { + let json = + serde_json::to_vec(msg).context("failed to serialise message to JSON")?; + write_frame(&mut self.writer, &json) + .context("failed to write frame to connection") + } + + /// Read one framed JSON message and deserialise it. + /// + /// Returns `Ok(None)` when the remote end has closed the connection + /// cleanly (EOF). Returns `Ok(Some(msg))` on a successful read. + /// + /// # Errors + /// + /// Delegates to [`read_frame`] for reading and + /// [`serde_json::from_slice`] for deserialisation. + pub fn receive(&mut self) -> Result> { + let raw = read_frame(&mut self.reader) + .context("failed to read frame from connection")?; + + match raw { + None => Ok(None), + Some(bytes) => { + let msg: T = serde_json::from_slice(&bytes) + .with_context(|| { + format!( + "failed to deserialise frame ({} byte(s))", + bytes.len() + ) + })?; + Ok(Some(msg)) + } + } + } +} + +// Safety: `UnixStream` is `Send` but not `Sync`. Wrapping `Connection` in +// a `Mutex` (as done in `IpcClient`) provides the `Sync` guarantee. +// The type itself is `Send` because both fields are `Send`. +// +// We explicitly assert Send here for clarity: +fn _assert_send() +where + Connection: Send, +{ +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + struct Ping { + seq: u32, + } + + /// Helper: create a pair of connected `Connection` values via a + /// Unix socket pair. + fn pair() -> (Connection, Connection) { + let (a, b) = UnixStream::pair().expect("UnixStream::pair failed"); + (Connection::new(a), Connection::new(b)) + } + + #[test] + fn round_trip() { + let (mut left, mut right) = pair(); + + left.send(&Ping { seq: 42 }).unwrap(); + let received: Ping = right.receive().unwrap().expect("expected a frame"); + assert_eq!(received, Ping { seq: 42 }); + } + + #[test] + fn eof_detection() { + let (left, right) = pair(); + drop(right); // close remote end + + // Send something first so we can read past it... actually let's + // just drop the peer and check that receive returns None. + // Since we dropped right, left's reads should eventually get EOF. + // But with a socket pair, dropping one end signals EOF on the other. + drop(left); // drop left too — we'll test EOF on a fresh pair + let (mut a, _b) = pair(); + drop(_b); + let result: Option = a.receive().unwrap(); + assert!(result.is_none()); + } +} diff --git a/crates/zesdex-ipc/src/frame.rs b/crates/zesdex-ipc/src/frame.rs new file mode 100644 index 0000000..af43938 --- /dev/null +++ b/crates/zesdex-ipc/src/frame.rs @@ -0,0 +1,140 @@ +//! 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) ] +//! ``` +//! +//! The length prefix **excludes** itself — it encodes only the number of +//! payload bytes that follow. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use anyhow::{Context, Result}; +use std::io::{Read, Write}; + +/// Maximum frame payload size (64 MiB). +const MAX_PAYLOAD: u32 = 64 * 1024 * 1024; + +/// Read one length-prefixed frame from `reader`. +/// +/// Returns `Ok(None)` when the stream has reached end-of-file (the reader +/// returned `Ok(0)` on the first read). Returns `Ok(Some(...))` with the +/// raw payload bytes for any successfully decoded frame. +/// +/// # Errors +/// +/// - `UnexpectedEof` if the stream terminates partway through a length +/// prefix or payload. +/// - `anyhow` error if the payload length exceeds [`MAX_PAYLOAD`]. +/// - Any I/O error from the underlying reader. +pub fn read_frame(reader: &mut impl Read) -> Result>> { + // --- Read the 4-byte big-endian length prefix --------------------------- + let mut len_buf = [0u8; 4]; + + match reader.read_exact(&mut len_buf) { + Ok(()) => {} + Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + // Zero bytes available → clean EOF. + 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})" + ); + } + + // --- Read the payload --------------------------------------------------- + let mut payload = vec![0u8; payload_len]; + reader + .read_exact(&mut payload) + .with_context(|| format!("failed to read {payload_len} byte(s) of frame payload"))?; + + Ok(Some(payload)) +} + +/// Write one length-prefixed frame to `writer`. +/// +/// Writes the 4-byte big-endian length of `data`, followed by `data` itself. +/// +/// # Errors +/// +/// - Returns an error if `data` is longer than [`MAX_PAYLOAD`]. +/// - Any I/O error from the underlying writer. +pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> Result<()> { + let payload_len: u32 = data + .len() + .try_into() + .context("payload length exceeds u32 range")?; + + 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) + .context("failed to write frame length prefix")?; + writer + .write_all(data) + .context("failed to write frame payload")?; + writer + .flush() + .context("failed to flush frame writer")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_small() { + let payload = b"hello world"; + let mut buf = Vec::new(); + write_frame(&mut buf, payload).unwrap(); + + let mut cursor = std::io::Cursor::new(&buf); + let result = read_frame(&mut cursor).unwrap(); + assert_eq!(result, Some(payload.to_vec())); + } + + #[test] + fn round_trip_empty() { + let payload = b""; + let mut buf = Vec::new(); + write_frame(&mut buf, payload).unwrap(); + + let mut cursor = std::io::Cursor::new(&buf); + let result = read_frame(&mut cursor).unwrap(); + assert_eq!(result, Some(payload.to_vec())); + } + + #[test] + fn eof_returns_none() { + let mut empty: &[u8] = b""; + let result = read_frame(&mut empty).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn oversized_rejected() { + let huge = vec![0u8; (MAX_PAYLOAD as usize) + 1]; + let mut buf = Vec::new(); + assert!(write_frame(&mut buf, &huge).is_err()); + } +} diff --git a/crates/zesdex-ipc/src/lib.rs b/crates/zesdex-ipc/src/lib.rs new file mode 100644 index 0000000..6ba7d7b --- /dev/null +++ b/crates/zesdex-ipc/src/lib.rs @@ -0,0 +1,17 @@ +//! Unix-socket IPC layer for daemon/client communication. +//! +//! This crate provides the wire protocol, framing, and connection +//! wrappers used by both the Zesdex daemon and its TUI client. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod protocol; +pub mod frame; +pub mod conn; +pub mod client; +pub mod server; diff --git a/crates/zesdex-ipc/src/protocol.rs b/crates/zesdex-ipc/src/protocol.rs new file mode 100644 index 0000000..9d23dba --- /dev/null +++ b/crates/zesdex-ipc/src/protocol.rs @@ -0,0 +1,159 @@ +//! Wire types for the Zesdex IPC protocol. +//! +//! All types exchanged between the daemon and the TUI client over the +//! Unix socket are defined here. Both [`ClientRequest`] and +//! [`DaemonFrame`] are serialised as JSON messages framed with a +//! length prefix (see [`crate::frame`]). + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// KeyAction +// --------------------------------------------------------------------------- + +/// A resolved key press sent from the daemon to the client (or used inside +/// the client event loop for deferred dispatch). +#[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), +} + +// --------------------------------------------------------------------------- +// ClientRequest +// --------------------------------------------------------------------------- + +/// A message sent from the TUI client to the daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ClientRequest { + /// Periodic heartbeat / tick event. + Tick, + /// A keyboard event with modifier flags. + KeyPress { + key: KeyAction, + ctrl: bool, + alt: bool, + shift: bool, + }, + /// A completed text submission (e.g. pressing Enter in the input bar). + Submit(String), + /// Pasted text content. + Paste(String), + /// Terminal resize notification. + Resize(u16, u16), + /// Graceful close / shutdown request. + Close, + /// Scroll the session view up one page or line. + ScrollUp, + /// Scroll the session view down one page or line. + ScrollDown, +} + +// --------------------------------------------------------------------------- +// MessageEntry +// --------------------------------------------------------------------------- + +/// A single chat message within a session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageEntry { + /// The role of the message author (e.g. "user", "assistant", "system"). + pub role: String, + /// The text content of the message. + pub content: String, + /// Unix timestamp (seconds since epoch) when the message was created. + pub timestamp: i64, +} + +// --------------------------------------------------------------------------- +// ToastEntry +// --------------------------------------------------------------------------- + +/// A transient toast notification sent to the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToastEntry { + /// The kind / category of the toast (e.g. "info", "error", "success"). + pub kind: String, + /// The display message. + pub message: String, + /// Unix timestamp when the toast was created. + pub created_at: i64, + /// How long (in milliseconds) the toast should remain visible. + pub lifetime_ms: u64, +} + +// --------------------------------------------------------------------------- +// StatePayload +// --------------------------------------------------------------------------- + +/// Full UI state snapshot pushed from the daemon to the client after every +/// mutation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatePayload { + /// Opaque session identifier. + pub session_id: String, + /// Ordered chat messages in the current session. + pub messages: Vec, + /// Monotonically increasing edit counter — used for change detection. + pub edit_count: u32, + /// Cached length of `messages` (redundant but avoids a deserialisation + /// lookup on the client side). + pub message_count: usize, + /// Name of the currently active overlay, if any. + pub overlay: Option, + /// Active toast notifications. + pub toasts: Vec, + /// Whether the session has uncommitted changes. + pub dirty: bool, + /// Current text in the client input buffer (set by the daemon when a + /// session is activated so the client restores cursor state). + pub input_buffer: String, + /// Cursor position within `input_buffer`. + pub input_cursor: usize, +} + +// --------------------------------------------------------------------------- +// DaemonFrame +// --------------------------------------------------------------------------- + +/// A frame sent from the daemon to the client. +/// +/// Every response from the daemon is one of these variants. The client +/// dispatches on the variant to update its UI model. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DaemonFrame { + /// Full state update — the client should replace its entire local state. + StateUpdate(Box), + /// A streaming token for incremental assistant response rendering. + StreamToken(String), + /// A system-level notification that doesn't alter the session state. + SystemNote { + /// The kind of system note (e.g. "info", "warning", "error"). + kind: String, + /// The note content. + message: String, + }, + /// Instructs the client to place `text` into the system clipboard. + ClipboardCopy(String), + /// Signals that the daemon has shut down / the session is complete. + Closed, +} diff --git a/crates/zesdex-ipc/src/server.rs b/crates/zesdex-ipc/src/server.rs new file mode 100644 index 0000000..c352bd8 --- /dev/null +++ b/crates/zesdex-ipc/src/server.rs @@ -0,0 +1,116 @@ +//! IPC server — binds a Unix socket and accepts incoming client +//! connections. +//! +//! [`IpcServer`] wraps a [`UnixListener`] and provides a blocking +//! `accept` method that returns a [`Connection`] for each new client. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use crate::conn::Connection; +use anyhow::{Context, Result}; +use std::os::unix::net::UnixListener; +use std::path::Path; + +/// A Unix-socket IPC server. +/// +/// Each call to [`accept`](Self::accept) blocks until a new client connects +/// and returns a [`Connection`] for that client. +pub struct IpcServer { + listener: UnixListener, +} + +impl IpcServer { + /// Bind a [`UnixListener`] to `path`. + /// + /// If `path` already exists, it is **removed** first so that a stale + /// socket file from a previous run does not prevent binding. + /// + /// # Errors + /// + /// Returns an error if the socket cannot be bound (e.g. insufficient + /// permissions or an unreachable parent directory). + pub fn bind_unix(path: &str) -> Result { + // Remove stale socket file if present. + let p = Path::new(path); + if p.exists() { + std::fs::remove_file(p) + .with_context(|| format!("failed to remove stale socket at {path:?}"))?; + } + + let listener = UnixListener::bind(path) + .with_context(|| format!("failed to bind Unix socket at {path:?}"))?; + + Ok(Self { listener }) + } + + /// Block until a client connects and return a [`Connection`] for the new + /// client. + /// + /// # Errors + /// + /// Returns an error if the underlying `accept` call fails. + pub fn accept(&self) -> Result { + let (stream, addr) = self + .listener + .accept() + .context("failed to accept client connection")?; + + tracing::debug!("accepted client from {addr:?}"); + Ok(Connection::new(stream)) + } +} + +/// `UnixListener` is `Send` but not `Sync`. However, `&self`-based +/// `accept` is fine because the OS-level listen backlog is inherently +/// thread-safe (multiple threads can call `accept` on the same listener). +/// +/// We explicitly assert Send + Sync for clarity: +fn _assert_send_sync() +where + IpcServer: Send + Sync, +{ +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + struct Ping { + seq: u32, + } + + #[test] + fn bind_and_accept_one() { + let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let sock_path = dir.join("server_test.sock"); + let sock_path_str = sock_path.to_string_lossy().to_string(); + + let server = IpcServer::bind_unix(&sock_path_str).unwrap(); + + let server_handle = std::thread::spawn(move || { + let mut conn = server.accept().unwrap(); + let msg: Ping = conn.receive().unwrap().unwrap(); + assert_eq!(msg, Ping { seq: 1 }); + conn.send(&Ping { seq: 2 }).unwrap(); + }); + + // Connect a raw client. + let stream = std::os::unix::net::UnixStream::connect(&sock_path_str).unwrap(); + let mut conn = Connection::new(stream); + conn.send(&Ping { seq: 1 }).unwrap(); + let resp: Ping = conn.receive().unwrap().unwrap(); + assert_eq!(resp, Ping { seq: 2 }); + + server_handle.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/zesdex-libs/Cargo.toml b/crates/zesdex-libs/Cargo.toml new file mode 100644 index 0000000..d1bfd32 --- /dev/null +++ b/crates/zesdex-libs/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "zesdex-libs" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +chrono.workspace = true +uuid.workspace = true +zesdex-entities = { path = "../zesdex-entities" } +zesdex-utils = { path = "../zesdex-utils" } +zesdex-iam = { path = "../zesdex-iam" } +zesdex-cms = { path = "../zesdex-cms" } +zesdex-middleware = { path = "../zesdex-middleware" } +tokio.workspace = true +axum.workspace = true +jsonwebtoken.workspace = true +argon2.workspace = true +rand_core = { version = "0.6", features = ["getrandom"] } +rusqlite.workspace = true +tracing.workspace = true diff --git a/crates/zesdex-libs/src/database.rs b/crates/zesdex-libs/src/database.rs new file mode 100644 index 0000000..a15e9cf --- /dev/null +++ b/crates/zesdex-libs/src/database.rs @@ -0,0 +1,144 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! SQLite database connection pool initialisation and schema migrations. +//! +//! Uses `r2d2` + `r2d2_sqlite` for connection pooling with the same +//! `rusqlite` backend the rest of the project uses, avoiding native +//! library conflicts between `rusqlite` and `sqlx`. + +use anyhow::{Context, Result}; +use std::sync::Arc; +use std::sync::Mutex; + +/// A shared SQLite connection wrapped for thread-safe access. +/// Uses a simple Mutex-guarded connection rather than a full pool, +/// since the daemon is single-threaded for database operations. +#[derive(Clone)] +pub struct DbConn { + conn: Arc>, +} + +impl DbConn { + /// Execute a closure with a reference to the underlying connection. + pub fn with(&self, f: F) -> Result + where + F: FnOnce(&rusqlite::Connection) -> Result, + { + let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?; + f(&conn) + } +} + +/// Embedded SQL schema for all zesdex tables. +/// +/// Uses `CREATE TABLE IF NOT EXISTS` so repeated runs are idempotent. +const SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + workspace_roots TEXT NOT NULL DEFAULT '[]', + message_count INTEGER NOT NULL DEFAULT 0, + token_count INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + summary TEXT +); + +CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS conversations ( + session_id TEXT PRIMARY KEY, + data TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS memories ( + name TEXT PRIMARY KEY, + data TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS edit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + entry TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_edit_logs_session + ON edit_logs (session_id); +"#; + +/// Initialise a shared SQLite connection at the given path. +/// +/// Opens (or creates) the database, enables WAL mode, and returns a +/// thread-safe `DbConn` handle. +/// +/// # Errors +/// +/// Returns an error if the database cannot be opened or created. +pub fn init_db(db_path: &str) -> Result { + let conn = rusqlite::Connection::open(db_path) + .with_context(|| format!("failed to open SQLite database at '{db_path}'"))?; + + conn.execute_batch("PRAGMA journal_mode = WAL;")?; + conn.execute_batch("PRAGMA busy_timeout = 5000;")?; + + tracing::info!("connected to SQLite database at '{db_path}'"); + Ok(DbConn { + conn: Arc::new(Mutex::new(conn)), + }) +} + +/// Run embedded SQL schema migrations. +/// +/// Executes the [`SCHEMA_SQL`] string which creates all tables using +/// `CREATE TABLE IF NOT EXISTS`, making it safe to call on every startup. +/// +/// # Errors +/// +/// Returns an error if any SQL statement fails. +pub fn run_migrations(db: &DbConn) -> Result<()> { + db.with(|conn| { + conn.execute_batch(SCHEMA_SQL) + .context("failed to execute database schema migrations") + })?; + tracing::info!("database schema migrations applied"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_init_db_and_migrate() { + let tmp = std::env::temp_dir().join(format!("zesdex-test-db-{}", uuid::Uuid::new_v4())); + let db_path = tmp.to_str().unwrap().to_string(); + + let db = init_db(&db_path).unwrap(); + run_migrations(&db).unwrap(); + + // Verify sessions table exists + db.with(|conn| { + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + Ok(()) + }) + .unwrap(); + + let _ = std::fs::remove_file(&db_path); + } +} diff --git a/crates/zesdex-libs/src/jwt.rs b/crates/zesdex-libs/src/jwt.rs new file mode 100644 index 0000000..e11d1d5 --- /dev/null +++ b/crates/zesdex-libs/src/jwt.rs @@ -0,0 +1,141 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification. +//! +//! Uses the `jsonwebtoken` crate under the hood. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Standard JWT claims with optional session binding. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtClaims { + /// Subject (usually a user or session identifier). + pub sub: String, + /// Expiration time (UNIX epoch seconds). + pub exp: u64, + /// Issued-at time (UNIX epoch seconds). + pub iat: u64, + /// Optional session id for binding the token to a specific session. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl JwtClaims { + /// Create a new set of claims with the current time as `iat` and the + /// given `exp` offset. + /// + /// * `sub` — subject identifier. + /// * `exp` — absolute expiry as a UNIX timestamp (seconds). + /// * `session_id` — optional session binding. + pub fn new(sub: String, exp: u64, session_id: Option) -> 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. +/// +/// * `secret` — HMAC secret key (at least 32 bytes recommended). +/// * `claims` — the claims to encode and sign. +/// +/// # Errors +/// +/// Returns an error if encoding or signing fails (e.g. malformed secret +/// or serialisation error). +pub fn create_token(secret: &str, claims: JwtClaims) -> Result { + let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); + let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()); + jsonwebtoken::encode(&header, &claims, &key) + .context("failed to encode JWT") +} + +/// Verify a JWT string and return its claims. +/// +/// * `secret` — the same HMAC secret used to sign the token. +/// * `token` — the encoded JWT string. +/// +/// Validation includes signature verification and expiration check. +/// +/// # Errors +/// +/// Returns an error if the token is malformed, expired, or has an invalid +/// signature. +pub fn verify_token(secret: &str, token: &str) -> Result { + 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::(token, &key, &validation) + .context("failed to verify JWT")?; + Ok(token_data.claims) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SECRET: &str = "this-is-a-test-secret-that-is-at-least-32-bytes-long!"; + + #[test] + fn test_create_and_verify_token() { + let claims = JwtClaims::new( + "test-user".to_string(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + 3600, + Some("sess-123".to_string()), + ); + let token = create_token(TEST_SECRET, claims.clone()).unwrap(); + let verified = verify_token(TEST_SECRET, &token).unwrap(); + assert_eq!(verified.sub, claims.sub); + assert_eq!(verified.session_id, claims.session_id); + } + + #[test] + fn test_verify_expired_token_fails() { + let claims = JwtClaims { + sub: "expired-user".to_string(), + exp: 1, // expired long ago + iat: 1, + session_id: None, + }; + let token = create_token(TEST_SECRET, claims).unwrap(); + let result = verify_token(TEST_SECRET, &token); + assert!(result.is_err()); + } + + #[test] + fn test_verify_invalid_signature_fails() { + let claims = JwtClaims::new( + "test-user".to_string(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + 3600, + None, + ); + let token = create_token(TEST_SECRET, claims).unwrap(); + let result = verify_token("wrong-secret", &token); + assert!(result.is_err()); + } +} diff --git a/crates/zesdex-libs/src/lib.rs b/crates/zesdex-libs/src/lib.rs new file mode 100644 index 0000000..87f4f64 --- /dev/null +++ b/crates/zesdex-libs/src/lib.rs @@ -0,0 +1,11 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod database; +pub mod jwt; +pub mod password; +pub mod state; diff --git a/crates/zesdex-libs/src/password.rs b/crates/zesdex-libs/src/password.rs new file mode 100644 index 0000000..8de95ad --- /dev/null +++ b/crates/zesdex-libs/src/password.rs @@ -0,0 +1,86 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Argon2 password hashing and verification utilities. +//! +//! Uses the `argon2` crate (Argon2id variant) with default parameters, +//! which provide a good security / performance trade-off for interactive +//! authentication. + +use anyhow::Result; +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use rand_core::OsRng; + +/// Hash a plaintext password using Argon2id with a random salt. +/// +/// The returned string is in the PHC string format +/// (`$argon2id$v=19$...`) and can be stored directly in the database. +/// +/// # Errors +/// +/// Returns an error if the argon2 library fails (extremely rare — +/// typically indicates an OOM or system-level crypto failure). +pub fn hash_password(password: &str) -> Result { + 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()) +} + +/// Verify a plaintext password against a previously-hashed PHC string. +/// +/// Returns `Ok(true)` if the password matches, `Ok(false)` if it does not, +/// and `Err` if the hash string is malformed. +/// +/// # Errors +/// +/// Returns an error if the hash string is not a valid PHC string or if +/// the argon2 library encounters an internal failure. +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed_hash = + PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?; + let argon2 = Argon2::default(); + Ok(argon2 + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify() { + let password = "my-secure-password-123!"; + let hash = hash_password(password).unwrap(); + assert!(verify_password(password, &hash).unwrap()); + } + + #[test] + fn test_wrong_password_fails() { + let hash = hash_password("correct-password").unwrap(); + assert!(!verify_password("wrong-password", &hash).unwrap()); + } + + #[test] + fn test_hashes_are_different() { + let h1 = hash_password("same-password").unwrap(); + let h2 = hash_password("same-password").unwrap(); + // Different salts → different hashes. + assert_ne!(h1, h2); + } + + #[test] + fn test_invalid_hash_returns_error() { + let result = verify_password("password", "not-a-valid-hash"); + assert!(result.is_err()); + } +} diff --git a/crates/zesdex-libs/src/state.rs b/crates/zesdex-libs/src/state.rs new file mode 100644 index 0000000..d26fad6 --- /dev/null +++ b/crates/zesdex-libs/src/state.rs @@ -0,0 +1,365 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Application state initialisation and wiring. +//! +//! This module acts as the composition root for the zesdex daemon (and +//! any other binary that needs a full set of services). It: +//! +//! 1. Defines [`IamServiceProvider`] and [`CmsServiceProvider`] trait +//! objects so callers depend on interfaces, not generics. +//! 2. Provides default implementations that wire together the +//! infrastructure/repository adapters with the domain service traits. +//! 3. Exposes [`initialize_app_context`] as a one-call entry point. + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use uuid::Uuid; +use zesdex_cms::domain::app_config::ProviderConfig; +use zesdex_cms::domain::conversation::Conversation; +use zesdex_cms::domain::memory::Memory; +use zesdex_cms::domain::settings::Settings; +use zesdex_cms::infrastructure::persistence::{ + JsonAppConfigRepository, JsonConversationRepository, JsonSettingsRepository, + MarkdownMemoryRepository, +}; +use zesdex_entities::seaorm::common::store::Store; +use zesdex_cms::domain::repository::{ + AppConfigRepository, ConversationRepository, MemoryRepository, SettingsRepository, +}; +use zesdex_iam::domain::repository::SessionRepository; +use zesdex_iam::domain::session::Session; +use zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository; + +use crate::database; + +// --------------------------------------------------------------------------- +// Trait definitions +// --------------------------------------------------------------------------- + +/// Session-management service provider. +/// +/// Abstracts session CRUD behind a trait object so the HTTP / CLI layers +/// do not depend on concrete repository generics. +pub trait IamServiceProvider: Send + Sync { + /// Create a new session with a generated UUID and default fields. + fn create_session(&self) -> Result; + + /// List all available sessions. + fn list_all(&self) -> Result>; + + /// Archive a session by id (sets `archived = true`). + fn archive_session(&self, id: &str) -> Result<()>; +} + +/// CMS (content-management) service provider. +/// +/// Combines settings, conversation, and memory operations behind a single +/// trait object. +pub trait CmsServiceProvider: Send + Sync { + // -- Settings -- + /// Load current settings from the default store. + fn load_settings(&self) -> Result; + + /// Persist updated settings. + fn save_settings(&self, settings: &Settings) -> Result<()>; + + /// Update the provider configuration (name and details). + fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()>; + + // -- Conversations -- + /// Load a conversation for the given session id. + fn load_conversation(&self, session_id: &str) -> Result; + + /// Persist a conversation. + fn save_conversation(&self, conv: &Conversation) -> Result<()>; + + // -- Memories -- + /// List all memory slugs. + fn list_memories(&self) -> Result>; + + /// Save (create or update) a memory. + fn save_memory(&self, memory: &Memory) -> Result<()>; + + /// Delete a memory by name. + fn delete_memory(&self, name: &str) -> Result<()>; +} + +// --------------------------------------------------------------------------- +// Default IAM provider +// --------------------------------------------------------------------------- + +/// Default [`IamServiceProvider`] backed by the filesystem session +/// repository. +pub struct DefaultIamServiceProvider { + session_repo: FileSystemSessionRepository, + base_dir: PathBuf, +} + +impl DefaultIamServiceProvider { + /// Create a new provider using the given data directory. + pub fn new(base_dir: PathBuf) -> Self { + Self { + session_repo: FileSystemSessionRepository::new(), + base_dir, + } + } +} + +impl IamServiceProvider for DefaultIamServiceProvider { + fn create_session(&self) -> Result { + let id = Uuid::new_v4().to_string(); + let session = Session::new(id, "New Session".to_string()); + self.session_repo + .save_session(&self.base_dir, &session) + .context("failed to persist new session")?; + Ok(session) + } + + fn list_all(&self) -> Result> { + self.session_repo + .list_sessions(&self.base_dir) + .context("failed to list sessions") + } + + fn archive_session(&self, id: &str) -> Result<()> { + let mut session = self + .session_repo + .load_session(&self.base_dir, id) + .with_context(|| format!("session not found: {id}"))?; + session.archived = true; + session.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + self.session_repo + .save_session(&self.base_dir, &session) + .context("failed to save archived session") + } +} + +// --------------------------------------------------------------------------- +// Default CMS provider +// --------------------------------------------------------------------------- + +/// Default [`CmsServiceProvider`] backed by filesystem repositories. +pub struct DefaultCmsServiceProvider { + settings_repo: JsonSettingsRepository, + app_config_repo: JsonAppConfigRepository, + conversation_repo: JsonConversationRepository, + memory_repo: MarkdownMemoryRepository, + base_dir: PathBuf, + memory_dir: PathBuf, +} + +impl DefaultCmsServiceProvider { + /// Create a new provider. + pub fn new(store: &Store) -> Self { + Self { + settings_repo: JsonSettingsRepository::new(), + app_config_repo: JsonAppConfigRepository::new(), + conversation_repo: JsonConversationRepository::new(), + memory_repo: MarkdownMemoryRepository::new(), + base_dir: store.base_dir.clone(), + memory_dir: store.memory_dir.clone(), + } + } + + /// Compute the session directory for a given session id. + fn session_dir(&self, session_id: &str) -> PathBuf { + self.base_dir.join("sessions").join(session_id) + } +} + +impl CmsServiceProvider for DefaultCmsServiceProvider { + // -- Settings -- + fn load_settings(&self) -> Result { + self.settings_repo + .load(&self.base_dir) + .context("failed to load settings") + } + + fn save_settings(&self, settings: &Settings) -> Result<()> { + self.settings_repo + .save(&self.base_dir, settings) + .context("failed to save settings") + } + + fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> { + let mut app_config = self + .app_config_repo + .load(&self.base_dir) + .context("failed to load app config")?; + app_config + .providers + .insert(name.to_string(), config.clone()); + self.app_config_repo + .save(&self.base_dir, &app_config) + .context("failed to save app config after provider update") + } + + // -- Conversations -- + fn load_conversation(&self, session_id: &str) -> Result { + let dir = self.session_dir(session_id); + self.conversation_repo + .load(&dir) + .with_context(|| format!("failed to load conversation for session '{session_id}'")) + } + + fn save_conversation(&self, conv: &Conversation) -> Result<()> { + let dir = self.session_dir(&conv.session_id); + self.conversation_repo + .save(&dir, conv) + .with_context(|| { + format!( + "failed to save conversation for session '{}'", + conv.session_id + ) + }) + } + + // -- Memories -- + fn list_memories(&self) -> Result> { + self.memory_repo + .list(&self.memory_dir) + .context("failed to list memories") + } + + fn save_memory(&self, memory: &Memory) -> Result<()> { + self.memory_repo + .save(&self.memory_dir, memory) + .with_context(|| format!("failed to save memory '{}'", memory.name)) + } + + fn delete_memory(&self, name: &str) -> Result<()> { + self.memory_repo + .delete(&self.memory_dir, name) + .with_context(|| format!("failed to delete memory '{name}'")) + } +} + +// --------------------------------------------------------------------------- +// AppContext +// --------------------------------------------------------------------------- + +/// Aggregated shared state for the zesdex daemon (or any binary using the +/// full service stack). +pub struct AppContext { + /// Filesystem store (resolved paths for all data directories). + pub store: Store, + /// IAM service provider (session management). + pub iam_service: Box, + /// CMS service provider (settings, conversations, memories). + pub cms_service: Box, + /// SQLite database connection. + pub db: database::DbConn, + /// JWT HMAC secret used to sign / verify tokens. + pub jwt_secret: String, +} + +impl AppContext { + /// Return a shared `Arc` for use with Axum's + /// `axum::extract::State`. + pub fn into_arc(self) -> Arc { + Arc::new(self) + } +} + +// --------------------------------------------------------------------------- +// Initialisation +// --------------------------------------------------------------------------- + +/// Wire together the full application stack and return an [`AppContext`]. +/// +/// Steps: +/// 1. Initialise [`Store`] and create all data directories. +/// 2. Connect to the SQLite database and run migrations. +/// 3. Instantiate the IAM and CMS service providers. +/// 4. Determine the JWT secret (env var `ZESDEX_JWT_SECRET` or a default). +/// +/// # Errors +/// +/// Returns an error if any step fails (directory creation, DB connection, +/// migration execution, etc.). +pub fn initialize_app_context() -> Result { + // -- Store -- + let store = Store::new(); + store + .ensure_dirs() + .context("failed to create store directories")?; + + // -- Database -- + let db_path = store.base_dir.join("zesdex.db"); + let db_path_str = db_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("invalid db path: {}", db_path.display()))?; + + let db = database::init_db(db_path_str) + .context("failed to initialise database")?; + + database::run_migrations(&db) + .context("failed to run database migrations")?; + + // -- Services -- + let iam_service: Box = + Box::new(DefaultIamServiceProvider::new(store.base_dir.clone())); + let cms_service: Box = + Box::new(DefaultCmsServiceProvider::new(&store)); + + // -- JWT secret -- + let jwt_secret = std::env::var("ZESDEX_JWT_SECRET") + .unwrap_or_else(|_| "zesdex-dev-secret-do-not-use-in-production".to_string()); + + Ok(AppContext { + store, + iam_service, + cms_service, + db, + jwt_secret, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_iam_provider_list_all_empty() { + let tmp = std::env::temp_dir().join(format!("zesdex-test-iam-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + let provider = DefaultIamServiceProvider::new(tmp.clone()); + let sessions = provider.list_all().unwrap(); + assert!(sessions.is_empty()); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_default_iam_provider_create_and_list() { + let tmp = std::env::temp_dir().join(format!("zesdex-test-iam-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + let provider = DefaultIamServiceProvider::new(tmp.clone()); + let session = provider.create_session().unwrap(); + assert!(!session.id.is_empty()); + let sessions = provider.list_all().unwrap(); + assert_eq!(sessions.len(), 1); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_default_cms_provider_default_settings() { + let tmp = std::env::temp_dir().join(format!("zesdex-test-cms-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + let mut store = Store::new(); + store.base_dir = tmp.clone(); + store.memory_dir = tmp.join("memory"); + let provider = DefaultCmsServiceProvider::new(&store); + let settings = provider.load_settings().unwrap(); + assert_eq!(settings.provider, "zen"); + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/crates/zesdex-middleware/Cargo.toml b/crates/zesdex-middleware/Cargo.toml new file mode 100644 index 0000000..05e5e11 --- /dev/null +++ b/crates/zesdex-middleware/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zesdex-middleware" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +chrono.workspace = true +zesdex-entities = { path = "../zesdex-entities" } +zesdex-utils = { path = "../zesdex-utils" } +axum.workspace = true +tower.workspace = true +tower-http = { workspace = true, features = ["cors", "limit"] } diff --git a/crates/zesdex-middleware/src/auth.rs b/crates/zesdex-middleware/src/auth.rs new file mode 100644 index 0000000..13a4c2a --- /dev/null +++ b/crates/zesdex-middleware/src/auth.rs @@ -0,0 +1,294 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Authentication middleware — session-lock based auth for Axum. +//! +//! Provides: +//! - [`SessionAuthLayer`]: a tower [`Layer`] that injects session validation +//! - [`SessionIdentity`]: extracted from validated requests +//! - [`validate_session`]: low-level session existence/validity check + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::extract::FromRequestParts; +use axum::http::header; +use axum::http::request::Parts; +use axum::http::{Request, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use tower::{Layer, Service}; +use zesdex_entities::seaorm::common::store::Store; + +// --------------------------------------------------------------------------- +// SessionIdentity +// --------------------------------------------------------------------------- + +/// Identity extracted from a validated session token / lock. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionIdentity { + /// The validated session id (from `X-Session-Id`). + pub session_id: String, + /// User-Agent header value, if present. + pub user_agent: String, + /// Unix-epoch timestamp (seconds) when the session was first seen by + /// this middleware. + pub connected_at: i64, +} + +impl SessionIdentity { + /// Create a new identity from a validated session id. + fn new(session_id: String, user_agent: String) -> Self { + let connected_at = chrono::Utc::now().timestamp(); + Self { + session_id, + user_agent, + connected_at, + } + } +} + +/// Extractor: pull the identity from request extensions. +/// +/// If the identity has not been inserted by the middleware the request is +/// rejected with 401 Unauthorized. +impl FromRequestParts for SessionIdentity { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "session identity not found").into_response()) + } +} + +// --------------------------------------------------------------------------- +// SessionAuthLayer +// --------------------------------------------------------------------------- + +/// Tower [`Layer`] that produces [`SessionAuthMiddleware`] services. +/// +/// Wraps every request with session validation: if the `X-Session-Id` +/// header points to a valid session, the request passes through and a +/// [`SessionIdentity`] is injected into the request extensions. Otherwise +/// a 401 response is returned immediately. +#[derive(Debug, Clone)] +pub struct SessionAuthLayer { + store: Arc, +} + +impl SessionAuthLayer { + /// Create a new layer backed by the given [`Store`]. + pub fn new(store: Store) -> Self { + Self { + store: Arc::new(store), + } + } + + /// Convenience constructor using `Store::new()`. + pub fn default() -> Self { + Self::new(Store::new()) + } +} + +impl Layer for SessionAuthLayer { + type Service = SessionAuthMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + SessionAuthMiddleware { + inner, + store: Arc::clone(&self.store), + } + } +} + +// --------------------------------------------------------------------------- +// SessionAuthMiddleware +// --------------------------------------------------------------------------- + +/// Tower [`Service`] that validates `X-Session-Id` before forwarding. +#[derive(Debug, Clone)] +pub struct SessionAuthMiddleware { + inner: S, + store: Arc, +} + +impl Service> for SessionAuthMiddleware +where + S: Service, Response = Response> + Send + 'static, + S::Future: Send + 'static, + ReqBody: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = + Pin> + Send + 'static>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + let store = Arc::clone(&self.store); + + // Extract session id from header. + let session_id = req + .headers() + .get("X-Session-Id") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let session_id = match session_id { + Some(id) if !id.is_empty() => id, + _ => { + let resp = (StatusCode::UNAUTHORIZED, "missing X-Session-Id header") + .into_response(); + return Box::pin(async move { Ok(resp) }); + } + }; + + // Validate session. + if let Err(e) = validate_session(&session_id, &store) { + let resp = ( + StatusCode::UNAUTHORIZED, + format!("session validation failed: {e}"), + ) + .into_response(); + return Box::pin(async move { Ok(resp) }); + } + let user_agent = req + .headers() + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let identity = SessionIdentity::new(session_id, user_agent); + req.extensions_mut().insert(identity); + + let fut = self.inner.call(req); + Box::pin(async move { fut.await }) + } +} + +// --------------------------------------------------------------------------- +// Helper: `require_session` (convenience middleware function) +// --------------------------------------------------------------------------- + +/// Axum middleware function that validates `X-Session-Id` against the +/// [`Store`] extracted from request extensions. +/// +/// This is an alternative to [`SessionAuthLayer`] when you want to attach +/// auth to a specific route group via `axum::middleware::from_fn_with_state`. +pub async fn require_session( + store: axum::extract::State, + mut req: Request, + next: axum::middleware::Next, +) -> Response { + let session_id = req + .headers() + .get("X-Session-Id") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let session_id = match session_id { + Some(id) if !id.is_empty() => id, + _ => { + return (StatusCode::UNAUTHORIZED, "missing X-Session-Id header").into_response(); + } + }; + + if let Err(e) = validate_session(&session_id, &store) { + return (StatusCode::UNAUTHORIZED, format!("session validation failed: {e}")).into_response(); + } + let user_agent = req + .headers() + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let identity = SessionIdentity::new(session_id, user_agent); + req.extensions_mut().insert(identity); + next.run(req).await +} + +// --------------------------------------------------------------------------- +// Session validation +// --------------------------------------------------------------------------- + +/// Check whether a session lock exists and is valid, returning the +/// associated [`SessionIdentity`]. +/// +/// Validation logic: +/// 1. Verify the session id is not a path-traversal attack. +/// 2. Check that `/sessions//session.json` exists. +/// 3. Deserialise the session metadata to confirm it is well-formed. +/// +/// This is a synchronous, CPU-light check so it can be called directly +/// inside tower service impls without spawning a blocking task. +pub fn validate_session(session_id: &str, store: &Store) -> anyhow::Result { + // Directory-traversal prevention. + if session_id.contains('/') || session_id.contains('\\') || session_id.contains("..") { + anyhow::bail!("invalid session id: must not contain path separators"); + } + + let session_path = store + .base_dir + .join("sessions") + .join(session_id) + .join("session.json"); + + if !session_path.exists() { + anyhow::bail!("session not found: {session_id}"); + } + + let _data = std::fs::read_to_string(&session_path)?; + // We verify the JSON is well-formed by deserialising it. + let _session: serde_json::Value = serde_json::from_str(&_data)?; + + let user_agent = String::new(); + Ok(SessionIdentity::new(session_id.to_string(), user_agent)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_session_rejects_path_traversal() { + let store = Store::new(); + assert!(validate_session("../etc/passwd", &store).is_err()); + assert!(validate_session("foo/bar", &store).is_err()); + assert!(validate_session("foo\\bar", &store).is_err()); + assert!(validate_session("..", &store).is_err()); + } + + #[test] + fn test_validate_session_nonexistent() { + let store = Store::new(); + let result = validate_session("nonexistent-session-id", &store); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("session not found")); + } + + #[test] + fn test_session_identity_creation() { + let identity = SessionIdentity::new("sess-123".into(), "test-agent".into()); + assert_eq!(identity.session_id, "sess-123"); + assert_eq!(identity.user_agent, "test-agent"); + assert!(identity.connected_at > 0); + } +} diff --git a/crates/zesdex-middleware/src/cors.rs b/crates/zesdex-middleware/src/cors.rs new file mode 100644 index 0000000..7ccf0d1 --- /dev/null +++ b/crates/zesdex-middleware/src/cors.rs @@ -0,0 +1,48 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! CORS layer factory for the daemon HTTP (IPC) server. +//! +//! Since the daemon only listens on `127.0.0.1`, the CORS policy is +//! intentionally permissive. These settings are still required because +//! Axum rejects cross-origin requests unless a CORS layer is present. + +use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; + +/// Return a permissive [`CorsLayer`] for local daemon IPC. +/// +/// - **Origin**: any (`*`) +/// - **Methods**: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS` +/// - **Headers**: `Content-Type`, `Authorization`, `X-Session-Id`, +/// `X-Request-Id`, `User-Agent` +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(), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_cors_layer_constructs() { + let _layer = default_cors_layer(); + } +} diff --git a/crates/zesdex-middleware/src/lib.rs b/crates/zesdex-middleware/src/lib.rs new file mode 100644 index 0000000..c207c58 --- /dev/null +++ b/crates/zesdex-middleware/src/lib.rs @@ -0,0 +1,10 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +pub mod auth; +pub mod cors; +pub mod rate_limit; diff --git a/crates/zesdex-middleware/src/rate_limit.rs b/crates/zesdex-middleware/src/rate_limit.rs new file mode 100644 index 0000000..3228376 --- /dev/null +++ b/crates/zesdex-middleware/src/rate_limit.rs @@ -0,0 +1,268 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +//! Simple in-memory rate limiter for Axum. +//! +//! Uses a sliding-window approach: each client has a rolling list of +//! timestamps. Requests arriving within the window that exceed the +//! configured max are rejected. + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Mutex; +use std::task::{Context, Poll}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::http::{Request, StatusCode}; +use axum::response::{IntoResponse, Response}; +use tower::{Layer, Service}; + +/// In-memory sliding-window rate limiter. +/// +/// Thread-safe via interior mutability (`Mutex`). Each client (identified +/// by a string key, e.g. IP address or session id) has a Vec of entry +/// timestamps (in seconds). Old entries are cleaned on every check. +#[derive(Debug)] +pub struct RateLimiter { + windows: Mutex>>, +} + +impl RateLimiter { + /// Create an empty rate limiter. + pub fn new() -> Self { + Self { + windows: Mutex::new(HashMap::new()), + } + } + + /// Check whether a request from `client_id` should be allowed. + /// + /// * `max_requests` — max number of requests permitted within the + /// window. + /// * `window_secs` — width of the sliding window in seconds. + /// + /// Returns `Ok(true)` if the request is allowed (and records it), + /// or `Ok(false)` if the client has exceeded the limit. + /// + /// The window is **sliding**: only timestamps falling within + /// `[now - window_secs, now]` are counted. + pub fn check_rate_limit( + &self, + client_id: &str, + max_requests: u32, + window_secs: u64, + ) -> anyhow::Result { + let now = SystemTime::now() + .duration_since(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); + + // Discard entries older than the window. + timestamps.retain(|&ts| ts >= cutoff); + + if timestamps.len() >= max_requests as usize { + return Ok(false); + } + + timestamps.push(now); + Ok(true) + } + + /// Convenience wrapper that returns an Axum [`Response`] on rejection + /// so it can be used directly in middleware. + pub fn check_or_429( + &self, + client_id: &str, + max_requests: u32, + window_secs: u64, + ) -> Result<(), Response> { + match self.check_rate_limit(client_id, max_requests, window_secs) { + Ok(true) => Ok(()), + Ok(false) => Err(( + StatusCode::TOO_MANY_REQUESTS, + "rate limit exceeded, try again later", + ) + .into_response()), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()), + } + } + + /// Remove all stored windows (for testing / reset). + 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() + } +} + +// --------------------------------------------------------------------------- +// Tower Layer / Service +// --------------------------------------------------------------------------- + +/// Configuration for the rate-limit middleware layer. +#[derive(Debug, Clone)] +pub struct RateLimitLayer { + limiter: std::sync::Arc, + max_requests: u32, + window_secs: u64, +} + +impl RateLimitLayer { + /// Create a new layer with the given limits. + /// + /// * `max_requests` — max requests per window per client. + /// * `window_secs` — sliding-window width in seconds. + pub fn new(max_requests: u32, window_secs: u64) -> Self { + Self { + limiter: std::sync::Arc::new(RateLimiter::new()), + max_requests, + window_secs, + } + } + + /// Return a reference to the shared [`RateLimiter`] so callers can + /// reset it or perform manual checks. + pub fn limiter(&self) -> &std::sync::Arc { + &self.limiter + } +} + +impl Layer for RateLimitLayer { + type Service = RateLimitMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + RateLimitMiddleware { + inner, + limiter: std::sync::Arc::clone(&self.limiter), + max_requests: self.max_requests, + window_secs: self.window_secs, + } + } +} + +/// Tower [`Service`] wrapping each request with a rate-limit check. +/// +/// Client identity is extracted from the `X-Forwarded-For` header first, +/// falling back to the remote address, then to `"unknown"`. +#[derive(Debug, Clone)] +pub struct RateLimitMiddleware { + inner: S, + limiter: std::sync::Arc, + max_requests: u32, + window_secs: u64, +} + +impl Service> for RateLimitMiddleware +where + S: Service, Response = Response> + Send + 'static, + S::Future: Send + 'static, + ReqBody: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = + Pin> + Send + 'static>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let client_id = req + .headers() + .get("X-Forwarded-For") + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_string()) + .or_else(|| { + req.headers() + .get("X-Real-IP") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }) + .or_else(|| { + req.extensions() + .get::>() + .map(|ci| ci.0.ip().to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let limiter = std::sync::Arc::clone(&self.limiter); + let max_requests = self.max_requests; + let window_secs = self.window_secs; + + match limiter.check_or_429(&client_id, max_requests, window_secs) { + Ok(()) => {} + Err(resp) => { + return Box::pin(async move { Ok(resp) }); + } + } + + let fut = self.inner.call(req); + Box::pin(async move { fut.await }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rate_limiter_allows_within_limit() { + let limiter = RateLimiter::new(); + assert!(limiter.check_rate_limit("client-1", 5, 60).unwrap()); + assert!(limiter.check_rate_limit("client-1", 5, 60).unwrap()); + assert!(limiter.check_rate_limit("client-1", 5, 60).unwrap()); + } + + #[test] + fn test_rate_limiter_rejects_excess() { + let limiter = RateLimiter::new(); + assert!(limiter.check_rate_limit("client-2", 3, 60).unwrap()); + assert!(limiter.check_rate_limit("client-2", 3, 60).unwrap()); + assert!(limiter.check_rate_limit("client-2", 3, 60).unwrap()); + assert!(!limiter.check_rate_limit("client-2", 3, 60).unwrap()); + } + + #[test] + fn test_rate_limiter_independent_clients() { + let limiter = RateLimiter::new(); + assert!(limiter.check_rate_limit("alice", 2, 60).unwrap()); + assert!(limiter.check_rate_limit("alice", 2, 60).unwrap()); + assert!(!limiter.check_rate_limit("alice", 2, 60).unwrap()); + assert!(limiter.check_rate_limit("bob", 2, 60).unwrap()); + } + + #[test] + fn test_rate_limiter_reset() { + let limiter = RateLimiter::new(); + assert!(limiter.check_rate_limit("client-3", 1, 60).unwrap()); + assert!(!limiter.check_rate_limit("client-3", 1, 60).unwrap()); + limiter.reset().unwrap(); + assert!(limiter.check_rate_limit("client-3", 1, 60).unwrap()); + } +} diff --git a/crates/zesdex-utils/Cargo.toml b/crates/zesdex-utils/Cargo.toml new file mode 100644 index 0000000..1864f1f --- /dev/null +++ b/crates/zesdex-utils/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "zesdex-utils" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +chrono = { workspace = true } +base64 = { workspace = true } +thiserror = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +dirs = { workspace = true } diff --git a/crates/zesdex-utils/src/clipboard.rs b/crates/zesdex-utils/src/clipboard.rs new file mode 100644 index 0000000..93a090e --- /dev/null +++ b/crates/zesdex-utils/src/clipboard.rs @@ -0,0 +1,65 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::io::{self, Write}; + +/// Write `text` to the terminal's clipboard using the OSC-52 escape sequence. +/// +/// OSC-52 (`\x1b]52;c;\x1b\\`) is supported by many terminal emulators +/// (iTerm2, Kitty, tmux, etc.) and allows writing to the system clipboard +/// without external binaries. +/// +/// The `output` parameter should be a writable handle to the terminal (e.g. +/// `io::stdout()` or `io::stderr()`). +pub fn write_osc52(output: &mut impl Write, text: &str) -> io::Result<()> { + use base64::Engine as _; + + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + + // OSC-52: ESC ] 52 ; c ; ST + // Where c = "c" for clipboard, ST = ESC \ + write!(output, "\x1b]52;c;{encoded}\x1b\\")?; + output.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_write_osc52_output_format() { + let mut buf = Vec::new(); + write_osc52(&mut buf, "hello").unwrap(); + let output = String::from_utf8(buf).unwrap(); + + // Should start with OSC sequence + assert!(output.starts_with("\x1b]52;c;"), "should start with OSC52 prefix"); + + // Should have base64 payload + assert!(output.contains("aGVsbG8="), "should contain base64 of 'hello'"); + + // Should end with ST + assert!(output.ends_with("\x1b\\"), "should end with string terminator"); + } + + #[test] + fn test_write_osc52_empty() { + let mut buf = Vec::new(); + write_osc52(&mut buf, "").unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert_eq!(output, "\x1b]52;c;\x1b\\"); + } + + #[test] + fn test_write_osc52_unicode() { + let mut buf = Vec::new(); + write_osc52(&mut buf, "日本語").unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!(output.starts_with("\x1b]52;c;")); + assert!(output.ends_with("\x1b\\")); + } +} diff --git a/crates/zesdex-utils/src/error.rs b/crates/zesdex-utils/src/error.rs new file mode 100644 index 0000000..09f4628 --- /dev/null +++ b/crates/zesdex-utils/src/error.rs @@ -0,0 +1,97 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::fmt; + +/// Unified error type for the zesdex codebase. +#[derive(Debug)] +pub enum Error { + /// Wraps an I/O error. + Io(std::io::Error), + /// Wraps a JSON serialization/deserialization error. + Serde(serde_json::Error), + /// A generic parse failure with a message. + Parse(String), + /// A resource was not found. + NotFound(String), + /// Invalid input was provided. + InvalidInput(String), + /// The session is locked and cannot be accessed. + SessionLocked, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Serde(e) => write!(f, "serialization error: {e}"), + Self::Parse(msg) => write!(f, "parse error: {msg}"), + Self::NotFound(resource) => write!(f, "not found: {resource}"), + Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"), + Self::SessionLocked => write!(f, "session is locked"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Serde(e) => Some(e), + Self::Parse(_) | Self::NotFound(_) | Self::InvalidInput(_) | Self::SessionLocked => { + None + } + } + } +} + +// --------------------------------------------------------------------------- +// From conversions +// --------------------------------------------------------------------------- + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Self::Serde(e) + } +} + +// Note: anyhow already provides `From for anyhow::Error` for all +// `E: std::error::Error + Send + Sync + 'static`, which our `Error` satisfies. + +// --------------------------------------------------------------------------- +// Type alias +// --------------------------------------------------------------------------- + +/// Convenience alias for `Result`. +pub type Result = std::result::Result; + +// --------------------------------------------------------------------------- +// Additional impls +// --------------------------------------------------------------------------- + +impl Error { + /// Create a `Parse` error. + pub fn parse(msg: impl Into) -> Self { + Self::Parse(msg.into()) + } + + /// Create a `NotFound` error. + pub fn not_found(resource: impl Into) -> Self { + Self::NotFound(resource.into()) + } + + /// Create an `InvalidInput` error. + pub fn invalid_input(msg: impl Into) -> Self { + Self::InvalidInput(msg.into()) + } +} diff --git a/crates/zesdex-utils/src/lib.rs b/crates/zesdex-utils/src/lib.rs new file mode 100644 index 0000000..7fd7c33 --- /dev/null +++ b/crates/zesdex-utils/src/lib.rs @@ -0,0 +1,13 @@ +pub mod clipboard; +pub mod error; +pub mod logger; +pub mod pagination; +pub mod sanitize; +pub mod slug; + +pub use error::{Error, Result}; +pub use pagination::{paginate, Paginated}; +pub use sanitize::{ + is_valid_session_id, sanitize_filename, sanitize_html, sanitize_path, truncate, +}; +pub use slug::{slug_path, slugify}; diff --git a/crates/zesdex-utils/src/logger.rs b/crates/zesdex-utils/src/logger.rs new file mode 100644 index 0000000..d7f73aa --- /dev/null +++ b/crates/zesdex-utils/src/logger.rs @@ -0,0 +1,100 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::fs::{self, OpenOptions}; +use std::io; +use std::path::PathBuf; +use tracing_subscriber::fmt::writer::MakeWriter; +use tracing_subscriber::EnvFilter; + +/// A [`MakeWriter`] that writes to a log file, falling back to `/dev/null`. +#[derive(Clone)] +struct LogFileWriter { + path: PathBuf, +} + +impl std::io::Write for LogFileWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + match OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + { + Ok(mut file) => file.write(buf), + Err(_) => { + // fallback: write to /dev/null + let mut null = fs::OpenOptions::new().write(true).open("/dev/null")?; + null.write(buf) + } + } + } + + fn flush(&mut self) -> io::Result<()> { + match OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + { + Ok(file) => file.sync_all(), + Err(_) => Ok(()), + } + } +} + +impl<'a> MakeWriter<'a> for LogFileWriter { + type Writer = LogFileWriter; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +/// Initialise tracing/logging for the application. +/// +/// Creates a log directory at `$DATA_DIR/zesdex/logs/` and opens a log file +/// with a timestamped name in append mode. If the directory cannot be created +/// or the file cannot be opened, falls back to `/dev/null` so that tracing +/// never panics at startup. +/// +/// The subscriber uses `RUST_LOG` / `ZESDEX_LOG` env-filtering. +pub fn init_logging() -> Result<(), anyhow::Error> { + // ── determine log directory ────────────────────────────────────── + let data_dir = dirs::data_dir() + .map(|p| p.join("zesdex")) + .unwrap_or_else(|| PathBuf::from("/tmp/zesdex")); + + let log_dir = data_dir.join("logs"); + + // ── create dir (best-effort) ───────────────────────────────────── + if let Err(e) = fs::create_dir_all(&log_dir) { + // If we can't create the directory, log via eprintln and continue + // with a /dev/null fallback. + eprintln!("[zesdex-utils::logger] failed to create log dir {log_dir:?}: {e}"); + } + + // ── build log file path ────────────────────────────────────────── + let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); + let log_path = log_dir.join(format!("zesdex-{timestamp}.log")); + + // ── initialise tracing subscriber ──────────────────────────────── + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_from_env("ZESDEX_LOG")) + .unwrap_or_else(|_| EnvFilter::new("info")); + + let writer = LogFileWriter { path: log_path }; + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(writer) + .with_ansi(false) // log files don't need ANSI colours + .with_target(true) + .with_file(true) + .with_line_number(true) + .init(); + + Ok(()) +} diff --git a/crates/zesdex-utils/src/pagination.rs b/crates/zesdex-utils/src/pagination.rs new file mode 100644 index 0000000..370cfe4 --- /dev/null +++ b/crates/zesdex-utils/src/pagination.rs @@ -0,0 +1,138 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use serde::{Deserialize, Serialize}; + +/// A generic paginated response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Paginated { + /// Items on the current page. + pub items: Vec, + /// Total number of items across all pages. + pub total: usize, + /// Current page number (1-based). + pub page: usize, + /// Number of items per page. + pub page_size: usize, +} + +impl Paginated { + /// The total number of pages. + pub fn total_pages(&self) -> usize { + if self.total == 0 { + return 0; + } + self.total.div_ceil(self.page_size) + } + + /// Whether there is a next page. + pub fn has_next(&self) -> bool { + self.page < self.total_pages() + } + + /// Whether there is a previous page. + pub fn has_prev(&self) -> bool { + self.page > 1 + } +} + +/// Create a [`Paginated`] response by slicing `items` according to the +/// given `page` (1-based) and `page_size`. +/// +/// # Panics +/// +/// Panics if `page == 0` or `page_size == 0`. +pub fn paginate(items: Vec, page: usize, page_size: usize) -> Paginated { + assert!(page > 0, "page must be 1-based"); + assert!(page_size > 0, "page_size must be > 0"); + + let total = items.len(); + let offset = (page - 1) * page_size; + let items = if offset >= total { + Vec::new() + } else { + let end = (offset + page_size).min(total); + items.into_iter().skip(offset).take(end - offset).collect() + }; + + Paginated { + items, + total, + page, + page_size, + } +} + +/// Compute the SQL offset/limit from 1-based page params. +/// +/// Returns `(offset, limit)`. +pub fn page_params(page: usize, page_size: usize) -> (usize, usize) { + let offset = page.saturating_sub(1) * page_size; + (offset, page_size) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paginate_first_page() { + let items: Vec = (1..=25).collect(); + let result = paginate(items, 1, 10); + assert_eq!(result.items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert_eq!(result.total, 25); + assert_eq!(result.page, 1); + assert_eq!(result.page_size, 10); + } + + #[test] + fn test_paginate_last_page() { + let items: Vec = (1..=25).collect(); + let result = paginate(items, 3, 10); + assert_eq!(result.items, vec![21, 22, 23, 24, 25]); + assert_eq!(result.total, 25); + } + + #[test] + fn test_paginate_empty() { + let items: Vec = vec![]; + let result = paginate(items, 1, 10); + assert!(result.items.is_empty()); + assert_eq!(result.total, 0); + } + + #[test] + fn test_total_pages() { + let items: Vec = (1..=25).collect(); + let result = paginate(items, 1, 10); + assert_eq!(result.total_pages(), 3); + assert!(result.has_next()); + assert!(!result.has_prev()); + } + + #[test] + fn test_page_params() { + assert_eq!(page_params(1, 20), (0, 20)); + assert_eq!(page_params(2, 20), (20, 20)); + assert_eq!(page_params(3, 20), (40, 20)); + assert_eq!(page_params(0, 20), (0, 20)); // saturating sub + } + + #[test] + fn test_serde_roundtrip() { + let p: Paginated = Paginated { + items: vec!["a".into(), "b".into()], + total: 2, + page: 1, + page_size: 10, + }; + let json = serde_json::to_string(&p).unwrap(); + let back: Paginated = serde_json::from_str(&json).unwrap(); + assert_eq!(back.items, p.items); + assert_eq!(back.total, p.total); + } +} diff --git a/crates/zesdex-utils/src/sanitize.rs b/crates/zesdex-utils/src/sanitize.rs new file mode 100644 index 0000000..3cbabea --- /dev/null +++ b/crates/zesdex-utils/src/sanitize.rs @@ -0,0 +1,196 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +/// Characters that are invalid in filenames on most operating systems. +const INVALID_FILENAME_CHARS: &[char] = &[ + '/', '\0', '<', '>', ':', '"', '\\', '|', '?', '*', '\x01', '\x02', '\x03', '\x04', '\x05', + '\x06', '\x07', '\x08', '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', + '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b', + '\x1c', '\x1d', '\x1e', '\x1f', '\x7f', +]; + +/// Replace characters that are invalid in filenames with `_`. +/// +/// Also strips leading/trailing whitespace and dots, because those can be +/// problematic on some filesystems. +pub fn sanitize_filename(s: &str) -> String { + let sanitized: String = s + .chars() + .map(|c| { + if INVALID_FILENAME_CHARS.contains(&c) { + '_' + } else { + c + } + }) + .collect(); + + // Trim leading/trailing whitespace and dots + let trimmed = sanitized.trim_matches(|c: char| c == '.' || c.is_whitespace()); + + if trimmed.is_empty() { + return "unnamed".to_string(); + } + + trimmed.to_string() +} + +/// Sanitize a user-supplied path to prevent directory traversal. +/// +/// Replaces `..` path components with `_`, collapses repeated separators, +/// and strips any leading `/` to keep the result relative. +pub fn sanitize_path(path: &str) -> String { + let mut cleaned = String::new(); + + for component in path.split(&['/', '\\'][..]) { + if component.is_empty() { + continue; + } + if component == "." { + continue; + } + if component == ".." { + if !cleaned.is_empty() { + cleaned.push('/'); + } + cleaned.push('_'); + } else { + if !cleaned.is_empty() { + cleaned.push('/'); + } + cleaned.push_str(component); + } + } + + cleaned +} + +/// Escape HTML special characters so the string can be safely embedded in +/// HTML or XML content. +pub fn sanitize_html(s: &str) -> String { + let mut escaped = String::with_capacity(s.len()); + + for c in s.chars() { + match c { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(c), + } + } + + escaped +} + +/// Truncate a string to at most `max_chars` characters, appending `…` if it +/// was truncated. +/// +/// If `max_chars` is 0, returns an empty string. If the string is already +/// short enough, returns it unchanged. +pub fn truncate(s: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + + if s.chars().count() <= max_chars { + return s.to_string(); + } + + // Leave room for the ellipsis character + let cutoff = max_chars.saturating_sub(1); + let truncated: String = s.chars().take(cutoff).collect(); + format!("{truncated}…") +} + +/// Validate that a session ID contains only alphanumeric characters, dashes, +/// and underscores, and is non-empty. +pub fn is_valid_session_id(id: &str) -> bool { + if id.is_empty() { + return false; + } + + id.chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_filename_replaces_invalid() { + assert_eq!(sanitize_filename("hello/world:test"), "hello_world_test"); + } + + #[test] + fn test_sanitize_filename_trims_dots() { + assert_eq!(sanitize_filename(".hidden"), "hidden"); + } + + #[test] + fn test_sanitize_filename_empty_fallback() { + assert_eq!(sanitize_filename(".."), "unnamed"); + } + + #[test] + fn test_sanitize_path_removes_dotdot() { + assert_eq!( + sanitize_path("foo/../../bar"), + "foo/_/_/bar" + ); + } + + #[test] + fn test_sanitize_path_removes_dot() { + assert_eq!(sanitize_path("./foo/./bar"), "foo/bar"); + } + + #[test] + fn test_sanitize_path_backslash() { + assert_eq!(sanitize_path("foo\\..\\bar"), "foo/_/bar"); + } + + #[test] + fn test_sanitize_html_escapes() { + assert_eq!( + sanitize_html(""), + "<script>alert('xss')</script>" + ); + } + + #[test] + fn test_sanitize_html_ampersand() { + assert_eq!(sanitize_html("a & b"), "a & b"); + } + + #[test] + fn test_truncate_short() { + assert_eq!(truncate("hello", 10), "hello"); + } + + #[test] + fn test_truncate_long() { + let result = truncate("hello world this is long", 10); + assert_eq!(result.chars().count(), 10); + assert!(result.ends_with('…')); + } + + #[test] + fn test_truncate_zero() { + assert_eq!(truncate("hello", 0), ""); + } + + #[test] + fn test_valid_session_id() { + assert!(is_valid_session_id("abc-123_def")); + assert!(!is_valid_session_id("abc 123")); + assert!(!is_valid_session_id("")); + assert!(!is_valid_session_id("../evil")); + } +} diff --git a/crates/zesdex-utils/src/slug.rs b/crates/zesdex-utils/src/slug.rs new file mode 100644 index 0000000..44a0db6 --- /dev/null +++ b/crates/zesdex-utils/src/slug.rs @@ -0,0 +1,136 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] + +use std::path::{Path, PathBuf}; + +const MAX_SLUG_LENGTH: usize = 80; + +/// Convert an arbitrary string into a URL / filesystem-safe slug. +/// +/// The algorithm: +/// 1. Lowercase the input. +/// 2. Replace any sequence of non-alphanumeric characters (except `-` and `_`) +/// with a single `-`. +/// 3. Strip leading/trailing `-`. +/// 4. If the result is empty, return `None`. +/// 5. Truncate to 80 characters, breaking at the last full word if possible. +/// +/// Returns `None` if the slug would be completely empty. +pub fn slugify(s: &str) -> Option { + if s.is_empty() { + return None; + } + + let lower = s.to_lowercase(); + + // Replace non-alphanumeric (except dash/underscore) sequences with '-' + let mut slug = String::with_capacity(lower.len()); + let mut prev_was_sep = false; + + for c in lower.chars() { + if c.is_alphanumeric() { + slug.push(c); + prev_was_sep = false; + } else if !prev_was_sep { + slug.push('-'); + prev_was_sep = true; + } + // else skip consecutive separators + } + + // Strip leading/trailing dashes + let slug = slug.trim_matches('-').to_string(); + + if slug.is_empty() { + return None; + } + + // Truncate to MAX_SLUG_LENGTH + let slug = if slug.len() > MAX_SLUG_LENGTH { + let mut truncated: String = slug.chars().take(MAX_SLUG_LENGTH).collect(); + + // Trim trailing dash from broken word boundary + while truncated.ends_with('-') { + truncated.pop(); + } + + if truncated.is_empty() { + // If trimming removed everything, take the raw max-length prefix + slug.chars().take(MAX_SLUG_LENGTH).collect() + } else { + truncated + } + } else { + slug + }; + + Some(slug) +} + +/// Join `base` with a slugified version of `name`. +/// +/// If `slugify(name)` returns `None`, the name is used as-is (lowercased). +pub fn slug_path(base: &Path, name: &str) -> PathBuf { + match slugify(name) { + Some(slug) => base.join(slug), + None => base.join(name.to_lowercase()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_slugify_basic() { + assert_eq!(slugify("Hello World"), Some("hello-world".into())); + } + + #[test] + fn test_slugify_special_chars() { + assert_eq!(slugify("Hello, World! #2"), Some("hello-world-2".into())); + } + + #[test] + fn test_slugify_empty() { + assert_eq!(slugify(""), None); + } + + #[test] + fn test_slugify_only_separators() { + assert_eq!(slugify("!!! @@"), None); + } + + #[test] + fn test_slugify_collapse() { + assert_eq!(slugify("a b---c___d"), Some("a-b-c-d".into())); + } + + #[test] + fn test_slugify_leading_trailing() { + assert_eq!(slugify("---hello---"), Some("hello".into())); + } + + #[test] + fn test_slugify_dash_underscore_as_separator() { + assert_eq!(slugify("my-slug_here"), Some("my-slug-here".into())); + } + + #[test] + fn test_slugify_truncate() { + let long = "a".repeat(100); + let slug = slugify(&long); + assert!(slug.is_some()); + assert!(slug.as_ref().unwrap().len() <= MAX_SLUG_LENGTH); + } + + #[test] + fn test_slug_path() { + let base = Path::new("/tmp"); + assert_eq!(slug_path(base, "Hello World"), Path::new("/tmp/hello-world")); + } +} diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..43b1044 --- /dev/null +++ b/default.nix @@ -0,0 +1,25 @@ +{ pkgs ? import { } }: + +pkgs.rustPlatform.buildRustPackage { + pname = "zesdex"; + version = "1.13.0"; + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + cargoBuildFlags = [ "-p" "zesdex-backend" "--bin" "zesdex" ]; + + nativeBuildInputs = with pkgs; [ pkg-config ]; + buildInputs = with pkgs; [ openssl sqlite ]; + + doCheck = false; + + meta = with pkgs.lib; { + description = "AI-native coding agent with workflow orchestration"; + homepage = "https://github.com/asepharyana/zesdex"; + license = licenses.mit; + mainProgram = "zesdex"; + }; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6768163 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3.9" + +services: + zesdex: + build: + context: . + dockerfile: Dockerfile + image: zesdex:latest + container_name: zesdex + volumes: + - zesdex-data:/data + - ./workspace:/workspace:ro + environment: + - ZESDEX_DATA_DIR=/data + - RUST_LOG=${RUST_LOG:-info} + stdin_open: true + tty: true + # For daemon mode, expose the IPC socket directory + # ports: + # - "127.0.0.1:${ZESDEX_PORT:-0}:${ZESDEX_PORT:-0}" + +volumes: + zesdex-data: diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f23078b --- /dev/null +++ b/flake.nix @@ -0,0 +1,60 @@ +{ + description = "Zesdex — AI-native coding agent"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, rust-overlay }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { inherit system overlays; }; + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" ]; + }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + rustToolchain + pkg-config + openssl + sqlite + ]; + + RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; + RUST_LOG = "info"; + }; + + packages.default = pkgs.rustPlatform.buildRustPackage { + pname = "zesdex"; + version = "1.13.0"; + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + # Build only the main binary + cargoBuildFlags = [ "-p" "zesdex-backend" "--bin" "zesdex" ]; + + nativeBuildInputs = with pkgs; [ pkg-config ]; + buildInputs = with pkgs; [ openssl sqlite ]; + + doCheck = false; # Tests require filesystem access + + meta = with pkgs.lib; { + description = "AI-native coding agent with workflow orchestration"; + homepage = "https://github.com/asepharyana/zesdex"; + license = licenses.mit; + mainProgram = "zesdex"; + }; + }; + }); +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..720fa6e --- /dev/null +++ b/shell.nix @@ -0,0 +1,17 @@ +{ pkgs ? import { } }: + +with pkgs; + +mkShell { + buildInputs = [ + (rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" ]; + }) + pkg-config + openssl + sqlite + ]; + + RUST_SRC_PATH = "${rust-bin.stable.latest.default}/lib/rustlib/src/rust/library"; + RUST_LOG = "info"; +} diff --git a/src/dto/chat/mod.rs b/src/dto/chat/mod.rs deleted file mode 100644 index a43b29a..0000000 --- a/src/dto/chat/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Chat DTO submodules: message roles/content and tool-call structures. -pub mod message; -pub mod tool; diff --git a/src/dto/mod.rs b/src/dto/mod.rs deleted file mode 100644 index 600a4be..0000000 --- a/src/dto/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Data transfer objects shared across the app: chat messages/tool calls -//! and provider request/response/usage shapes. -pub mod chat; -pub mod provider; diff --git a/src/dto/provider/mod.rs b/src/dto/provider/mod.rs deleted file mode 100644 index 92eace6..0000000 --- a/src/dto/provider/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Provider-facing DTOs: chat completion request, response, and usage/cost. -pub mod request; -pub mod response; -pub mod usage; diff --git a/src/dto/provider/response.rs b/src/dto/provider/response.rs deleted file mode 100644 index 3a2c238..0000000 --- a/src/dto/provider/response.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Inbound response DTOs for the non-streaming chat completions API. -//! -//! Flow: provider HTTP response body → `serde_json` deserializes into -//! `ChatResponse` → caller reads `choices[0].message` for the assistant -//! reply and `usage` for token accounting. -//! -//! Why: separate from the streaming SSE path (see `app/runtime/stream/mod.rs`), -//! which parses incremental deltas rather than a single complete payload. -use serde::{Deserialize, Serialize}; - -/// Non-streaming chat completion response returned by the provider. -/// -/// Flow: deserialized directly from the HTTP response body of a -/// non-streaming completion call. -/// -/// Return: not a function, but the value callers inspect for the model's -/// reply (`choices`) and token usage (`usage`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatResponse { - pub id: String, - pub model: String, - pub choices: Vec, - pub usage: Option, - pub created: Option, -} - -/// One completion candidate within a `ChatResponse.choices` list. -/// -/// Why: `finish_reason` is optional/string-typed since providers vary in -/// what values they emit (e.g. `"stop"`, `"tool_calls"`, `"length"`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Choice { - pub index: u32, - pub message: super::super::chat::message::ChatMessage, - pub finish_reason: Option, -} diff --git a/src/dto/provider/usage.rs b/src/dto/provider/usage.rs deleted file mode 100644 index 1da05b4..0000000 --- a/src/dto/provider/usage.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Token usage accounting DTO shared by streaming and non-streaming responses. -//! -//! Flow: populated from the provider's `usage` object (either the final SSE -//! chunk when `stream_options.include_usage` is set, or the `usage` field of -//! a non-streaming `ChatResponse`) → surfaced to the TUI for cost/token -//! display. -use serde::{Deserialize, Serialize}; - -/// Token counts and optional cost breakdown for a single completion request. -/// -/// Why: all fields are optional because providers differ in what they -/// report — some omit per-token cost entirely, others omit usage altogether -/// on certain response paths. `Default` lets callers start from an empty -/// usage record when a provider sends none. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Usage { - pub prompt_tokens: Option, - pub completion_tokens: Option, - pub total_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub prompt_tokens_cost: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub completion_tokens_cost: Option, -} diff --git a/src/ipc/client.rs b/src/ipc/client.rs deleted file mode 100644 index 66ab507..0000000 --- a/src/ipc/client.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Unix-socket client used by the `--attach` process to talk to a -//! running `--daemon`. -//! -//! Flow: `IpcClient::connect_unix` opens a `Connection` (see `conn.rs`) -//! to the daemon's socket path → `send`/`receive` exchange framed JSON -//! messages (typically `ClientRequest`/`DaemonFrame` from `protocol.rs`). -use super::conn::Connection; -use anyhow::Result; - -/// Client-side handle for the `--attach` process: wraps a `Connection` -/// to a daemon's Unix socket. -pub struct IpcClient { - conn: Connection, -} - -impl IpcClient { - /// Connect to a daemon listening on the given Unix socket path. - /// - /// Return: `Ok(IpcClient)` on success, or an error if the socket is - /// missing or the daemon isn't accepting connections. - pub fn connect_unix(path: &str) -> Result { - let conn = Connection::connect_unix(path)?; - Ok(IpcClient { conn }) - } - - /// Serialize and send a value to the daemon (see `frame::write_frame`). - pub fn send(&mut self, value: &T) -> Result<()> { - self.conn.send(value) - } - - /// Read and deserialize the next frame from the daemon. - /// - /// Return: `Ok(None)` if the daemon closed the connection cleanly. - pub fn receive(&mut self) -> Result> { - self.conn.receive() - } -} diff --git a/src/ipc/conn.rs b/src/ipc/conn.rs deleted file mode 100644 index 805fb0d..0000000 --- a/src/ipc/conn.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Framed Unix-socket connection shared by both the server (`server.rs`) -//! and client (`client.rs`) sides of the IPC layer. -//! -//! Flow: `Connection` wraps a `UnixStream` (either accepted by the server -//! or dialed by the client) → `send` serializes a value to JSON and -//! writes it as one length-prefixed frame (`frame::write_frame`) → -//! `receive` reads one frame and deserializes it back to the caller's -//! type, propagating a clean peer-close as `Ok(None)`. -use super::frame; -use anyhow::Result; -use std::os::unix::net::UnixStream; - -/// A framed Unix-socket connection shared by client and server sides of -/// the IPC layer; each `send`/`receive` moves one length-prefixed JSON frame. -pub struct Connection { - inner: UnixStream, -} - -impl Connection { - /// Wrap an already-connected/accepted `UnixStream`. - pub fn from_stream(stream: UnixStream) -> Self { - Connection { inner: stream } - } - - /// Open a new Unix-socket connection to `path`. - pub fn connect_unix(path: &str) -> Result { - let stream = UnixStream::connect(path)?; - Ok(Connection { inner: stream }) - } - - /// Serialize `value` to JSON and write it as one length-prefixed frame. - pub fn send(&mut self, value: &T) -> Result<()> { - let data = frame::serialize_frame(value)?; - frame::write_frame(&mut self.inner, &data) - } - - /// Read one length-prefixed frame and deserialize it as `T`. - /// - /// Return: `Ok(None)` on clean EOF (peer closed the connection). - pub fn receive(&mut self) -> Result> { - let data = frame::read_frame(&mut self.inner)?; - match data { - Some(bytes) => { - let value: T = frame::deserialize_frame(&bytes)?; - Ok(Some(value)) - } - None => Ok(None), - } - } -} diff --git a/src/ipc/diff.rs b/src/ipc/diff.rs deleted file mode 100644 index 23a7845..0000000 --- a/src/ipc/diff.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Field-level diffing of JSON app-state snapshots, for sending only -//! incremental changes over IPC instead of a full `StateSnapshot`. -//! -//! Flow: `compute_diff` recursively walks two JSON `Value`s (before/after) -//! → for objects, recurses per key building a dotted path string; any -//! other mismatch is recorded wholesale → results accumulate into a -//! `StateDiff`'s `Vec`, built via `StateDiff::new`/`add_change` -//! and reset via `clear`. -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// A timestamped batch of field-level changes to app state, keyed by -/// dotted JSON path, for a given session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StateDiff { - pub timestamp: i64, - pub session_id: String, - pub changes: Vec, -} - -/// A single field change: the JSON path and its old/new values. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Change { - pub path: String, - pub old_value: Option, - pub new_value: Option, -} - -impl StateDiff { - /// Create an empty diff for `session_id`, timestamped at creation. - pub fn new(session_id: String) -> Self { - StateDiff { - timestamp: chrono::Utc::now().timestamp_millis(), - session_id, - changes: Vec::new(), - } - } - - /// Append a single field change to the diff. - pub fn add_change(&mut self, path: String, old_value: Option, new_value: Option) { - self.changes.push(Change { - path, - old_value, - new_value, - }); - } - - /// Whether the diff has no recorded changes. - pub fn is_empty(&self) -> bool { - self.changes.is_empty() - } - - /// Drop all changes and refresh the timestamp. - pub fn clear(&mut self) { - self.changes.clear(); - self.timestamp = chrono::Utc::now().timestamp_millis(); - } -} - -/// Recursively diff two JSON values, appending field-level `Change`s. -/// -/// Flow: equal values short-circuit → for two objects, recurse per key -/// (union of both maps' keys, missing side treated as `Null`) building -/// a dotted `path` → any other value-type mismatch (or non-object diff) -/// is recorded as one `Change` at the current `path`. -/// -/// Why: only objects are diffed structurally; arrays and scalars are -/// compared wholesale so a change anywhere inside them replaces the -/// whole value rather than producing an index-level diff. -pub fn compute_diff(before: &Value, after: &Value, path: &str, changes: &mut Vec) { - if before == after { - return; - } - match (before, after) { - (Value::Object(b_map), Value::Object(a_map)) => { - let mut all_keys: Vec<&str> = Vec::new(); - for key in b_map.keys() { - if !all_keys.contains(&key.as_str()) { - all_keys.push(key.as_str()); - } - } - for key in a_map.keys() { - if !all_keys.contains(&key.as_str()) { - all_keys.push(key.as_str()); - } - } - for key in all_keys { - let child_path = if path.is_empty() { - key.to_string() - } else { - format!("{}.{}", path, key) - }; - let b_val = b_map.get(key); - let a_val = a_map.get(key); - compute_diff( - b_val.unwrap_or(&Value::Null), - a_val.unwrap_or(&Value::Null), - &child_path, - changes, - ); - } - } - _ => { - changes.push(Change { - path: path.to_string(), - old_value: Some(before.clone()), - new_value: Some(after.clone()), - }); - } - } -} diff --git a/src/ipc/frame.rs b/src/ipc/frame.rs deleted file mode 100644 index 1b3e4d4..0000000 --- a/src/ipc/frame.rs +++ /dev/null @@ -1,199 +0,0 @@ -#![allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - clippy::cast_possible_wrap -)] -//! Length-prefixed binary framing and JSON (de)serialization helpers for -//! the IPC wire protocol. -//! -//! Flow: `write_frame`/`read_frame` handle the raw byte-level framing -//! (4-byte big-endian length header + payload) over any `Read`/`Write`; -//! `serialize_frame`/`deserialize_frame` handle the JSON layer on top. -//! `Connection` (see `conn.rs`) composes both layers for a full send/receive. -//! -//! Why: a fixed-size length prefix lets the reader know exactly how many -//! bytes to pull before attempting to parse, avoiding partial-JSON reads -//! over a stream socket. -use anyhow::Result; -use std::io::{Read, Write}; - -/// Upper bound on a single frame's byte size (64 MiB), enforced on both -/// the write and read paths to bound memory use and reject malformed or -/// malicious oversized length headers. -pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024; - -/// Write `data` as a length-prefixed frame: 4-byte big-endian length -/// followed by the raw bytes, then flush. -/// -/// Why: rejects frames over `MAX_FRAME_SIZE` to bound memory use on the -/// reading side before any bytes are read. -pub fn write_frame(writer: &mut W, data: &[u8]) -> Result<()> { - let len = data.len(); - if len > MAX_FRAME_SIZE { - anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit"); - } - let len_bytes = (len as u32).to_be_bytes(); - writer.write_all(&len_bytes)?; - writer.write_all(data)?; - writer.flush()?; - Ok(()) -} - -/// Read one length-prefixed frame written by `write_frame`. -/// -/// Flow: read 4-byte length header → on clean EOF before any bytes, -/// return `Ok(None)` (peer closed) → validate against `MAX_FRAME_SIZE` -/// → read the payload. -/// -/// Return: `Ok(None)` signals a graceful connection close, distinct -/// from an `Err` mid-frame I/O failure. -pub fn read_frame(reader: &mut R) -> Result>> { - let mut len_buf = [0u8; 4]; - match reader.read_exact(&mut len_buf) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), - Err(e) => return Err(e.into()), - } - let len = u32::from_be_bytes(len_buf) as usize; - if len > MAX_FRAME_SIZE { - anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit"); - } - let mut buf = vec![0u8; len]; - reader.read_exact(&mut buf)?; - Ok(Some(buf)) -} - -/// Serialize `value` to JSON bytes, rejecting output over `MAX_FRAME_SIZE`. -pub fn serialize_frame(value: &T) -> Result> { - let json = serde_json::to_vec(value)?; - if json.len() > MAX_FRAME_SIZE { - anyhow::bail!("serialized frame too large: {} bytes", json.len()); - } - Ok(json) -} - -/// Deserialize a frame's raw JSON bytes into `T`. -pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result { - Ok(serde_json::from_slice(data)?) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Write a value, read it back, and verify exact equality. - fn roundtrip_bytes(data: &[u8]) { - let mut buf: Vec = Vec::new(); - write_frame(&mut buf, data).unwrap(); - let read_back = read_frame(&mut buf.as_slice()) - .unwrap() - .expect("expected Some(frame)"); - assert_eq!(read_back, data); - } - - #[test] - fn test_write_read_roundtrip_empty() { - roundtrip_bytes(b""); - } - - #[test] - fn test_write_read_roundtrip_small_text() { - roundtrip_bytes(b"hello world"); - } - - #[test] - fn test_write_read_roundtrip_binary() { - roundtrip_bytes(&[0x00, 0xFF, 0xAB, 0xCD, 0x01, 0x02, 0x03]); - } - - #[test] - fn test_write_read_roundtrip_large() { - let data = vec![0x42u8; 100_000]; - roundtrip_bytes(&data); - } - - #[test] - fn test_write_rejects_too_large_frame() { - let oversized = vec![0u8; MAX_FRAME_SIZE + 1]; - let mut buf = Vec::new(); - let result = write_frame(&mut buf, &oversized); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("too large") || err.contains("64 MiB")); - } - - #[test] - fn test_read_rejects_too_large_header() { - // Manually craft a 4-byte length header that exceeds MAX_FRAME_SIZE - let len = (MAX_FRAME_SIZE as u32).wrapping_add(1); - let header = len.to_be_bytes(); - let mut buf = Vec::from(&header[..]); - buf.extend_from_slice(b"dummy"); - let result = read_frame(&mut buf.as_slice()); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("too large")); - } - - #[test] - fn test_read_empty_buf_returns_none() { - let empty: &[u8] = &[]; - let result = read_frame(&mut &empty[..]).unwrap(); - assert!(result.is_none(), "expected None for empty reader"); - } - - #[test] - fn test_read_partial_header_returns_none() { - // Only 2 bytes of the 4-byte header → EOF - let partial: &[u8] = &[0x00, 0x01]; - let result = read_frame(&mut &partial[..]).unwrap(); - assert!(result.is_none(), "expected None for partial header"); - } - - #[test] - fn test_read_truncated_payload_returns_err() { - let mut buf = Vec::new(); - let header = (10u32).to_be_bytes(); - buf.extend_from_slice(&header); - buf.extend_from_slice(b"abc"); // only 3 of 10 bytes - let result = read_frame(&mut buf.as_slice()); - assert!(result.is_err(), "truncated payload should error"); - } - - #[test] - fn test_serialize_deserialize_roundtrip() { - use serde::{Deserialize, Serialize}; - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - struct Msg { - id: u32, - content: String, - tags: Vec, - } - - let original = Msg { - id: 42, - content: "hello world".into(), - tags: vec!["foo".into(), "bar".into()], - }; - - let bytes = serialize_frame(&original).unwrap(); - let deserialized: Msg = deserialize_frame(&bytes).unwrap(); - assert_eq!(original, deserialized); - } - - #[test] - fn test_serialize_rejects_oversized_value() { - let huge = vec![0u8; MAX_FRAME_SIZE + 1]; - let result = serialize_frame(&huge); - assert!(result.is_err()); - } - - #[test] - fn test_deserialize_malformed_json_errors() { - let bad_json = b"this is not json"; - let result: Result = deserialize_frame(bad_json); - assert!(result.is_err()); - } -} diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs deleted file mode 100644 index 00adf96..0000000 --- a/src/ipc/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Unix-socket IPC layer used to connect a `--attach` TUI client to a -//! `--daemon` process: length-prefixed framing, connection wrapper, -//! client/server handles, and the wire protocol types. -pub mod client; -pub mod conn; -pub mod frame; -pub mod protocol; -pub mod server; diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs deleted file mode 100644 index 64c7d3c..0000000 --- a/src/ipc/protocol.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Wire message types exchanged between an attached client and the -//! daemon over the `Connection`/framing layer (`conn.rs`, `frame.rs`). -//! -//! Flow: client input events are captured as `KeyAction`/`ClientRequest` -//! and sent to the daemon → the daemon applies them to its `AppStateRest` -//! and replies with `DaemonFrame` variants (a flattened `StatePayload` -//! for redraw, streamed tokens, system notes, or a close signal). -//! -//! Why: `StatePayload`/`MessageEntry`/`ToastEntry` are deliberately flat, -//! serializable projections of daemon-side state so the client can -//! redraw its TUI without sharing any in-process state with the daemon. -use serde::{Deserialize, Serialize}; - -/// Wire-serializable subset of `crossterm::event::KeyCode`, sent from -/// an attached client to the daemon over IPC. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum KeyAction { - Char(char), - Enter, - Escape, - Backspace, - Delete, - Tab, - Up, - Down, - Left, - Right, - Home, - End, - PageUp, - PageDown, - Function(u8), -} - -/// Messages an attached client sends to the daemon: input events, a -/// full-line submit, terminal resize, and connection lifecycle. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ClientRequest { - Tick, - KeyPress { - key: KeyAction, - ctrl: bool, - alt: bool, - shift: bool, - }, - Submit(String), - /// Bulk-pasted text from a bracketed-paste event. - Paste(String), - Resize(u16, u16), - Close, - ScrollUp, - ScrollDown, -} - -/// Flattened chat message sent from daemon to client for transcript display. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageEntry { - pub role: String, - pub content: String, - pub timestamp: i64, -} - -/// Flattened toast notification sent from daemon to client for rendering. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToastEntry { - pub kind: String, - pub message: String, - pub created_at: i64, - pub lifetime_ms: u64, -} - -/// Snapshot of daemon-side `AppStateRest` sent to the client after every -/// action, enough for the client to redraw its TUI without shared state. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StatePayload { - pub session_id: String, - pub messages: Vec, - pub edit_count: u32, - pub message_count: usize, - pub overlay: Option, - pub toasts: Vec, - pub dirty: bool, - pub input_buffer: String, - pub input_cursor: usize, -} - -/// Messages the daemon sends back to an attached client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DaemonFrame { - StateUpdate(Box), - StreamToken(String), - SystemNote { kind: String, message: String }, - ClipboardCopy(String), - Closed, -} diff --git a/src/ipc/server.rs b/src/ipc/server.rs deleted file mode 100644 index 2140088..0000000 --- a/src/ipc/server.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Unix-socket listener for the `--daemon` process. -//! -//! Flow: `IpcServer::bind_unix` opens/binds a Unix socket at a well-known -//! path (clearing any stale file left by a crashed prior daemon) → -//! `accept` blocks for the next client and wraps it as a `Connection` -//! (see `conn.rs`) for framed request/response traffic. -use super::conn::Connection; -use anyhow::Result; -use std::os::unix::net::UnixListener; - -/// Server-side handle for the `--daemon` process: listens on a Unix -/// socket and hands out `Connection`s to accepted clients. -pub struct IpcServer { - listener: UnixListener, -} - -impl IpcServer { - /// Bind a new Unix-socket listener at `path`. - /// - /// Why: removes any stale socket file at `path` first, since a prior - /// crashed daemon can leave one behind and `UnixListener::bind` fails - /// on an existing path. - pub fn bind_unix(path: &str) -> Result { - let _ = std::fs::remove_file(path); - let listener = UnixListener::bind(path)?; - Ok(IpcServer { listener }) - } - - /// Block until a client connects, then wrap it as a `Connection`. - pub fn accept(&self) -> Result { - let (stream, _addr) = self.listener.accept()?; - Ok(Connection::from_stream(stream)) - } -} diff --git a/src/ipc/snapshot.rs b/src/ipc/snapshot.rs deleted file mode 100644 index 3f0c452..0000000 --- a/src/ipc/snapshot.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Point-in-time state snapshots for external inspection/persistence of -//! a running session (distinct from the incremental `StateDiff` in -//! `diff.rs`). -//! -//! Flow: `StateSnapshot::new` builds an empty, `dirty`-marked snapshot → -//! callers populate/replace its fields as state changes → -//! `serialize_snapshot`/`deserialize_snapshot` move it to/from JSON bytes -//! for storage or IPC transport. -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// Point-in-time summary of app state (mode, session, counts, arbitrary -/// `payload`) used for external inspection/persistence of a running session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StateSnapshot { - pub timestamp: i64, - pub mode: String, - pub session_id: String, - pub message_count: usize, - pub edit_count: u32, - pub dirty: bool, - pub overlay_active: bool, - pub model: String, - pub payload: Value, -} - -impl StateSnapshot { - /// Build a fresh, empty snapshot marked `dirty` for the given session. - pub fn new(session_id: String, mode: String, model: String) -> Self { - StateSnapshot { - timestamp: chrono::Utc::now().timestamp_millis(), - mode, - session_id, - message_count: 0, - edit_count: 0, - dirty: true, - overlay_active: false, - model, - payload: serde_json::json!({}), - } - } -} - -/// Serialize a `StateSnapshot` to JSON bytes. -pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result> { - let data = serde_json::to_vec(snapshot)?; - Ok(data) -} - -/// Deserialize JSON bytes back into a `StateSnapshot`. -pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result { - let snapshot: StateSnapshot = serde_json::from_slice(data)?; - Ok(snapshot) -} diff --git a/src/model/conversation.rs b/src/model/conversation.rs deleted file mode 100644 index 801a7ff..0000000 --- a/src/model/conversation.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! In-memory conversation state: message history plus the system prompt and -//! model parameters used to drive the LLM. -use serde::{Deserialize, Serialize}; - -/// A single conversation's message history and generation settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Conversation { - pub messages: Vec, - pub system_prompt: String, - pub session_id: String, - pub model: String, - pub max_tokens: Option, - pub temperature: Option, -} - -impl Conversation { - /// Create an empty conversation with the given system prompt and - /// session id, using default model/token/temperature settings. - pub fn new(system_prompt: String, session_id: String) -> Self { - Conversation { - messages: Vec::new(), - system_prompt, - session_id, - model: "anthropic/claude-opus-4-8".to_string(), - max_tokens: None, - temperature: None, - } - } - - /// Append a message to the conversation history. - pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) { - self.messages.push(msg); - } - - /// Replace the system prompt and strip any prior `System`-role - /// messages from history. - /// - /// Why: the system prompt is re-injected fresh at request time via - /// `to_api_messages`, so stale `System` messages in `self.messages` - /// would be redundant/conflicting if left in place. - pub fn rebuild_system(&mut self, new_prompt: String) { - self.system_prompt = new_prompt; - self.messages.retain(|m| { - !matches!(m.role, crate::dto::chat::message::Role::System) - }); - } - - /// Build the message list to send to the LLM API, with the system - /// prompt prepended. - /// - /// Return: a new `Vec` (clone of history) with a synthesized system - /// message at index 0. - pub fn to_api_messages(&self) -> Vec { - let mut msgs = Vec::with_capacity(self.messages.len() + 1); - msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt)); - msgs.extend(self.messages.iter().cloned()); - msgs - } - - /// Number of messages in the conversation history (excluding the - /// synthesized system message). - pub fn len(&self) -> usize { - self.messages.len() - } -} diff --git a/src/model/mod.rs b/src/model/mod.rs deleted file mode 100644 index 0e7f938..0000000 --- a/src/model/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Persistence and domain model layer: sessions, conversations, memory, -//! message log (`SQLite`), edit log, and app/settings config. -pub mod app_config; -pub mod editlog; -pub mod memory; -pub mod msglog; -pub mod session; -pub mod session_lock; -pub mod settings; -pub mod store; diff --git a/src/model/msglog/summary.rs b/src/model/msglog/summary.rs deleted file mode 100644 index 119323b..0000000 --- a/src/model/msglog/summary.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Session archive/summary metadata tracked alongside the message log -//! (title, model, counts, and a rolling text summary). -use serde::{Deserialize, Serialize}; - -/// Summary metadata for one archived/summarized session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SummaryRecord { - pub session_id: String, - pub title: String, - pub model: String, - pub message_count: usize, - pub token_count: usize, - pub summary: String, - pub created_at: i64, - pub updated_at: i64, -} - -impl SummaryRecord { - /// Create a fresh summary record with zeroed counts and an empty - /// summary, timestamped to now. - pub fn new(session_id: String, title: String, model: String) -> Self { - let now = chrono::Utc::now().timestamp_millis(); - SummaryRecord { - session_id, - title, - model, - message_count: 0, - token_count: 0, - summary: String::new(), - created_at: now, - updated_at: now, - } - } - - /// Replace the summary text and bump `updated_at`. - pub fn update_summary(&mut self, summary: String) { - self.summary = summary; - self.updated_at = chrono::Utc::now().timestamp_millis(); - } - - /// Add to the running message/token counts and bump `updated_at`. - pub fn increment_counts(&mut self, messages: usize, tokens: usize) { - self.message_count += messages; - self.token_count += tokens; - self.updated_at = chrono::Utc::now().timestamp_millis(); - } -} diff --git a/src/service/mod.rs b/src/service/mod.rs deleted file mode 100644 index c7c4918..0000000 --- a/src/service/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! External service integrations: the LLM provider HTTP client and OAuth flows. -pub mod oauth; -pub mod provider; diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs deleted file mode 100644 index b70f74f..0000000 --- a/src/service/oauth/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! OAuth 2.0 authorization-code + PKCE support: verifier/challenge generation, -//! the loopback redirect server, and the token-exchange manager. -pub mod loopback; -pub mod manager; -pub mod pkce;