Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ced6681c2 | ||
|
|
08e2f9998d | ||
|
|
792695b65a | ||
|
|
da2ed6da25 | ||
|
|
bceba665c0 | ||
|
|
714b4617dd | ||
|
|
e9a8e93c83 | ||
|
|
ab1a54b72e | ||
|
|
5aaedbf787 | ||
|
|
6680795ce7 | ||
|
|
9b6e51dc67 | ||
|
|
40524f930f | ||
|
|
086cb86f0d | ||
|
|
ff47bffe0c | ||
|
|
431c8d3b89 | ||
|
|
e32501ee59 | ||
|
|
7d8487cefb | ||
|
|
4cd38c9291 | ||
|
|
9a6ab62562 | ||
|
|
b02754acd2 | ||
|
|
b3c5b2a57b | ||
|
|
796bb09c5b | ||
|
|
5aad7e1eb1 | ||
|
|
0dfde96f81 | ||
|
|
9a67137954 | ||
|
|
1f0ae9f551 | ||
|
|
22dd6fdda7 | ||
|
|
b272858edb | ||
|
|
3f79b283e2 | ||
|
|
9618ef413b | ||
|
|
0ad3b0e539 | ||
|
|
dc9fd4dbd1 | ||
|
|
8e6acc30d5 | ||
|
|
6d41ffc587 | ||
|
|
f51a32569f | ||
|
|
add6845edf | ||
|
|
4ab812d93b | ||
|
|
ff6a749c11 | ||
|
|
f2d97fb17d | ||
|
|
be278f8b1c | ||
|
|
910aa5e071 | ||
|
|
5ede65f454 | ||
|
|
4dc4f80fa3 | ||
|
|
3401d63063 | ||
|
|
be0a9582bb | ||
|
|
86cc412395 | ||
|
|
1afca97c1b | ||
|
|
a00aa9bec8 | ||
|
|
7d99cd6618 | ||
|
|
e075eb7acc | ||
|
|
d6de9735ab | ||
|
|
85d16ebe97 | ||
|
|
96084f7462 | ||
|
|
82c024a854 | ||
|
|
78e402cf14 | ||
|
|
3a6e32d8f3 | ||
|
|
e080d9fc6b | ||
|
|
ea0b498829 | ||
|
|
682007a250 | ||
|
|
7ffdf44135 | ||
|
|
ceb84790bb | ||
|
|
12a03fd3d1 | ||
|
|
059ca8ea24 | ||
|
|
c219b6ec58 | ||
|
|
8bb697a53f | ||
|
|
75e9cadcd5 | ||
|
|
c4e1d7c2b2 | ||
|
|
da39033e81 | ||
|
|
f62ac3f688 | ||
|
|
aaab953d8a | ||
|
|
e0d0860d7b | ||
|
|
dcfc5b9ec0 | ||
|
|
472162d135 | ||
|
|
71b1613d84 | ||
|
|
d93bdba59b | ||
|
|
3b6abd920e | ||
|
|
7f2509ecd2 | ||
|
|
2eebedfea5 | ||
|
|
930961bd85 | ||
|
|
93be92a5fb | ||
|
|
95cae8fd8a | ||
|
|
5c413cf9a3 | ||
|
|
c386030573 | ||
|
|
199028fa2e | ||
|
|
8d77e4565c | ||
|
|
683715cd7a | ||
|
|
2bb8e6f255 | ||
|
|
c3c0ef632a | ||
|
|
5dd835ff24 | ||
|
|
0a24903eb0 | ||
|
|
de8e3703f2 | ||
|
|
b7fc335bf5 | ||
|
|
821622e80d | ||
|
|
8fbc51534d | ||
|
|
3f284cbb9a | ||
|
|
54484ed137 | ||
|
|
9bf5236680 | ||
|
|
4428e8bc01 | ||
|
|
b2e848d124 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,98 @@
|
||||
# [1.16.0](https://github.com/asepharyana/zesdex/compare/v1.15.2...v1.16.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tui:** add usage overlay and sidebar for displaying usage statistics and tasks ([da2ed6d](https://github.com/asepharyana/zesdex/commit/da2ed6da25953b823354cc5deaa7b404b7b13cb0))
|
||||
* **tui:** enhance agent turn with tool descriptions and sanitize arguments ([08e2f99](https://github.com/asepharyana/zesdex/commit/08e2f9998d3693e4cd57225491359c762d03f1d9))
|
||||
* **tui:** implement agent turn engine for background processing and enhance input handling ([792695b](https://github.com/asepharyana/zesdex/commit/792695b65a393cfc54efe353480b7831e91544b5))
|
||||
|
||||
## [1.15.2](https://github.com/asepharyana/zesdex/compare/v1.15.1...v1.15.2) (2026-07-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct jitter range to ±25% and fix abort.rs doc — review findings ([9b6e51d](https://github.com/asepharyana/zesdex/commit/9b6e51dc677cc4b798532f1e63335785f2f7bbbf))
|
||||
|
||||
## [1.15.1](https://github.com/asepharyana/zesdex/compare/v1.15.0...v1.15.1) (2026-07-17)
|
||||
|
||||
# [1.15.0](https://github.com/asepharyana/zesdex/compare/v1.14.0...v1.15.0) (2026-07-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cms:** perbaiki serde default hive_mind_node_timeout_ms & toleransi parse gagal di Settings ([6d41ffc](https://github.com/asepharyana/zesdex/commit/6d41ffc587f6f8de5bb4757c7eb8d69077831057))
|
||||
* **cms:** satukan ChatMessage/Role Conversation dengan tipe kanonik zesdex-entities ([b272858](https://github.com/asepharyana/zesdex/commit/b272858edbe36af0b2eca119ff5b132de8dadce9))
|
||||
* **iam:** redirect_uri dinamis + validasi CSRF state di OAuthServiceImpl ([be278f8](https://github.com/asepharyana/zesdex/commit/be278f8b1c2bc1295368e49c38622ef2bc3ed9ee))
|
||||
* **iam:** set permission 0600 pada file token OAuth ([f2d97fb](https://github.com/asepharyana/zesdex/commit/f2d97fb17dcf4f52123da89532d7964ed8f8124c))
|
||||
* **middleware:** jangan percaya header X-Forwarded-For/X-Real-IP secara default di rate limiter ([4dc4f80](https://github.com/asepharyana/zesdex/commit/4dc4f80fa344d894c2ce62f75d239847cd13001a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cms:** implement RewindBlobRepository for managing binary blobs ([22dd6fd](https://github.com/asepharyana/zesdex/commit/22dd6fdda7d0c8eabfce296bbad53587204c69fa))
|
||||
* **iam:** implementasikan FileSystemSessionLockRepository (sebelumnya belum ada implementasi) ([ff6a749](https://github.com/asepharyana/zesdex/commit/ff6a749c1149d890173922fbff76facef50a93b0))
|
||||
* **iam:** port LoopbackServer OAuth callback listener dari zesdex-backend ([910aa5e](https://github.com/asepharyana/zesdex/commit/910aa5e071911f158609c4dd8985f776d8d9235f))
|
||||
* **iam:** tambahkan CSPRNG (OsRng) untuk token state/PKCE ([5ede65f](https://github.com/asepharyana/zesdex/commit/5ede65f454b08303f588754a208dca0c37d3ce34))
|
||||
|
||||
# [1.14.0](https://github.com/asepharyana/zesdex/compare/v1.13.0...v1.14.0) (2026-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **context:** batasi squash_log ke tool bash saja ([7d99cd6](https://github.com/asepharyana/zesdex/commit/7d99cd66187b3fafb2ddeb19d8e8aa7db139df64))
|
||||
* **context:** perbaiki fixture test shaping agar men-drop pesan lama ([8bb697a](https://github.com/asepharyana/zesdex/commit/8bb697a53fe89c9d00f709df33ccbd7944482f07))
|
||||
* **plan:** perbaiki bug entropy gate dan fixture test squash.rs ([ceb8479](https://github.com/asepharyana/zesdex/commit/ceb84790bb741352b2b3416cda4bdbc6b42767c9))
|
||||
* **plan:** perbaiki fixture test array-cutoff squash_json ([7ffdf44](https://github.com/asepharyana/zesdex/commit/7ffdf441355cda0972b849bc9b475d3de8f39843))
|
||||
* **plan:** perbaiki fixture test shaping agar benar-benar men-drop pesan ([3a6e32d](https://github.com/asepharyana/zesdex/commit/3a6e32d8f3f0dd47d2c5ff63ab0c184b0ee4a6bc))
|
||||
* **plan:** perkuat fixture test log agar benar-benar uji squash_log ([682007a](https://github.com/asepharyana/zesdex/commit/682007a2507f209c2378796442d4bc879516191a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **context:** tambah context::dedup untuk hasil tool yang berulang ([12a03fd](https://github.com/asepharyana/zesdex/commit/12a03fd3d1123c7a88289e44064537a71b9f574b))
|
||||
* **context:** tambah context::shaping (port dari shortsend) ([e080d9f](https://github.com/asepharyana/zesdex/commit/e080d9fc6b7daf2ab37fb242d03a3f64e8629acc))
|
||||
* **context:** tambah context::squash untuk kompresi hasil tool ([ea0b498](https://github.com/asepharyana/zesdex/commit/ea0b4988299894e4588d851ebc91704a9e73bc73))
|
||||
* **context:** tambah context::tokens dengan tiktoken-rs ([c219b6e](https://github.com/asepharyana/zesdex/commit/c219b6ec58ddad4770e491a48bbe1b6e3d0c8884))
|
||||
* **context:** tambah context::window::resolve ([059ca8e](https://github.com/asepharyana/zesdex/commit/059ca8ea246768dbec06ae5336433853e4276cec))
|
||||
* **runtime:** kompres hasil tool lewat squash sebelum masuk context ([96084f7](https://github.com/asepharyana/zesdex/commit/96084f74621636a72c209388580d0ebbb335d198))
|
||||
* **settings:** tambah mode ringkas opsional (concise_output) ([d6de973](https://github.com/asepharyana/zesdex/commit/d6de9735aba7745f5f57a8e7a5e625613c1a2569))
|
||||
|
||||
# [1.13.0](https://github.com/asepharyana/zesdex/compare/v1.12.0...v1.13.0) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct test assertion in dim_false_plain_text_has_no_color ([8d77e45](https://github.com/asepharyana/zesdex/commit/8d77e4565c2e3ca49058022c71e8277bdd790312))
|
||||
* Remove orphaned span_text helper function from markdown test module ([199028f](https://github.com/asepharyana/zesdex/commit/199028fa2eb6056dad2bdf0753554939c153a163))
|
||||
* **state:** cegah panic saat select mention dengan cursor stale ([dcfc5b9](https://github.com/asepharyana/zesdex/commit/dcfc5b9ec0d35f4c66b1648ded00f4f1279e0413))
|
||||
* **state:** jangan bangun mention index di mode attach ([e0d0860](https://github.com/asepharyana/zesdex/commit/e0d0860d7ba0ab6a4c9b9f1562f6a767a6424d99))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Deteksi trigger [@mention](https://github.com/mention) dan Tab-cycle di input handler ([2eebedf](https://github.com/asepharyana/zesdex/commit/2eebedfea56c7d4827f94a2739dbb5fa006e3069))
|
||||
* **ipc:** dukung Ctrl+Y clipboard copy di mode daemon/attach ([472162d](https://github.com/asepharyana/zesdex/commit/472162d135478666913a888a68867e635e50d14a))
|
||||
* Judul dropdown autocomplete mengikuti jenisnya (Commands vs Files) ([7f2509e](https://github.com/asepharyana/zesdex/commit/7f2509ecd2688495545333debee0c81016b28641))
|
||||
* **state:** Alirkan mention_index lewat ToolCtx dan AppStateRest, bangun index di background thread ([93be92a](https://github.com/asepharyana/zesdex/commit/93be92a5fb8644c9020478c49421585ade2d0fd1))
|
||||
* Tambah Ctrl+Y untuk menyalin pesan assistant terakhir ([d93bdba](https://github.com/asepharyana/zesdex/commit/d93bdba59b7dd14b4a76dc91c6b7f70a3c0f721c))
|
||||
* Tambah field pending_clipboard_copy di MiscState ([3b6abd9](https://github.com/asepharyana/zesdex/commit/3b6abd920ea92329ebcf888c3bb50470675c4152))
|
||||
* Tambah helper truncate_diff untuk membatasi panjang diff ([5dd835f](https://github.com/asepharyana/zesdex/commit/5dd835ff244a138bb5f1a8dbfd7cf252c2671e5e))
|
||||
* Tambah MentionIndex, AutocompleteKind, dan deteksi [@mention](https://github.com/mention) di InputState ([95cae8f](https://github.com/asepharyana/zesdex/commit/95cae8fd8a8ce737ba60c3ac8bea77f4c2e14b1a))
|
||||
* Tambah write_osc52 dan salin ke clipboard di mode single-process ([71b1613](https://github.com/asepharyana/zesdex/commit/71b1613d8467cfcb2383b3fce153a25c7883ac3d))
|
||||
* Tambahkan file baru ke mention_index saat tool write membuatnya ([930961b](https://github.com/asepharyana/zesdex/commit/930961bd85701ab8906b8d81095e46d3635a951d))
|
||||
* Tampilkan unified diff pada hasil tool edit ([c3c0ef6](https://github.com/asepharyana/zesdex/commit/c3c0ef632a605d8bf32712049035e4fc814af9d5))
|
||||
* Tampilkan unified diff saat tool write menimpa file yang sudah ada ([2bb8e6f](https://github.com/asepharyana/zesdex/commit/2bb8e6f2555eac01baf2311d41811afad4aa3041))
|
||||
* **view:** Tambah parameter dim dan pewarnaan baris diff di markdown renderer ([683715c](https://github.com/asepharyana/zesdex/commit/683715cd7af77d87a2ae3834360bb6f7a4388bd0))
|
||||
|
||||
# [1.12.0](https://github.com/asepharyana/zesdex/compare/v1.11.0...v1.12.0) (2026-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add mouse capture functionality to terminal and enhance markdown rendering with table support ([4428e8b](https://github.com/asepharyana/zesdex/commit/4428e8bc01c196415ac408a42f57305227a79760))
|
||||
* Improve markdown rendering with enhanced line wrapping and indentation for code blocks ([b2e848d](https://github.com/asepharyana/zesdex/commit/b2e848d124e726c4d8b644d473e518398fab1dea))
|
||||
|
||||
# [1.11.0](https://github.com/asepharyana/zesdex/compare/v1.10.0...v1.11.0) (2026-07-14)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
- **Error handling** — `anyhow::Result` and `anyhow::bail!` throughout. No custom error types.
|
||||
- **Static strings** — MCP tool descriptions use `Box::leak` + `OnceLock` cache.
|
||||
- **Tools** — `trait Tool { fn name() -> &str, fn run() -> Result<String> }`, 28 impls, gated by `Harness`.
|
||||
- **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands.
|
||||
- **Shell safety** — `tool/shell_filter/` blocks destructive git commands (`shell_filter::git::check_git_destructive`, called from `tool/shell.rs::Bash::run`). It also contains a `check_credential_read` detector for credential-file reads, but that one is intentionally NOT wired into `Bash::run` today — see the doc comment on `Bash::run` for why.
|
||||
|
||||
### Hive-Mind Orchestration (Machine Intelligence)
|
||||
|
||||
|
||||
Generated
+690
-17
File diff suppressed because it is too large
Load Diff
+67
-33
@@ -1,11 +1,25 @@
|
||||
[package]
|
||||
name = "zesdex"
|
||||
version = "1.11.0"
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"apps/domain",
|
||||
"apps/application",
|
||||
"apps/infrastructure",
|
||||
"apps/interfaces/tui",
|
||||
"apps/interfaces/api",
|
||||
"apps/interfaces/daemon",
|
||||
"apps/interfaces/ws",
|
||||
"apps/interfaces/grpc",
|
||||
"apps/interfaces/web",
|
||||
"apps/gateway",
|
||||
"apps/bootstrap",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.16.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
# Treat all warnings as errors, set strict clippy levels
|
||||
[lints.rust]
|
||||
[workspace.lints.rust]
|
||||
unused = "deny"
|
||||
dead_code = "deny"
|
||||
unreachable_code = "deny"
|
||||
@@ -17,46 +31,66 @@ 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 }
|
||||
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"
|
||||
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"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
[[bin]]
|
||||
name = "zesdex"
|
||||
path = "src/main.rs"
|
||||
# Clean-architecture workspace crate references
|
||||
zesdex-domain = { path = "apps/domain" }
|
||||
zesdex-application = { path = "apps/application" }
|
||||
zesdex-infrastructure = { path = "apps/infrastructure" }
|
||||
zesdex-tui = { path = "apps/interfaces/tui" }
|
||||
zesdex-api = { path = "apps/interfaces/api" }
|
||||
zesdex-daemon = { path = "apps/interfaces/daemon" }
|
||||
zesdex-ws = { path = "apps/interfaces/ws" }
|
||||
zesdex-grpc = { path = "apps/interfaces/grpc" }
|
||||
zesdex-web = { path = "apps/interfaces/web" }
|
||||
zesdex-gateway = { path = "apps/gateway" }
|
||||
zesdex-bootstrap = { path = "apps/bootstrap" }
|
||||
|
||||
+30
@@ -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"]
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "zesdex-application"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Application layer — port traits (interfaces), use cases, DTOs.
|
||||
# Depends ONLY on domain. Application services orchestrate domain objects
|
||||
# through port traits without knowing concrete implementations.
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Auth use-case implementations.
|
||||
//!
|
||||
//! Contains concrete service types that implement the domain's
|
||||
//! authentication and session management traits by coordinating
|
||||
//! injected repository and port dependencies.
|
||||
//!
|
||||
//! # Use Cases
|
||||
//!
|
||||
//! - [`oauth_service`] — `OAuthUseCase`: OAuth 2.0 authorization-code + PKCE flow
|
||||
//! - [`session_service`] — `SessionServiceImpl`: session CRUD lifecycle
|
||||
|
||||
pub mod oauth_service;
|
||||
pub mod session_service;
|
||||
|
||||
pub use oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger};
|
||||
pub use session_service::SessionServiceImpl;
|
||||
@@ -0,0 +1,250 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow use-case.
|
||||
//!
|
||||
//! `OAuthUseCase` orchestrates the standard PKCE-enhanced OAuth flow:
|
||||
//!
|
||||
//! 1. **`start_flow`** — generates a cryptographic PKCE code verifier,
|
||||
//! derives its S256 challenge, creates a CSRF state token, persists
|
||||
//! the verifier + state via `OAuthFlowStore`, and builds an
|
||||
//! authorization URL with all required parameters.
|
||||
//! 2. **`complete_flow`** — validates the returned `state` against the
|
||||
//! stored value (CSRF check), reads the stored verifier, delegates
|
||||
//! the token-code exchange to an injected `TokenExchanger`, and
|
||||
//! persists the resulting `OAuthToken` via `OAuthRepository`.
|
||||
//! 3. **`get_token`** — loads the stored OAuth token (if any).
|
||||
//!
|
||||
//! # Portability
|
||||
//!
|
||||
//! The service is generic over three injected dependencies:
|
||||
//! - `R: OAuthRepository` — token persistence
|
||||
//! - `S: OAuthFlowStore` — ephemeral flow state (verifier + CSRF state)
|
||||
//! - `E: TokenExchanger` — the HTTP token-endpoint exchange
|
||||
//!
|
||||
//! This keeps all I/O and protocol-level concerns abstracted behind
|
||||
//! port traits; the service itself contains only orchestration logic.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::auth::{OAuthConfig, OAuthRepository, OAuthToken, ServiceError};
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port traits (defined here because they are specific to this use-case)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Persistence contract for ephemeral OAuth flow state.
|
||||
///
|
||||
/// Between `start_flow` and `complete_flow` the verifier and CSRF state
|
||||
/// must survive across process boundaries (the user opens a browser, the
|
||||
/// provider redirects back to a loopback listener on the next invocation).
|
||||
///
|
||||
/// Implementors store key-value pairs to disk or another durable medium
|
||||
/// and clear them after a successful (or failed) flow completion.
|
||||
pub trait OAuthFlowStore: Send + Sync {
|
||||
/// Persist the PKCE code verifier and CSRF state token.
|
||||
fn save_flow_state(
|
||||
&self,
|
||||
verifier: &str,
|
||||
state: &str,
|
||||
) -> Result<(), ServiceError>;
|
||||
|
||||
/// Load the stored PKCE code verifier.
|
||||
fn load_verifier(&self) -> Result<String, ServiceError>;
|
||||
|
||||
/// Load the stored CSRF state token.
|
||||
fn load_state(&self) -> Result<String, ServiceError>;
|
||||
|
||||
/// Clear stored flow state (verifier + state).
|
||||
fn clear(&self) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Abstraction for exchanging an authorization code for tokens.
|
||||
///
|
||||
/// Implementors handle the HTTP POST to the provider's token endpoint
|
||||
/// with the appropriate form-encoded parameters, parse the JSON
|
||||
/// response, and return the extracted `OAuthToken`.
|
||||
pub trait TokenExchanger: Send + Sync {
|
||||
/// Exchange an authorization code for an access token.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `token_url` — the provider's token endpoint URL
|
||||
/// - `client_id` — OAuth client identifier
|
||||
/// - `client_secret` — optional client secret
|
||||
/// - `redirect_uri` — must match the URI used in `start_flow`
|
||||
/// - `code` — the authorization code from the provider's redirect
|
||||
/// - `code_verifier` — the PKCE verifier from `start_flow`
|
||||
fn exchange_code(
|
||||
&self,
|
||||
token_url: &str,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a PKCE code-verifier and its S256 code-challenge.
|
||||
///
|
||||
/// Uses 32 cryptographically random bytes, base64url-encoded (no padding)
|
||||
/// for the verifier, then SHA-256 hashes the verifier and base64url-encodes
|
||||
/// the digest for the challenge. This satisfies the PKCE `S256` method
|
||||
/// which requires a minimum verifier length of 43 characters.
|
||||
fn generate_pkce_pair() -> (String, String) {
|
||||
// 32 random bytes → 43 base64url chars (well above the 43-char PKCE
|
||||
// minimum).
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
|
||||
let verifier = URL_SAFE_NO_PAD.encode(&bytes);
|
||||
let challenge = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(hasher.finalize())
|
||||
};
|
||||
(verifier, challenge)
|
||||
}
|
||||
|
||||
/// Generate a random CSRF state token (UUID-based, 36 chars).
|
||||
fn generate_state_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Concrete OAuth flow use-case.
|
||||
///
|
||||
/// Generic over three dependencies:
|
||||
/// - `R` — token persistence (`OAuthRepository`)
|
||||
/// - `S` — flow-state persistence (`OAuthFlowStore`)
|
||||
/// - `E` — token-endpoint HTTP exchange (`TokenExchanger`)
|
||||
pub struct OAuthUseCase<R, S, E> {
|
||||
/// Repository for persisting / loading OAuth tokens.
|
||||
pub token_repo: R,
|
||||
/// Store for ephemeral flow state (verifier + CSRF state).
|
||||
pub flow_store: S,
|
||||
/// Token-endpoint HTTP exchanger.
|
||||
pub token_exchanger: E,
|
||||
/// File path for the token JSON file.
|
||||
pub token_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
|
||||
/// Create a new OAuth use-case.
|
||||
pub fn new(
|
||||
token_repo: R,
|
||||
flow_store: S,
|
||||
token_exchanger: E,
|
||||
token_path: PathBuf,
|
||||
) -> Self {
|
||||
OAuthUseCase {
|
||||
token_repo,
|
||||
flow_store,
|
||||
token_exchanger,
|
||||
token_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
|
||||
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E>
|
||||
{
|
||||
fn start_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(String, String), ServiceError> {
|
||||
if config.auth_url.is_empty() {
|
||||
return Err(ServiceError::InvalidConfig(
|
||||
"OAuth auth_url is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (verifier, challenge) = generate_pkce_pair();
|
||||
let state = generate_state_token();
|
||||
|
||||
// Persist verifier + state so `complete_flow` can retrieve them.
|
||||
self.flow_store.save_flow_state(&verifier, &state)?;
|
||||
|
||||
tracing::debug!(
|
||||
auth_url = %config.auth_url,
|
||||
redirect_uri = %redirect_uri,
|
||||
state_len = state.len(),
|
||||
"starting OAuth flow",
|
||||
);
|
||||
|
||||
let mut url = url::Url::parse(&config.auth_url)
|
||||
.map_err(|e| {
|
||||
ServiceError::InvalidConfig(format!(
|
||||
"invalid auth_url '{}': {e}",
|
||||
config.auth_url
|
||||
))
|
||||
})?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &config.client_id)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("scope", &config.scopes.join(" "))
|
||||
.append_pair("state", &state)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", &challenge);
|
||||
|
||||
Ok((url.to_string(), state))
|
||||
}
|
||||
|
||||
fn complete_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> Result<OAuthToken, ServiceError> {
|
||||
// CSRF check: validate the returned state against the stored value.
|
||||
let expected_state = self.flow_store.load_state()?;
|
||||
if expected_state != state {
|
||||
return Err(ServiceError::StateMismatch);
|
||||
}
|
||||
|
||||
// Read the PKCE verifier that was saved in `start_flow`.
|
||||
let verifier = self.flow_store.load_verifier()?;
|
||||
|
||||
tracing::debug!(
|
||||
token_url = %config.token_url,
|
||||
code_len = code.len(),
|
||||
"completing OAuth flow — exchanging code for token",
|
||||
);
|
||||
|
||||
// Delegate the HTTP token exchange to the injected exchanger.
|
||||
let token = self.token_exchanger.exchange_code(
|
||||
&config.token_url,
|
||||
&config.client_id,
|
||||
config.client_secret.as_deref(),
|
||||
redirect_uri,
|
||||
code,
|
||||
&verifier,
|
||||
)?;
|
||||
|
||||
// Persist the token and clean up flow state.
|
||||
self.token_repo.save_token(&self.token_path, &token)?;
|
||||
let _ = self.flow_store.clear();
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError> {
|
||||
self.token_repo
|
||||
.load_token(&self.token_path)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Session management use-case.
|
||||
//!
|
||||
//! `SessionServiceImpl` implements [`SessionService`] from the domain
|
||||
//! layer by delegating CRUD operations to injected repository traits.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! - **`create_session`** — generates a UUID v4 id, creates a `Session`
|
||||
//! entity with the given title, persists via `SessionRepository`.
|
||||
//! - **`list_all`** — delegates to `SessionRepository::list_sessions`.
|
||||
//! - **`archive_session`** — loads session, sets `archived = true`,
|
||||
//! persists the updated entity.
|
||||
//!
|
||||
//! # Generics
|
||||
//!
|
||||
//! - `R: SessionRepository` — session CRUD persistence
|
||||
//! - `L: SessionLockRepository` — session lock acquire/release
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
use zesdex_domain::auth::{
|
||||
ServiceError, Session, SessionId, SessionLockRepository, SessionRepository,
|
||||
};
|
||||
|
||||
/// Concrete session service backed by injected repository implementations.
|
||||
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
|
||||
/// Repository for session CRUD operations.
|
||||
pub session_repo: R,
|
||||
/// Repository for session lock acquire/release.
|
||||
pub lock_repo: L,
|
||||
/// Base data directory passed to repository methods.
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
|
||||
/// 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<R: SessionRepository, L: SessionLockRepository>
|
||||
zesdex_domain::auth::SessionService for SessionServiceImpl<R, L>
|
||||
{
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
||||
let id = SessionId::new(&Uuid::new_v4().to_string())
|
||||
.map_err(|e| ServiceError::Other(e))?;
|
||||
let title_owned = if title.is_empty() {
|
||||
"New Session".to_string()
|
||||
} else {
|
||||
title.to_string()
|
||||
};
|
||||
let session = Session::new(id.into_string(), title_owned);
|
||||
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
|
||||
self.session_repo
|
||||
.save_session(&self.base_dir, &session)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn list_all(&self) -> Result<Vec<Session>, ServiceError> {
|
||||
tracing::debug!("listing all sessions");
|
||||
self.session_repo
|
||||
.list_sessions(&self.base_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
|
||||
tracing::debug!(session_id = %id, "archiving session");
|
||||
let mut session = self
|
||||
.session_repo
|
||||
.load_session(&self.base_dir, &id)?;
|
||||
session.archived = true;
|
||||
let millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
|
||||
self.session_repo
|
||||
.save_session(&self.base_dir, &session)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Conversation use-case implementation.
|
||||
//!
|
||||
//! `ConversationServiceImpl` implements [`ConversationService`] from the
|
||||
//! domain layer. It is generic over `R: ConversationRepository`, delegating
|
||||
//! all persistence to that adapter.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method computes the session directory from the session ID, then
|
||||
//! delegates the actual I/O to the injected `repo`. Error context is
|
||||
//! added at this layer to identify which session caused the failure.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{Conversation, ConversationRepository, ServiceError};
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
/// Service implementation for conversation CRUD operations.
|
||||
///
|
||||
/// Generic over `R: ConversationRepository` so the persistence layer
|
||||
/// can be swapped without changing business logic.
|
||||
pub struct ConversationServiceImpl<R> {
|
||||
pub repo: R,
|
||||
/// Base directory containing session subdirectories.
|
||||
pub sessions_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
||||
/// Create a new service with the given repository and sessions directory.
|
||||
pub fn new(repo: R, sessions_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating ConversationServiceImpl");
|
||||
Self {
|
||||
repo,
|
||||
sessions_dir: sessions_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the session directory for a given session id.
|
||||
fn session_dir(&self, session_id: &str) -> PathBuf {
|
||||
self.sessions_dir.join(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
|
||||
for ConversationServiceImpl<R>
|
||||
{
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
|
||||
tracing::debug!("loading conversation for session {session_id}");
|
||||
let dir = self.session_dir(session_id);
|
||||
self.repo.load(&dir).map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving conversation for session {}", conv.session_id);
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo.save(&dir, conv)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_message(
|
||||
&self,
|
||||
conv: &mut Conversation,
|
||||
msg: ChatMessage,
|
||||
) -> Result<(), ServiceError> {
|
||||
tracing::debug!("adding message to session {}", conv.session_id);
|
||||
conv.push(msg);
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo.save(&dir, conv)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Memory use-case implementation.
|
||||
//!
|
||||
//! `MemoryServiceImpl` implements [`MemoryService`] from the domain
|
||||
//! layer. It is generic over `R: MemoryRepository`, delegating all
|
||||
//! persistence to that adapter.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method delegates to the injected `repo` with the configured
|
||||
//! `memory_dir`. Error context is added at this layer to identify which
|
||||
//! memory operation failed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{Memory, MemoryRepository, ServiceError};
|
||||
|
||||
/// Service implementation for memory CRUD operations.
|
||||
///
|
||||
/// Generic over `R: MemoryRepository` so the persistence layer can be
|
||||
/// swapped without changing business logic.
|
||||
pub struct MemoryServiceImpl<R> {
|
||||
pub repo: R,
|
||||
/// Base directory for memory storage files.
|
||||
pub memory_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> MemoryServiceImpl<R> {
|
||||
/// Create a new service with the given repository and memory directory.
|
||||
pub fn new(repo: R, memory_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating MemoryServiceImpl");
|
||||
Self {
|
||||
repo,
|
||||
memory_dir: memory_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> zesdex_domain::cms::MemoryService for MemoryServiceImpl<R> {
|
||||
fn list_memories(&self) -> Result<Vec<String>, ServiceError> {
|
||||
tracing::debug!("listing memories from {:?}", self.memory_dir);
|
||||
self.repo
|
||||
.list(&self.memory_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving memory '{}'", memory.name);
|
||||
self.repo.save(&self.memory_dir, memory)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_memory(&self, name: &str) -> Result<(), ServiceError> {
|
||||
tracing::debug!("deleting memory '{name}'");
|
||||
self.repo
|
||||
.delete(&self.memory_dir, name)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! CMS use-case implementations.
|
||||
//!
|
||||
//! Contains concrete service types that implement the domain's CMS
|
||||
//! service traits by coordinating injected repository dependencies.
|
||||
//!
|
||||
//! # Use Cases
|
||||
//!
|
||||
//! - [`conversation_service`] — `ConversationServiceImpl`: conversation CRUD
|
||||
//! - [`memory_service`] — `MemoryServiceImpl`: long-term memory management
|
||||
//! - [`settings_service`] — `SettingsServiceImpl`: settings & app-config management
|
||||
|
||||
pub mod conversation_service;
|
||||
pub mod memory_service;
|
||||
pub mod settings_service;
|
||||
|
||||
pub use conversation_service::ConversationServiceImpl;
|
||||
pub use memory_service::MemoryServiceImpl;
|
||||
pub use settings_service::SettingsServiceImpl;
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Settings and app-config use-case implementation.
|
||||
//!
|
||||
//! `SettingsServiceImpl` implements [`SettingsService`] from the domain
|
||||
//! layer. It is generic over `S: SettingsRepository` and `C: AppConfigRepository`,
|
||||
//! delegating persistence to those adapters.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method delegates to the appropriate injected repository with the
|
||||
//! configured `base_dir`. The `update_provider` method coordinates between
|
||||
//! both repositories: load app config → mutate provider map → save app config.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{
|
||||
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings,
|
||||
SettingsRepository,
|
||||
};
|
||||
|
||||
/// Service implementation for settings and app-config operations.
|
||||
///
|
||||
/// Generic over `S: SettingsRepository` and `C: AppConfigRepository` so
|
||||
/// the persistence layer can be swapped without changing business logic.
|
||||
pub struct SettingsServiceImpl<S, C> {
|
||||
pub settings_repo: S,
|
||||
pub app_config_repo: C,
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||
/// Create a new service with the given repositories and base directory.
|
||||
pub fn new(
|
||||
settings_repo: S,
|
||||
app_config_repo: C,
|
||||
base_dir: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
tracing::debug!("creating SettingsServiceImpl");
|
||||
Self {
|
||||
settings_repo,
|
||||
app_config_repo,
|
||||
base_dir: base_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository>
|
||||
zesdex_domain::cms::SettingsService for SettingsServiceImpl<S, C>
|
||||
{
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError> {
|
||||
tracing::debug!("loading settings");
|
||||
self.settings_repo
|
||||
.load(&self.base_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving settings");
|
||||
self.settings_repo.save(&self.base_dir, settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<(), ServiceError> {
|
||||
tracing::debug!("updating provider '{name}'");
|
||||
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
||||
app_config
|
||||
.providers
|
||||
.insert(name.to_string(), config.clone());
|
||||
self.app_config_repo.save(&self.base_dir, &app_config)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! # Zesdex Application Layer
|
||||
//!
|
||||
//! Defines port traits (interfaces) and use-case implementations for the
|
||||
//! Zesdex application. This crate depends **only** on the domain crate;
|
||||
//! it has no knowledge of infrastructure or interface adapters.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/application/src/
|
||||
//! ├── lib.rs — crate root, re-exports
|
||||
//! ├── ports/ — Port traits (interfaces to external services)
|
||||
//! │ ├── provider.rs -- ProviderService (LLM chat completion)
|
||||
//! │ ├── password.rs -- PasswordService (hash / verify)
|
||||
//! │ ├── token.rs -- TokenService (JWT create / verify)
|
||||
//! │ └── authentication.rs -- AuthService (combined auth)
|
||||
//! ├── auth/ — Auth use-cases
|
||||
//! │ ├── oauth_service.rs -- OAuth 2.0 PKCE flow
|
||||
//! │ └── session_service.rs -- Session CRUD lifecycle
|
||||
//! └── cms/ — CMS use-cases
|
||||
//! ├── conversation_service.rs -- Conversation CRUD
|
||||
//! ├── memory_service.rs -- Long-term memory management
|
||||
//! └── settings_service.rs -- Settings & app-config management
|
||||
//! ```
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//!
|
||||
//! Application services are generic over their repository/port dependencies.
|
||||
//! Concrete implementations are injected at the composition root, keeping
|
||||
//! the use-case logic independent of any specific persistence or infrastructure
|
||||
//! technology.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cms;
|
||||
pub mod ports;
|
||||
|
||||
// Re-export port traits for ergonomic access.
|
||||
pub use ports::*;
|
||||
|
||||
// Re-export auth use-cases.
|
||||
pub use auth::{
|
||||
oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger},
|
||||
session_service::SessionServiceImpl,
|
||||
};
|
||||
|
||||
// Re-export CMS use-cases.
|
||||
pub use cms::{
|
||||
conversation_service::ConversationServiceImpl,
|
||||
memory_service::MemoryServiceImpl,
|
||||
settings_service::SettingsServiceImpl,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
//! AuthService port — combined authentication operations.
|
||||
//!
|
||||
//! Defines a high-level authentication trait that composes password
|
||||
//! verification and token generation into a single use-case boundary.
|
||||
//! Implementations delegate to the injected `PasswordService` and
|
||||
//! `TokenService` adapters.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
/// High-level authentication service combining password verification
|
||||
/// and token issuance (login flow).
|
||||
///
|
||||
/// # Flow
|
||||
///
|
||||
/// 1. **`authenticate`** — verify a subject's password against a stored hash.
|
||||
/// 2. **`issue_tokens`** — generate an access + refresh token pair for a subject.
|
||||
///
|
||||
/// Implementations are generic over `PasswordService` and `TokenService`
|
||||
/// port traits.
|
||||
pub trait AuthService: Send + Sync {
|
||||
/// Authenticate a user by verifying a password against a stored hash.
|
||||
///
|
||||
/// Returns `true` if the password matches, `false` otherwise.
|
||||
fn authenticate(
|
||||
&self,
|
||||
password: &str,
|
||||
hash: &str,
|
||||
) -> impl Future<Output = Result<bool>> + Send;
|
||||
|
||||
/// Issue a new access + refresh token pair for the given subject.
|
||||
///
|
||||
/// Returns `(access_token, refresh_token)`.
|
||||
fn issue_tokens(&self, sub: &str) -> Result<(String, String)>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Port traits — interfaces for external / infrastructure services.
|
||||
//!
|
||||
//! These traits define the boundaries between the application layer and
|
||||
//! the outside world. Infrastructure adapters implement these traits;
|
||||
//! the application layer depends only on the trait definitions.
|
||||
//!
|
||||
//! # Ports
|
||||
//!
|
||||
//! - [`provider`] — `ProviderService`: LLM chat completion (streaming + non-streaming)
|
||||
//! - [`password`] — `PasswordService`: password hashing and verification
|
||||
//! - [`token`] — `TokenService`: JWT access/refresh token generation and verification
|
||||
//! - [`authentication`] — `AuthService`: combined authentication operations
|
||||
|
||||
pub mod authentication;
|
||||
pub mod password;
|
||||
pub mod provider;
|
||||
pub mod token;
|
||||
|
||||
pub use authentication::AuthService;
|
||||
pub use password::PasswordService;
|
||||
pub use provider::ProviderService;
|
||||
pub use token::TokenService;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! PasswordService port — password hashing and verification abstraction.
|
||||
//!
|
||||
//! Defines the trait that password-hashing adapters (argon2, bcrypt, etc.)
|
||||
//! implement. The application layer depends only on this trait, never on
|
||||
//! a concrete hashing library.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
/// Abstraction for password hashing and verification.
|
||||
///
|
||||
/// Implementors handle the actual hashing algorithm (argon2, bcrypt, etc.)
|
||||
/// and parameter selection. The trait is `Send + Sync` for use in async
|
||||
/// service layers.
|
||||
pub trait PasswordService: Send + Sync {
|
||||
/// Hash a plaintext password and return the encoded hash string
|
||||
/// (suitable for storage in a credential store).
|
||||
fn hash(&self, password: &str) -> impl Future<Output = Result<String>> + Send;
|
||||
|
||||
/// Verify a plaintext password against a previously-hashed string.
|
||||
///
|
||||
/// Returns `true` if the password matches the hash, `false` otherwise.
|
||||
fn verify(&self, password: &str, hash: &str) -> impl Future<Output = Result<bool>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! ProviderService port — LLM chat completion provider abstraction.
|
||||
//!
|
||||
//! Defines the trait that HTTP-based provider clients (OpenAI, Anthropic,
|
||||
//! etc.) implement. Supports both non-streaming and SSE-streaming chat
|
||||
//! completion requests.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. Caller builds a message list and optional tool definitions.
|
||||
//! 2. `chat` sends a non-streaming request and returns the full response.
|
||||
//! 3. `chat_stream` sends a streaming request and invokes `on_event` for
|
||||
//! each parsed `StreamEvent` as it arrives, then returns the assembled
|
||||
//! message and usage.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||
|
||||
/// Abstraction for an LLM provider chat-completion service.
|
||||
///
|
||||
/// Both methods accept a message list, optional tool definitions, and
|
||||
/// generation parameters. Implementors handle authentication, HTTP
|
||||
/// transport, retry logic, and response parsing internally.
|
||||
///
|
||||
/// # Send + Sync
|
||||
///
|
||||
/// This trait is `Send + Sync` so it can be shared across async tasks
|
||||
/// and injected into service structs that require thread safety.
|
||||
pub trait ProviderService: Send + Sync {
|
||||
/// Send a non-streaming chat completion request.
|
||||
///
|
||||
/// Returns the assistant's `ChatMessage` and optional token usage
|
||||
/// `(prompt_tokens, completion_tokens)`.
|
||||
fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
|
||||
|
||||
/// Send a streaming chat completion request.
|
||||
///
|
||||
/// `on_event` is called for every parsed SSE event and returns `false`
|
||||
/// to signal abort (caller cancellation). Returns the fully assembled
|
||||
/// assistant message and optional usage once the stream completes.
|
||||
fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
on_event: Box<dyn FnMut(&StreamEvent) -> bool + Send>,
|
||||
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! TokenService port — JWT access and refresh token abstraction.
|
||||
//!
|
||||
//! Defines the trait that JWT adapter implementations provide. Covers
|
||||
//! token generation (pair of access + refresh tokens) and access token
|
||||
//! verification (returns the subject claim).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Abstraction for JWT-based token generation and verification.
|
||||
///
|
||||
/// Implementors handle signing key management, token serialisation,
|
||||
/// and expiry validation. The trait is `Send + Sync` for use across
|
||||
/// thread boundaries.
|
||||
pub trait TokenService: Send + Sync {
|
||||
/// Generate an access + refresh token pair for the given subject
|
||||
/// identifier.
|
||||
///
|
||||
/// Returns `(access_token, refresh_token)`.
|
||||
fn generate_tokens(&self, sub: &str) -> Result<(String, String)>;
|
||||
|
||||
/// Verify an access token and return the embedded subject claim.
|
||||
///
|
||||
/// Returns `Err` if the token is expired, malformed, or has an
|
||||
/// invalid signature.
|
||||
fn verify_access_token(&self, token: &str) -> Result<String>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "zesdex-bootstrap"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Bootstrap binary — seeds initial system data (permissions, roles,
|
||||
# admin user) idempotently. Run once after first deployment.
|
||||
[[bin]]
|
||||
name = "bootstrap"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
zesdex-application = { path = "../application" }
|
||||
zesdex-infrastructure = { path = "../infrastructure" }
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
dirs.workspace = true
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Bootstrap library — shared utilities for the bootstrap binary.
|
||||
//! The main entry point is in `main.rs`.
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Bootstrap binary — seeds initial system data idempotently.
|
||||
//!
|
||||
//! Creates default permissions, roles, and admin user if they don't
|
||||
//! already exist. Run once after first deployment.
|
||||
//!
|
||||
//! Usage: cargo run --bin bootstrap
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
println!("Zesdex Bootstrap — seeding initial data...");
|
||||
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
// Seed default settings if not present
|
||||
let settings_path = store.base_dir.join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
let settings = zesdex_domain::cms::Settings::default();
|
||||
let content = serde_json::to_string_pretty(&settings)?;
|
||||
std::fs::write(&settings_path, content)?;
|
||||
println!(" ✓ Default settings created");
|
||||
} else {
|
||||
println!(" · Settings already exist, skipping");
|
||||
}
|
||||
|
||||
// Seed default app config if not present
|
||||
let config_path = store.base_dir.join("app_config.json");
|
||||
if !config_path.exists() {
|
||||
let config = zesdex_domain::cms::AppConfig::default();
|
||||
let content = serde_json::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_path, content)?;
|
||||
println!(" ✓ Default app_config created");
|
||||
} else {
|
||||
println!(" · App config already exists, skipping");
|
||||
}
|
||||
|
||||
println!("Bootstrap complete.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "zesdex-domain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Domain layer — PURE entities, value objects, repository/service traits.
|
||||
# Zero framework dependencies. Only serde for serialization, chrono for
|
||||
# timestamps, uuid for identity.
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
libc.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Command types for IAM domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / command pattern from clean architecture,
|
||||
//! these types encapsulate the input data for create/update operations
|
||||
//! on domain entities. They decouple presentation DTOs from the entity
|
||||
//! mutation surface and provide a clear boundary for validation.
|
||||
|
||||
/// Command to create a new session.
|
||||
///
|
||||
/// Carries only the data needed to construct a session entity — the
|
||||
/// service generates the UUID and timestamp internally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSession {
|
||||
/// Human-readable session title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl From<String> for NewSession {
|
||||
fn from(title: String) -> Self {
|
||||
Self { title }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for NewSession {
|
||||
fn from(title: &str) -> Self {
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Domain error types for the IAM (auth) module.
|
||||
//!
|
||||
//! Typed error enums replace `anyhow::Result` in domain traits and
|
||||
//! application services, enabling callers to match on specific error
|
||||
//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (config, state
|
||||
//! mismatch, provider failures)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for IAM persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the IAM domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided configuration is invalid.
|
||||
InvalidConfig(String),
|
||||
/// OAuth state mismatch — possible CSRF attack.
|
||||
StateMismatch,
|
||||
/// The OAuth provider returned an error.
|
||||
OAuthProvider(String),
|
||||
/// A generic error with a message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
ServiceError::Repository(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
|
||||
ServiceError::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"),
|
||||
ServiceError::StateMismatch => {
|
||||
write!(f, "OAuth state mismatch — possible CSRF attack")
|
||||
}
|
||||
ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {msg}"),
|
||||
ServiceError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ServiceError::Repository(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! IAM Session re-export.
|
||||
//!
|
||||
//! Re-exports `Session` from the auth module for consistent IAM-boundary
|
||||
//! imports. Consumers of the IAM module import `Session` from here rather
|
||||
//! than from the core session module directly, keeping the dependency
|
||||
//! internal and allowing the IAM crate to own its domain vocabulary.
|
||||
|
||||
pub use super::session::Session;
|
||||
|
||||
/// Alias for `Session` used in IAM contexts to distinguish from other
|
||||
/// session types in the system.
|
||||
pub type IamSession = Session;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Authentication domain entities, commands, errors, and repository/service traits.
|
||||
//!
|
||||
//! Combines the session types from `zesdex-entities` (auth sub-module) with the
|
||||
//! IAM domain types (commands, OAuth, repository/service traits) from `zesdex-iam`.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`session`] — `Session` entity (session metadata)
|
||||
//! - [`session_id`] — `SessionId` value object (validated newtype)
|
||||
//! - [`session_lock`] — `SessionLock` RAII guard (PID-file lock)
|
||||
//! - [`oauth`] — `OAuthToken`, `OAuthConfig` entities
|
||||
//! - [`iam_session`] — Re-export of `Session` for IAM-boundary consistency
|
||||
//! - [`commands`] — `NewSession` command type
|
||||
//! - [`error`] — `RepositoryError`, `ServiceError` types
|
||||
//! - [`repository`] — `SessionRepository`, `SessionLockRepository`, `OAuthRepository`
|
||||
//! - [`service`] — `SessionService`, `OAuthService` traits
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod iam_session;
|
||||
pub mod oauth;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
pub mod session_id;
|
||||
pub mod session_lock;
|
||||
|
||||
pub use commands::NewSession;
|
||||
pub use error::{RepositoryError, ServiceError};
|
||||
pub use iam_session::IamSession;
|
||||
pub use oauth::{OAuthConfig, OAuthToken};
|
||||
pub use repository::{OAuthRepository, SessionLockRepository, SessionRepository};
|
||||
pub use service::{OAuthService, SessionService};
|
||||
pub use session::Session;
|
||||
pub use session_id::SessionId;
|
||||
pub use session_lock::SessionLock;
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Pure OAuth entities — no HTTP or persistence logic.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`OAuthToken`] — access token with optional refresh token, epoch expiry
|
||||
//! - [`OAuthConfig`] — provider configuration (auth URL, token URL, client id,
|
||||
//! optional client secret, scopes)
|
||||
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 {
|
||||
/// The OAuth 2.0 access token string.
|
||||
pub access_token: String,
|
||||
/// Optional refresh token for long-lived access.
|
||||
pub refresh_token: Option<String>,
|
||||
/// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH).
|
||||
pub expires_at: u64,
|
||||
/// Token type, e.g. `"Bearer"`.
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
/// Authorization endpoint URL.
|
||||
pub auth_url: String,
|
||||
/// Token exchange endpoint URL.
|
||||
pub token_url: String,
|
||||
/// OAuth client identifier.
|
||||
pub client_id: String,
|
||||
/// Optional client secret (not all flows require it).
|
||||
pub client_secret: Option<String>,
|
||||
/// Space-separated list of requested scopes.
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Repository trait definitions (pure — no impls, no concrete persistence).
|
||||
//!
|
||||
//! Defines the repository contracts that infrastructure adapters implement.
|
||||
//! Following clean architecture, domain code depends only on these traits,
|
||||
//! not on concrete persistence libraries.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionRepository`] — CRUD for session metadata
|
||||
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
|
||||
//! - [`OAuthRepository`] — persist/load OAuth tokens
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::auth::error::RepositoryError;
|
||||
use crate::auth::oauth::OAuthToken;
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Repository for loading, saving, listing, and deleting sessions.
|
||||
pub trait SessionRepository {
|
||||
/// List all loadable sessions under `<base_dir>/sessions/`.
|
||||
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError>;
|
||||
|
||||
/// Load a single session by id.
|
||||
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError>;
|
||||
|
||||
/// Save a session's metadata to disk.
|
||||
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Delete a session directory and all its contents.
|
||||
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// 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) -> Result<bool, RepositoryError>;
|
||||
|
||||
/// Release the lock by removing the lock file.
|
||||
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError>;
|
||||
|
||||
/// 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) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Load an OAuth token from a JSON file, returning `None` if the file
|
||||
/// does not exist.
|
||||
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Service trait definitions — use-case interfaces for session management
|
||||
//! and OAuth flows.
|
||||
//!
|
||||
//! These traits define the boundary between the application orchestration
|
||||
//! layer and the domain. Implementations live in the application layer.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionService`] — create, list, archive sessions
|
||||
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
|
||||
|
||||
use crate::auth::error::ServiceError;
|
||||
use crate::auth::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Session management use-case boundary.
|
||||
pub trait SessionService {
|
||||
/// Create a new session with a generated UUID and the given title.
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
|
||||
|
||||
/// List all available sessions.
|
||||
fn list_all(&self) -> Result<Vec<Session>, ServiceError>;
|
||||
|
||||
/// Archive a session by id (sets `archived = true`).
|
||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// OAuth flow use-case boundary.
|
||||
pub trait OAuthService {
|
||||
/// Start an OAuth authorization-code + PKCE flow for the given
|
||||
/// `redirect_uri` (the caller is responsible for actually listening on
|
||||
/// it — e.g. a bound `LoopbackServer`). Returns `(auth_url, state)`:
|
||||
/// the URL to send the user to, and the CSRF state token that must be
|
||||
/// passed back into `complete_flow` unchanged.
|
||||
fn start_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(String, String), ServiceError>;
|
||||
|
||||
/// Complete the OAuth flow: validates `state` against the value
|
||||
/// persisted during `start_flow` (bailing on mismatch — this is the
|
||||
/// CSRF check), then exchanges `code` for a token using the same
|
||||
/// `redirect_uri` passed to `start_flow`.
|
||||
fn complete_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
|
||||
/// Retrieve the currently stored OAuth token (if any).
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||
//! persisted as `session.json` per session directory.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Created via [`Session::new`] → mutated in-memory → persisted via repository.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Session` struct — fields for all session metadata
|
||||
//! - `new` — timestamped constructor
|
||||
//! - `session_dir` / `conversation_path` — pure path computation
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata for one conversation session (distinct from the message
|
||||
/// history itself, which lives in `Conversation`/the msglog).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
/// Unique session identifier (validated against path traversal in `load`).
|
||||
pub id: String,
|
||||
/// Epoch-millis timestamp of creation (`Utc::now().timestamp_millis()`).
|
||||
pub created_at: i64,
|
||||
/// Epoch-millis timestamp of last update.
|
||||
pub updated_at: i64,
|
||||
/// Human-readable title for the conversation.
|
||||
pub title: String,
|
||||
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Workspace root directories associated with this session.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Running count of messages in the conversation.
|
||||
pub message_count: u32,
|
||||
/// Running count of tokens consumed.
|
||||
pub token_count: u32,
|
||||
/// Soft-delete flag — archived sessions are hidden from the default list.
|
||||
pub archived: bool,
|
||||
/// Optional AI-generated conversation summary (used for compact context).
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session with the given id/title, defaulting the
|
||||
/// model, workspace root (current dir), and counters.
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Session {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
/// Compute this session's `conversation.json` path.
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Validated session identifier newtype.
|
||||
//!
|
||||
//! [`SessionId`] wraps a `String` that has been checked for path-traversal
|
||||
//! characters. Construction via `SessionId::new(str)` validates the input
|
||||
//! once; the guarantee is then enforced by the type system for all
|
||||
//! downstream use.
|
||||
//!
|
||||
//! # Validation rules
|
||||
//!
|
||||
//! - Must not be empty
|
||||
//! - Must only contain alphanumeric characters, hyphens, and underscores
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A validated session identifier.
|
||||
///
|
||||
/// Guarantees the inner string is non-empty and contains no path-traversal
|
||||
/// characters (`/`, `\\`, `..`) or other unsafe delimiters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct SessionId(String);
|
||||
|
||||
impl SessionId {
|
||||
/// Validate and construct a `SessionId`.
|
||||
///
|
||||
/// Returns `Err(msg)` if the input contains path separators, `..`, or
|
||||
/// is empty.
|
||||
pub fn new(id: &str) -> Result<Self, String> {
|
||||
if id.is_empty() {
|
||||
return Err("session id must not be empty".to_string());
|
||||
}
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err(format!(
|
||||
"session id '{id}' must not contain path separators"
|
||||
));
|
||||
}
|
||||
Ok(SessionId(id.to_string()))
|
||||
}
|
||||
|
||||
/// Return the underlying string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Return the underlying owned string.
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Append this session id as a component of `base_dir`, yielding
|
||||
/// `base_dir / self.0`.
|
||||
///
|
||||
/// Safe because the id has been validated to contain no path separators.
|
||||
pub fn join_to(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for SessionId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionId> for String {
|
||||
fn from(sid: SessionId) -> Self {
|
||||
sid.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_uuids() {
|
||||
assert!(SessionId::new("550e8400-e29b-41d4-a716-446655440000").is_ok());
|
||||
assert!(SessionId::new("my-session_123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_path_traversal() {
|
||||
assert!(SessionId::new("../etc/passwd").is_err());
|
||||
assert!(SessionId::new("foo/../../bar").is_err());
|
||||
assert!(SessionId::new("foo\\..\\bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_empty() {
|
||||
assert!(SessionId::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_string() {
|
||||
let sid = SessionId::new("abc-123").unwrap();
|
||||
assert_eq!(sid.into_string(), "abc-123");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! the same session directory concurrently.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
|
||||
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
|
||||
//! owning PID is checked via liveness verification. Stale locks are
|
||||
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
|
||||
//! the lock file is removed automatically.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
|
||||
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
|
||||
//! - `unlock` / `Drop` — explicit and implicit release
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing;
|
||||
|
||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||
/// auto-removed on drop.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
/// Path to the `.lock` file inside the session directory.
|
||||
pub(crate) path: PathBuf,
|
||||
/// Process ID that holds (or will hold) this lock.
|
||||
pub(crate) pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
@@ -27,18 +43,12 @@ impl SessionLock {
|
||||
///
|
||||
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
||||
/// succeeds, the lock is ours — write our PID and return ok. If the
|
||||
/// file already exists, read the PID inside it and check `is_alive`:
|
||||
/// if that process is still running, fail to acquire; otherwise the
|
||||
/// lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Why: `create_new(true)` is atomic on POSIX (unlike the previous
|
||||
/// read-then-write pattern which had a TOCTOU race between checking
|
||||
/// `path.exists()` and writing). The stale-lock recovery path reads
|
||||
/// the stale PID and verifies liveness via `kill(pid, 0)`.
|
||||
/// file already exists, read the PID inside it and check whether that
|
||||
/// PID is still alive: if the process is still running, fail to acquire;
|
||||
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
#[allow(clippy::suspicious_open_options)]
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
||||
match fs::OpenOptions::new()
|
||||
@@ -49,9 +59,11 @@ impl SessionLock {
|
||||
Ok(mut file) => {
|
||||
write!(file, "{}", self.pid)?;
|
||||
file.sync_all()?;
|
||||
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
|
||||
return Ok(true);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness");
|
||||
// Lock file exists — check if it's stale.
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
@@ -60,9 +72,11 @@ impl SessionLock {
|
||||
// Phase 2: lock file exists — check liveness of the owning process.
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if self.is_alive(pid) {
|
||||
if Self::is_alive(pid) {
|
||||
tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process");
|
||||
return Ok(false);
|
||||
}
|
||||
tracing::debug!(stale = pid, "stale lock detected, overwriting");
|
||||
}
|
||||
|
||||
// Phase 3: stale lock — overwrite it atomically (best-effort).
|
||||
@@ -71,6 +85,7 @@ impl SessionLock {
|
||||
{
|
||||
let mut tmp_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
write!(tmp_file, "{}", self.pid)?;
|
||||
@@ -89,29 +104,44 @@ impl SessionLock {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive and
|
||||
/// is actually a zesdex process (not a recycled PID from a different
|
||||
/// program).
|
||||
#[allow(clippy::unused_self)]
|
||||
fn is_alive(&self, pid: u32) -> bool {
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal
|
||||
// it. The integer argument is a PID already validated by `try_lock`.
|
||||
if unsafe { libc::kill(pid as i32, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
// Extra check: verify the PID belongs to a zesdex process via
|
||||
// /proc/<pid>/exe to mitigate the PID-reuse race (a recycled PID
|
||||
// from a different program would answer kill but shouldn't hold
|
||||
// our lock). This is best-effort — /proc may not be available
|
||||
// on all platforms.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) { if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
/// Check whether a process with the given PID is currently alive.
|
||||
///
|
||||
/// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production;
|
||||
/// here we provide a best-effort check using the process table.
|
||||
/// On non-Unix platforms this always returns `true` (conservative).
|
||||
fn is_alive(pid: u32) -> bool {
|
||||
// On Unix, signal 0 checks process existence without sending a signal.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal it.
|
||||
// The integer argument is a PID validated by `try_lock`.
|
||||
let pid_signed: i32 = match pid.try_into() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
} else { /* cannot resolve own exe, trust kill check */ } } else { /* /proc unavailable, trust kill check */ }
|
||||
true
|
||||
// Extra check: verify the PID belongs to a zesdex process via
|
||||
// /proc/<pid>/exe to mitigate the PID-reuse race.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Fallback: always assume alive (conservative).
|
||||
let _ = pid;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Pure domain entity for application configuration.
|
||||
//!
|
||||
//! Defines `AppConfig`, `ProviderConfig`, and `ModelRole` — the data
|
||||
//! structures that describe which LLM providers are registered, which
|
||||
//! model roles exist, and which provider/model is the default.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! These are pure data structures with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `AppConfigRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. `AppConfig` is deserialised from `app_config.json` at startup
|
||||
//! 2. The HTTP handler layer calls `SettingsService::update_provider()`
|
||||
//! to mutate the provider map
|
||||
//! 3. The modified `AppConfig` is serialised back to `app_config.json`
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level application configuration.
|
||||
///
|
||||
/// Holds the registry of configured LLM providers, named model roles
|
||||
/// (logical profiles mapping to a provider+model pair), and the default
|
||||
/// provider/model selection.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `providers` — map of provider name → connection details
|
||||
/// - `model_roles` — map of role name → provider/model/temperature
|
||||
/// - `default_provider` — the provider to use when none is specified
|
||||
/// - `default_model` — the model to use when none is specified
|
||||
/// - `default_context_window` — fallback context window size in tokens
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
pub model_roles: HashMap<String, ModelRole>,
|
||||
pub default_provider: String,
|
||||
pub default_model: String,
|
||||
pub default_context_window: u32,
|
||||
}
|
||||
|
||||
/// Connection details for a single LLM provider endpoint.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `api_base` — base URL for the provider API
|
||||
/// - `api_key_env` — optional environment variable name holding the API key
|
||||
/// - `default_model` — optional default model name for this provider
|
||||
/// - `default_api_key` — optional inline API key (less secure than env var)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
pub api_key_env: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A named model role mapping to a specific provider/model with parameters.
|
||||
///
|
||||
/// Roles allow the UI to present logical profiles (e.g. "fast", "reasoning")
|
||||
/// that abstract over concrete provider+model strings.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `provider` — which provider serves this role
|
||||
/// - `model` — which model to use for this role
|
||||
/// - `max_tokens` — optional maximum output token limit
|
||||
/// - `context_window` — optional context window override
|
||||
/// - `temperature` — optional generation temperature
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub context_window: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Returns the default AppConfig with built-in "zen" and "router" providers.
|
||||
impl Default for AppConfig {
|
||||
/// Construct an AppConfig with the default "zen" and "router" providers.
|
||||
///
|
||||
/// ## Defaults
|
||||
/// - Zen provider: `deepseek-v4-flash-free` model
|
||||
/// - Router provider: `claude-opus-4-8` model
|
||||
/// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7
|
||||
/// - `default_context_window`: 256,000 tokens
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Command types for CMS domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture,
|
||||
//! these types encapsulate the input data for create/update operations
|
||||
//! on domain entities. They decouple presentation DTOs from the entity
|
||||
//! mutation surface and provide a clear boundary for validation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::settings::InternetMode;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Partial update command for `Settings`.
|
||||
///
|
||||
/// Every field is `Option`al — only non-`None` fields are applied to the
|
||||
/// existing settings instance. Use `apply_to()` to merge into a `Settings`
|
||||
/// value.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SettingsPatch {
|
||||
/// Override the internet access mode.
|
||||
pub internet_mode: Option<String>,
|
||||
/// Override the active provider name.
|
||||
pub provider: Option<String>,
|
||||
/// Override the active model name.
|
||||
pub model: Option<String>,
|
||||
/// Replace the entire API-keys map.
|
||||
pub api_keys: Option<HashMap<String, String>>,
|
||||
/// Override the max tokens for completions.
|
||||
pub max_tokens: Option<Option<u32>>,
|
||||
/// Override the temperature for completions.
|
||||
pub temperature: Option<Option<f32>>,
|
||||
/// Override the review max lessons per run.
|
||||
pub review_max_lessons_per_run: Option<usize>,
|
||||
/// Override the adaptive review max skip count.
|
||||
pub adaptive_review_max_skip: Option<u32>,
|
||||
/// Override the verify shell command.
|
||||
pub verify_command: Option<Option<String>>,
|
||||
/// Override the verify timeout in milliseconds.
|
||||
pub verify_timeout_ms: Option<u64>,
|
||||
/// Override the max concurrency for workflow execution.
|
||||
pub workflow_max_concurrency: Option<usize>,
|
||||
/// Override the review-enabled flag.
|
||||
pub review_enabled: Option<bool>,
|
||||
/// Override the session-archive-enabled flag.
|
||||
pub session_archive_enabled: Option<bool>,
|
||||
/// Override the LSP auto-provision flag.
|
||||
pub lsp_auto_provision: Option<bool>,
|
||||
/// Override the list of LSP-managed languages.
|
||||
pub lsp_languages: Option<Vec<String>>,
|
||||
/// Override the hive-mind node timeout in milliseconds.
|
||||
pub hive_mind_node_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl SettingsPatch {
|
||||
/// Merge this patch into `settings`, overwriting each non-`None` field.
|
||||
///
|
||||
/// Flow: for each optional field, if `Some`, assign it to the target.
|
||||
///
|
||||
/// ## Errors
|
||||
/// Returns `Err` with a message if `internet_mode` is set to an
|
||||
/// unrecognised value.
|
||||
pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> {
|
||||
if let Some(ref val) = self.internet_mode {
|
||||
settings.internet_mode = match val.as_str() {
|
||||
"Off" => InternetMode::Off,
|
||||
"ReadOnly" => InternetMode::ReadOnly,
|
||||
"Full" => InternetMode::Full,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"invalid internet_mode '{val}'; expected Off, ReadOnly, or Full"
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(ref val) = self.provider {
|
||||
settings.provider = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.model {
|
||||
settings.model = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.api_keys {
|
||||
settings.api_keys = val.clone();
|
||||
}
|
||||
if let Some(val) = self.max_tokens {
|
||||
settings.max_tokens = val;
|
||||
}
|
||||
if let Some(val) = self.temperature {
|
||||
settings.temperature = val;
|
||||
}
|
||||
if let Some(val) = self.review_max_lessons_per_run {
|
||||
settings.review_max_lessons_per_run = val;
|
||||
}
|
||||
if let Some(val) = self.adaptive_review_max_skip {
|
||||
settings.adaptive_review_max_skip = val;
|
||||
}
|
||||
if let Some(ref val) = self.verify_command {
|
||||
settings.verify_command = val.clone();
|
||||
}
|
||||
if let Some(val) = self.verify_timeout_ms {
|
||||
settings.verify_timeout_ms = val;
|
||||
}
|
||||
if let Some(val) = self.workflow_max_concurrency {
|
||||
settings.workflow_max_concurrency = val;
|
||||
}
|
||||
if let Some(val) = self.review_enabled {
|
||||
settings.flags.review_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.session_archive_enabled {
|
||||
settings.flags.session_archive_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.lsp_auto_provision {
|
||||
settings.flags.lsp_auto_provision = val;
|
||||
}
|
||||
if let Some(ref val) = self.lsp_languages {
|
||||
settings.lsp_languages = val.clone();
|
||||
}
|
||||
if let Some(val) = self.hive_mind_node_timeout_ms {
|
||||
settings.hive_mind_node_timeout_ms = val;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command to create a new memory entry.
|
||||
///
|
||||
/// All required fields are non-optional; optional fields use `Option`
|
||||
/// and default to sensible values (empty or the service default).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewMemory {
|
||||
/// Unique name / slug for the memory.
|
||||
pub name: String,
|
||||
/// One-line summary of what the memory captures.
|
||||
pub description: String,
|
||||
/// The full memory content.
|
||||
pub content: String,
|
||||
/// Category kind (defaults to "reference" in the handler).
|
||||
pub kind: Option<String>,
|
||||
/// Outcome of the remembered action.
|
||||
pub outcome: Option<String>,
|
||||
/// Lifecycle stage (defaults to "new" in the handler).
|
||||
pub lifecycle: Option<String>,
|
||||
/// Scope context for the memory.
|
||||
pub scope: Option<String>,
|
||||
/// Code snippet captured before the action.
|
||||
pub before_snippet: Option<String>,
|
||||
/// Code snippet captured after the action.
|
||||
pub after_snippet: Option<String>,
|
||||
/// Source provenances (files, conversations, etc.).
|
||||
pub provenances: Option<Vec<String>>,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Pure domain entity for conversations and chat messages.
|
||||
//!
|
||||
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
|
||||
//! types from the core module to provide a consistent domain import
|
||||
//! boundary within the CMS module. All CMS code references conversation
|
||||
//! types through this module rather than depending on the core module
|
||||
//! directly.
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//! - `Conversation` — top-level conversation container with message list
|
||||
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
||||
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
||||
|
||||
pub use crate::core::message::{ChatMessage, Role};
|
||||
pub use crate::core::conversation::Conversation;
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Pure domain entity for the edit log — an append-only log of file mutations.
|
||||
//!
|
||||
//! Records every file mutation made by any tool, enabling audit trails
|
||||
//! and potential undo operations. Each entry captures the tool name,
|
||||
//! target path, reason, content hash, and byte delta.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `EditLogRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. Tools call `EditLog::push()` to record each mutation
|
||||
//! 2. The in-memory `EditLog` is periodically flushed to disk by the repo
|
||||
//! 3. Oldest entries are evicted from the in-memory cache when
|
||||
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single recorded file edit event.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `ts` — Unix timestamp (seconds) when the edit occurred
|
||||
/// - `tool` — name of the tool that performed the edit (e.g. "Bash", "Edit")
|
||||
/// - `path` — absolute file path that was modified
|
||||
/// - `reason` — human-readable explanation of why the edit was made
|
||||
/// - `content_sha256` — SHA-256 hex digest of the content *after* the edit
|
||||
/// - `bytes_delta` — signed byte count change (+added, -removed)
|
||||
/// - `origin` — origin identifier (which agent / session context)
|
||||
/// - `session_id` — session in which this edit was performed
|
||||
#[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-running sessions.
|
||||
pub const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||
|
||||
/// In-memory view of a session's edit log.
|
||||
///
|
||||
/// Wraps a `Vec<EditLogEntry>` and provides basic query helpers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
/// Ordered list of edit entries (newest appended last).
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Create an empty edit log with no entries.
|
||||
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 contains no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditLog {
|
||||
/// Returns an empty `EditLog` via `EditLog::new()`.
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Domain error types for the CMS module.
|
||||
//!
|
||||
//! Typed error enums for repository and service operations.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (invalid input, generic)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for CMS persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the CMS domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided input is invalid.
|
||||
InvalidInput(String),
|
||||
/// A generic error with a message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
ServiceError::Repository(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
|
||||
ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
|
||||
ServiceError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ServiceError::Repository(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Pure domain entity for long-term agent memory.
|
||||
//!
|
||||
//! A `Memory` entry stores a named, kinded piece of information (lesson,
|
||||
//! reference, fact) with frontmatter metadata and free-form markdown
|
||||
//! content. Memories are persisted as individual `.md` files with YAML
|
||||
//! frontmatter.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `MemoryRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Utility Functions
|
||||
//! - `slugify()` — converts a name string into a filesystem-safe slug
|
||||
//! - `path()` — computes the on-disk path for a given memory name
|
||||
//!
|
||||
//! Both are pure computations that take parameters and perform no I/O.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single memory entry with frontmatter metadata and markdown content.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `name` — unique identifier / title for this memory
|
||||
/// - `description` — short summary of what this memory contains
|
||||
/// - `content` — free-form markdown body
|
||||
/// - `kind` — category/tag (e.g. "lesson", "reference", "fact")
|
||||
/// - `created_at` — Unix timestamp of creation
|
||||
/// - `updated_at` — Unix timestamp of last modification
|
||||
/// - `outcome` — optional outcome of applying this memory
|
||||
/// - `lifecycle` — lifecycle stage (e.g. "active", "archived")
|
||||
/// - `scope` — optional scope qualifier (which session/context this applies to)
|
||||
/// - `before_snippet` — optional context snapshot before memory was applied
|
||||
/// - `after_snippet` — optional context snapshot after memory was applied
|
||||
/// - `provenances` — list of origin identifiers that created or confirmed this memory
|
||||
#[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<String>,
|
||||
pub lifecycle: String,
|
||||
pub scope: Option<String>,
|
||||
pub before_snippet: Option<String>,
|
||||
pub after_snippet: Option<String>,
|
||||
pub provenances: Vec<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
// Phase 1: replace every non-alphanumeric character with '-'
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
// Phase 2: collapse consecutive '-' separators
|
||||
let slug: String = slug
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() || slug.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(slug)
|
||||
}
|
||||
|
||||
/// Compute the on-disk path for a memory of the given name.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `memory_dir` — the base directory for memory storage
|
||||
/// - `name` — the memory name (will be slugified internally)
|
||||
///
|
||||
/// Falls back to `"memory.md"` when the name slugifies to an empty
|
||||
/// or invalid string.
|
||||
///
|
||||
/// ## Pure Computation
|
||||
/// This function performs **no I/O** — it only computes a path.
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Domain layer for CMS — pure entities, value objects, repository traits,
|
||||
//! and service interfaces.
|
||||
//!
|
||||
//! This is the innermost layer of the Clean Architecture onion. It has **zero
|
||||
//! infrastructure dependencies** — all I/O is expressed through repository
|
||||
//! traits defined in [`repository`], and business operations through service
|
||||
//! traits in [`service`].
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`)
|
||||
//! - `conversation` — conversation entity + chat message model (re-exported from core)
|
||||
//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`)
|
||||
//! - `memory` — memory file model (`Memory`)
|
||||
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
|
||||
//! - `repository` — trait definitions for all persistence adapters
|
||||
//! - `service` — trait definitions for all application services
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//! Domain types are plain Rust structs with `serde` for serialisation.
|
||||
//! They contain no I/O, no framework imports, and no side effects.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod commands;
|
||||
pub mod conversation;
|
||||
pub mod edit_log;
|
||||
pub mod error;
|
||||
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 error::{RepositoryError, ServiceError};
|
||||
pub use memory::Memory;
|
||||
pub use repository::AppConfigRepository;
|
||||
pub use repository::ConversationRepository;
|
||||
pub use repository::EditLogRepository;
|
||||
pub use repository::MemoryRepository;
|
||||
pub use repository::RewindBlobRepository;
|
||||
pub use repository::SettingsRepository;
|
||||
pub use service::ConversationService;
|
||||
pub use service::MemoryService;
|
||||
pub use service::SettingsService;
|
||||
pub use commands::{NewMemory, SettingsPatch};
|
||||
pub use settings::InternetMode;
|
||||
pub use settings::Settings;
|
||||
pub use settings::SettingsFlags;
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsRepository` — load/save `Settings` from/to a base directory
|
||||
//! - `AppConfigRepository` — load/save `AppConfig` from/to a base directory
|
||||
//! - `ConversationRepository` — load/save `Conversation` from/to a session directory
|
||||
//! - `MemoryRepository` — list/load/save/delete `Memory` entries
|
||||
//! - `RewindBlobRepository` — store/retrieve/list binary blobs per session
|
||||
//! - `EditLogRepository` — open/append/query edit log entries per session
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application services accept these traits as generic type parameters,
|
||||
//! allowing the composition root to inject concrete implementations
|
||||
//! (file-based, SQLite-backed, etc.) without changing business logic.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::app_config::AppConfig;
|
||||
use super::conversation::Conversation;
|
||||
use super::edit_log::{EditLog, EditLogEntry};
|
||||
use super::error::RepositoryError;
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Persistence contract for `Settings` (application settings model).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait SettingsRepository {
|
||||
/// Load `Settings` from the given base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError>;
|
||||
|
||||
/// Persist `Settings` to the given base directory.
|
||||
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `AppConfig` (provider and model configuration).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait AppConfigRepository {
|
||||
/// Load `AppConfig` from the given base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError>;
|
||||
|
||||
/// Persist `AppConfig` to the given base directory.
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Conversation` (session conversation data).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait ConversationRepository {
|
||||
/// Load a `Conversation` from the given session directory.
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
||||
|
||||
/// Persist a `Conversation` to the given session directory.
|
||||
fn save(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
conversation: &Conversation,
|
||||
) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Memory` (long-term agent memory entries).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. per-memory markdown files).
|
||||
pub trait MemoryRepository {
|
||||
/// List all memory slugs (filenames without extension) in the memory directory.
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
|
||||
/// Load a single `Memory` by name from the memory directory.
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError>;
|
||||
|
||||
/// Save (create or overwrite) a `Memory` in the memory directory.
|
||||
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Delete a `Memory` by name from the memory directory.
|
||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for rewind-snapshot binary blobs.
|
||||
///
|
||||
/// Blobs are keyed by an arbitrary caller-supplied key (e.g. a tool-call ID)
|
||||
/// within a session. They capture file snapshots for the "rewind" feature.
|
||||
pub trait RewindBlobRepository {
|
||||
/// Store (or overwrite) a binary blob under `blob_key` for this session.
|
||||
fn store_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
data: &[u8],
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Retrieve a blob's raw bytes by key, or `None` if not found.
|
||||
fn retrieve_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>, RepositoryError>;
|
||||
|
||||
/// List all blob keys for this session, ordered oldest-first.
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `EditLog` (append-only file mutation log).
|
||||
///
|
||||
/// Implementors manage an append-only log of `EditLogEntry` items per session,
|
||||
/// typically persisted to a file for audit and potential undo.
|
||||
pub trait EditLogRepository {
|
||||
/// Open (or initialise) the edit log for a session directory.
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
|
||||
|
||||
/// Append one entry to the log and persist immediately (write-through).
|
||||
fn append(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
log: &mut EditLog,
|
||||
entry: EditLogEntry,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Return a cloned copy of all in-memory entries for inspection.
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Service trait definitions — use-case boundaries for CMS operations.
|
||||
//!
|
||||
//! These traits define the public API of the application use-cases.
|
||||
//! They are implemented by concrete types in the `application` layer
|
||||
//! and consumed by infrastructure adapters (HTTP handlers, CLI commands).
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsService` — load/save settings, update provider config
|
||||
//! - `ConversationService` — load/save conversations, add messages
|
||||
//! - `MemoryService` — list/save/delete session memories
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application service implementations accept repository traits as generic
|
||||
//! type parameters. Infrastructure adapters depend only on these service
|
||||
//! traits, never on concrete implementations.
|
||||
|
||||
use super::conversation::{ChatMessage, Conversation};
|
||||
use super::error::ServiceError;
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Use-cases for application settings.
|
||||
pub trait SettingsService {
|
||||
/// Load the current `Settings` from the default store location.
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError>;
|
||||
|
||||
/// Persist updated `Settings` to the default store location.
|
||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
|
||||
|
||||
/// Update (or insert) a provider configuration entry.
|
||||
fn update_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
config: &super::app_config::ProviderConfig,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for conversation (session message) management.
|
||||
pub trait ConversationService {
|
||||
/// Load a `Conversation` for the given session ID.
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError>;
|
||||
|
||||
/// Persist a `Conversation` to its session storage.
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
||||
|
||||
/// Append a single `ChatMessage` to the conversation and persist.
|
||||
fn add_message(
|
||||
&self,
|
||||
conv: &mut Conversation,
|
||||
msg: ChatMessage,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for long-term memory management.
|
||||
pub trait MemoryService {
|
||||
/// List all memory slugs (filenames without extension).
|
||||
fn list_memories(&self) -> Result<Vec<String>, ServiceError>;
|
||||
|
||||
/// Save (create or overwrite) a `Memory`.
|
||||
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError>;
|
||||
|
||||
/// Delete a `Memory` by its slug/name.
|
||||
fn delete_memory(&self, name: &str) -> Result<(), ServiceError>;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Pure domain entity for application settings.
|
||||
//!
|
||||
//! Defines `Settings` (top-level user configuration), `SettingsFlags`
|
||||
//! (grouped boolean toggles), and `InternetMode` (network access level).
|
||||
//! Serialised to `settings.json` by the infrastructure layer.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `SettingsRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Settings Fields
|
||||
//! - `internet_mode` — network access policy (Off / ReadOnly / Full)
|
||||
//! - `provider` / `model` — default LLM provider and model name
|
||||
//! - `api_keys` — per-provider API key overrides (name → key)
|
||||
//! - `max_tokens` / `temperature` — generation parameter defaults
|
||||
//! - `review_max_lessons_per_run` — max lessons per auto-review pass
|
||||
//! - `verify_command` — optional shell command to run for verification
|
||||
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
|
||||
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
|
||||
//! - `flags` — grouped boolean feature toggles
|
||||
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls how much network access the agent is permitted during a session.
|
||||
///
|
||||
/// ## Variants
|
||||
/// - `Off` — no network access
|
||||
/// - `ReadOnly` — HTTP GET / HEAD only
|
||||
/// - `Full` — any HTTP method permitted
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum InternetMode {
|
||||
/// No network access permitted.
|
||||
#[default]
|
||||
Off,
|
||||
/// HTTP GET / HEAD requests only.
|
||||
ReadOnly,
|
||||
/// Any HTTP method permitted.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Grouped boolean feature toggles for the application.
|
||||
///
|
||||
/// Kept as a separate struct to avoid clippy's
|
||||
/// `default-too-many-fields` threshold on `Settings`.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `review_enabled` — enable automatic inline review after edits
|
||||
/// - `session_archive_enabled` — enable periodic session archiving
|
||||
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
|
||||
#[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 {
|
||||
/// Returns the default flags with all features enabled.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
review_enabled: true,
|
||||
session_archive_enabled: true,
|
||||
lsp_auto_provision: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default hive-mind node timeout (600 seconds).
|
||||
fn default_hive_mind_node_timeout_ms() -> u64 {
|
||||
600_000
|
||||
}
|
||||
|
||||
/// Top-level application settings model.
|
||||
///
|
||||
/// Serialised to `settings.json` by the infrastructure persistence layer.
|
||||
/// Holds LLM provider selection, generation parameters, feature flags,
|
||||
/// and workflow configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_keys: HashMap<String, String>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub verify_command: Option<String>,
|
||||
pub verify_timeout_ms: u64,
|
||||
pub workflow_max_concurrency: usize,
|
||||
#[serde(flatten)]
|
||||
pub flags: SettingsFlags,
|
||||
pub lsp_languages: Vec<String>,
|
||||
#[serde(default = "default_hive_mind_node_timeout_ms")]
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
//! In-memory conversation state: message history plus the system prompt and
|
||||
//! model parameters used to drive the LLM.
|
||||
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`Conversation::new`] → [`push`](Conversation::push) to add messages →
|
||||
//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM
|
||||
//! API (system prompt prepended).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Conversation` — message vector + session metadata + generation params
|
||||
//! - `push` / `rebuild_system` — mutation helpers
|
||||
//! - `to_api_messages` — formats messages for API consumption
|
||||
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<crate::dto::chat::message::ChatMessage>,
|
||||
/// Ordered list of chat messages (user, assistant, tool, system).
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// System prompt prepended at request time (see `to_api_messages`).
|
||||
pub system_prompt: String,
|
||||
/// Foreign key referencing the owning session.
|
||||
pub session_id: String,
|
||||
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Optional cap on output tokens.
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Optional temperature (0.0 – 2.0).
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
@@ -29,7 +48,7 @@ impl Conversation {
|
||||
}
|
||||
|
||||
/// Append a message to the conversation history.
|
||||
pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
pub fn push(&mut self, msg: ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
@@ -41,9 +60,7 @@ impl Conversation {
|
||||
/// 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)
|
||||
});
|
||||
self.messages.retain(|m| !matches!(m.role, Role::System));
|
||||
}
|
||||
|
||||
/// Build the message list to send to the LLM API, with the system
|
||||
@@ -51,9 +68,9 @@ impl Conversation {
|
||||
///
|
||||
/// Return: a new `Vec` (clone of history) with a synthesized system
|
||||
/// message at index 0.
|
||||
pub fn to_api_messages(&self) -> Vec<crate::dto::chat::message::ChatMessage> {
|
||||
pub fn to_api_messages(&self) -> Vec<ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt));
|
||||
msgs.push(ChatMessage::system(&self.system_prompt));
|
||||
msgs.extend(self.messages.iter().cloned());
|
||||
msgs
|
||||
}
|
||||
@@ -63,4 +80,9 @@ impl Conversation {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
//! Chat message types shared across the DTO layer: `Role` and `ChatMessage`
|
||||
//! with convenience constructors.
|
||||
|
||||
//! Chat message types shared across the entity layer.
|
||||
//!
|
||||
//! Provides [`Role`] (conversation participant) and [`ChatMessage`] (a single
|
||||
//! message with optional tool-call metadata). Includes convenience constructors
|
||||
//! for each role: `user`, `assistant`, `system`, `tool`/`tool_result`.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Messages are constructed via the typed constructors → pushed into
|
||||
//! [`Conversation`](super::conversation::Conversation) → serialized as JSON
|
||||
//! to `conversation.json`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The conversation participant who authored a message.
|
||||
///
|
||||
/// Variants: `User`, `Assistant`, `System`, `Tool`. Serialized as lowercase
|
||||
/// strings (e.g. `"user"`, `"assistant"`, `"system"`, `"tool"`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
#[serde(rename = "user")]
|
||||
@@ -17,18 +28,39 @@ pub enum Role {
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Return the role as a lowercase string.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message in a conversation, compatible with the OpenAI/Anthropic
|
||||
/// chat-completion API structures.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// Who sent this message (user, assistant, system, tool).
|
||||
pub role: Role,
|
||||
/// The message text content. `None` for assistant messages that only
|
||||
/// contain tool calls.
|
||||
pub content: Option<String>,
|
||||
/// Tool-call requests attached to an assistant message (OpenAI-style).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool::ToolCall>>,
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
/// For tool-role messages: the `id` of the `ToolCall` being responded to.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// Optional function name for the tool invocation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
@@ -68,6 +100,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,
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Core domain entities shared across the Zesdex application.
|
||||
//!
|
||||
//! Contains pure data structures for conversations, messages, tool calls,
|
||||
//! usage statistics, provider API types, and store path configuration.
|
||||
//! All types derive `Serialize`/`Deserialize` for JSON persistence.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`conversation`] — Ordered conversation (vector of `ChatMessage`)
|
||||
//! - [`message`] — `ChatMessage` + `Role` enum
|
||||
//! - [`provider`] — LLM provider API types: `ChatRequest`, `ChatResponse`,
|
||||
//! `StreamEvent`, `SseParser`, `ToolDef`, etc.
|
||||
//! - [`store`] — `Store` paths for data directories
|
||||
//! - [`tool_call`] — `ToolCall` + `ToolFunction` (function-calling request)
|
||||
//! - [`tool_result`] — `ToolCallResult` (function-calling response)
|
||||
//! - [`usage`] — `UsageStats` (token counts, costs)
|
||||
|
||||
pub mod conversation;
|
||||
pub mod message;
|
||||
pub mod provider;
|
||||
pub mod store;
|
||||
pub mod tool_call;
|
||||
pub mod tool_result;
|
||||
pub mod usage;
|
||||
|
||||
pub use conversation::Conversation;
|
||||
pub use message::{ChatMessage, Role};
|
||||
pub use provider::{
|
||||
ChatRequest, ChatResponse, Choice, Delta, SseParser, StreamEvent, StreamOptions, TokenUsage,
|
||||
ToolDef, ToolFunctionDef,
|
||||
};
|
||||
pub use store::Store;
|
||||
pub use tool_call::{ToolCall, ToolFunction};
|
||||
pub use tool_result::ToolCallResult;
|
||||
pub use usage::UsageStats;
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Provider-facing DTOs: chat completion request, response, streaming types,
|
||||
//! and the SSE stream parser.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. **Request** — [`ChatRequest`] is built with model, messages, tools,
|
||||
//! streaming options and sent to the LLM provider.
|
||||
//! 2. **Response** — Non-streaming responses arrive as [`ChatResponse`] with
|
||||
//! [`Choice`]s containing the full [`ChatMessage`](super::message::ChatMessage).
|
||||
//! 3. **Streaming** — SSE chunks are fed into [`SseParser::feed`] which yields
|
||||
//! [`StreamEvent`]s: token/text, reasoning, tool-call deltas, usage, done.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `ChatRequest` / `StreamOptions` / `ToolDef` / `ToolFunctionDef` — outbound
|
||||
//! - `ChatResponse` / `Choice` / `Delta` / `TokenUsage` — non-streaming inbound
|
||||
//! - `StreamEvent` — one atomic streaming event (Token, Reasoning,
|
||||
//! ToolCallDelta, Usage, Done, Error)
|
||||
//! - `SseParser` — incremental SSE frame parser: `feed()` → `Vec<StreamEvent>`
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat request / response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
|
||||
/// provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
/// Model identifier, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Full message history (system + user + assistant + tool turns).
|
||||
pub messages: Vec<super::message::ChatMessage>,
|
||||
/// Maximum number of output tokens.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Sampling temperature (0.0 – 2.0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// Tool definitions available to the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDef>>,
|
||||
/// Controls which (if any) function is called by the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
/// Whether to use SSE streaming (`true`) or a single response.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
/// Nucleus sampling threshold.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// Sequences where the model should stop generation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
/// Additional streaming options (e.g. `include_usage`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// The tool type discriminator, e.g. `"function"`.
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
/// The function definition (name, description, JSON schema).
|
||||
pub function: ToolFunctionDef,
|
||||
}
|
||||
|
||||
/// Name, description, and JSON schema parameters for a tool definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionDef {
|
||||
/// The function name the model may invoke.
|
||||
pub name: String,
|
||||
/// Human-readable description of what the function does.
|
||||
pub description: String,
|
||||
/// JSON Schema object describing the expected arguments.
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Non-streaming chat completion response returned by the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
/// Unique response identifier from the provider.
|
||||
pub id: String,
|
||||
/// Object type, e.g. `"chat.completion"`.
|
||||
pub object: Option<String>,
|
||||
/// Model identifier that produced this response.
|
||||
pub model: String,
|
||||
/// One or more completion candidates.
|
||||
pub choices: Vec<Choice>,
|
||||
/// Token usage statistics (prompt, completion, total).
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// Unix-timestamp of response creation.
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
/// One completion candidate within a `ChatResponse.choices` list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
/// Zero-based index of this choice in the candidate list.
|
||||
pub index: u32,
|
||||
/// Full message (non-streaming response).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<super::message::ChatMessage>,
|
||||
/// Incremental delta (streaming response).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta: Option<Delta>,
|
||||
/// Why the model stopped: `"stop"`, `"tool_calls"`, `"length"`, etc.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Incremental delta emitted in a streaming SSE chunk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Delta {
|
||||
/// Role being set for the first streaming chunk.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<super::message::Role>,
|
||||
/// Incremental text content delta.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Incremental tool-call delta (partial name/arguments).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token counts and optional cost breakdown for a single completion request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TokenUsage {
|
||||
/// Tokens consumed by the prompt (input).
|
||||
pub prompt_tokens: u32,
|
||||
/// Tokens consumed by the completion (output).
|
||||
pub completion_tokens: u32,
|
||||
/// Sum of prompt + completion tokens.
|
||||
pub total_tokens: u32,
|
||||
/// Estimated cost for prompt tokens (provider-specific).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_cost: Option<f64>,
|
||||
/// Estimated cost for completion tokens (provider-specific).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_cost: Option<f64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE streaming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One atomic event extracted from an LLM streaming response stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StreamEvent {
|
||||
/// An incremental text token.
|
||||
Token(String),
|
||||
/// An incremental reasoning token (Anthropic `reasoning_content`).
|
||||
Reasoning(String),
|
||||
/// An incremental tool-call delta (partial ID, name, or arguments).
|
||||
ToolCallDelta {
|
||||
/// Tool-call index (multiple calls in one response).
|
||||
index: usize,
|
||||
/// Optional tool-call ID (usually in the first delta for a call).
|
||||
id: Option<String>,
|
||||
/// Optional function name (usually in the first delta for a call).
|
||||
name: Option<String>,
|
||||
/// Partial JSON arguments delta for this tool call.
|
||||
arguments_delta: String,
|
||||
},
|
||||
/// Final usage chunk with token counts.
|
||||
Usage {
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
},
|
||||
/// Stream complete (all tokens have been delivered).
|
||||
Done,
|
||||
/// A stream-level error occurred.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Buffered SSE frame parser that accumulates raw `data:` lines and
|
||||
/// flushes a `StreamEvent` on each blank-line boundary.
|
||||
pub struct SseParser {
|
||||
/// Leftover bytes from the last chunk that did not end with `\n`.
|
||||
buffer: String,
|
||||
/// The current `event:` type (set by `event:` lines, cleared on flush).
|
||||
event_type: Option<String>,
|
||||
/// Accumulated `data:` lines for the current event frame.
|
||||
data_lines: Vec<String>,
|
||||
}
|
||||
|
||||
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<StreamEvent> {
|
||||
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<StreamEvent> {
|
||||
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
|
||||
},
|
||||
);
|
||||
let index = usize::try_from(index).unwrap_or(0);
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
||||
|
||||
use std::path::PathBuf;
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`Store::new`] resolves all paths from OS data dir / temp dir →
|
||||
//! [`ensure_dirs`](Store::ensure_dirs) creates them on startup.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Store` — resolved path bundle (base, memory, scratch, images, downloads)
|
||||
//! - `new` — path computation (no I/O)
|
||||
//! - `ensure_dirs` — creates all directories if missing
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
/// Resolved paths for all data directories zesdex reads from and writes to.
|
||||
///
|
||||
@@ -9,10 +20,15 @@ use serde::{Deserialize, Serialize};
|
||||
/// where memory, scratch, session images, and downloads live.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
/// Top-level data directory, e.g. `~/.local/share/zesdex`.
|
||||
pub base_dir: PathBuf,
|
||||
/// Temporary scratch root, usually under the OS temp dir.
|
||||
pub scratch_root: PathBuf,
|
||||
/// Directory for persistent memory files (`.md` summaries).
|
||||
pub memory_dir: PathBuf,
|
||||
/// Directory for per-session image snapshots.
|
||||
pub session_images_dir: PathBuf,
|
||||
/// Directory for downloaded files.
|
||||
pub download_dir: PathBuf,
|
||||
}
|
||||
|
||||
@@ -24,9 +40,13 @@ impl Store {
|
||||
///
|
||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||
pub fn new() -> Self {
|
||||
let base = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
||||
.join("zesdex");
|
||||
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
|
||||
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
|
||||
{
|
||||
PathBuf::from(data_dir).join("zesdex")
|
||||
} else {
|
||||
PathBuf::from(".local/share/zesdex")
|
||||
};
|
||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||
Store {
|
||||
memory_dir: base.join("memory"),
|
||||
@@ -37,10 +57,12 @@ 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<()> {
|
||||
tracing::debug!(base = %self.base_dir.display(), "ensuring store directories exist");
|
||||
std::fs::create_dir_all(&self.base_dir)?;
|
||||
std::fs::create_dir_all(&self.memory_dir)?;
|
||||
std::fs::create_dir_all(&self.scratch_root)?;
|
||||
@@ -49,3 +71,9 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Store {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user