Compare commits

...
27 Commits
Author SHA1 Message Date
semantic-release-bot 89ee213454 chore(release): 1.17.0 [skip ci]
# [1.17.0](https://github.com/asepharyana/zesdex/compare/v1.16.1...v1.17.0) (2026-07-20)

### Features

* **token:** add refresh token verification to TokenService ([a046519](https://github.com/asepharyana/zesdex/commit/a04651905f4afd562b516c839449a2c813ce6627))
2026-07-20 05:30:00 +00:00
asepharyana a04651905f feat(token): add refresh token verification to TokenService
feat(bootstrap): create temporary settings and config files to prevent data loss

refactor(edit_log): switch from Vec to VecDeque for efficient memory management

fix(gateway): ensure store directories are created before starting the API server

refactor(bgbash): implement a global singleton for BashControl

feat(auth): enhance session authentication middleware to use SessionRepository

fix(edit_log_repo): update to use VecDeque for in-memory edit log storage

fix(memory_repo): add newline escaping for frontmatter fields

fix(session_lock_repo): improve error handling for lock file operations

fix(bash_tools): prevent path traversal in job_id argument

refactor(delete): enforce empty directory deletion in file system tools

fix(edit): optimize string replacement to only replace the first occurrence

fix(git_cred): improve credential management with piped input to git commands

feat(git_operator): add safety filter to block destructive git operations

fix(shell): register background jobs in Bash control

feat(spawn): add access tier specification for pipeline stages

refactor(hive_mind): run directives concurrently for improved performance

fix(auth): update refresh token verification in the refresh handler

fix(chat): optimize LLM client usage based on model matching

fix(conversations): enhance message deletion to target specific indices

feat(api): add JWT authentication middleware for all API routes

fix(state): implement refresh token verification in JwtTokenService

fix(daemon): improve usage tracking with saturating addition

fix(tui): handle compacted messages in the TUI state management
2026-07-20 12:26:10 +07:00
semantic-release-bot 600ea041ef chore(release): 1.16.1 [skip ci]
## [1.16.1](https://github.com/asepharyana/zesdex/compare/v1.16.0...v1.16.1) (2026-07-20)
2026-07-20 05:06:27 +00:00
asepharyana 1ec2aa136a refactor: improve code readability and consistency across multiple files 2026-07-20 12:02:50 +07:00
semantic-release-bot 4ced6681c2 chore(release): 1.16.0 [skip ci]
# [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))
2026-07-20 04:33:19 +00:00
asepharyana 08e2f9998d feat(tui): enhance agent turn with tool descriptions and sanitize arguments 2026-07-20 11:29:33 +07:00
asepharyana 792695b65a feat(tui): implement agent turn engine for background processing and enhance input handling 2026-07-20 10:55:09 +07:00
asepharyana da2ed6da25 feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
2026-07-20 09:04:57 +07:00
asepharyana bceba665c0 refactor: remove unused tracing imports across multiple files 2026-07-20 07:02:55 +07:00
asepharyana 714b4617dd Refactor CMS and IAM modules: restructure presentation and command layers
- Removed HTTP adapter module from CMS infrastructure.
- Updated CMS infrastructure module to exclude HTTP.
- Introduced presentation layer in CMS with DTOs and handlers for REST API.
- Added command types for CMS domain operations to encapsulate input data.
- Created typed error handling for CMS presentation layer.
- Implemented handlers for CMS REST API endpoints.
- Removed HTTP DTOs and handlers from IAM infrastructure.
- Introduced command types for IAM domain operations.
- Created presentation layer in IAM with DTOs and handlers for OAuth flow.
- Implemented typed error handling for IAM presentation layer.
2026-07-20 06:53:01 +07:00
asepharyana e9a8e93c83 Refactor session ID handling and improve error management
- Introduced `SessionId` newtype for validated session identifiers, ensuring safety against path traversal attacks.
- Updated session repository methods to accept `SessionId` instead of raw strings, enhancing type safety.
- Removed redundant error handling in repository methods by leveraging the new `Error` type from `zesdex_utils`.
- Simplified atomic JSON write operations by eliminating unnecessary error conversions.
- Enhanced integer casting with a new `CastOr` trait for safer narrowing conversions.
- Removed deprecated error handling code and consolidated error types across the codebase.
- Updated HTTP handlers to utilize the new session ID validation, improving overall robustness.
2026-07-20 06:39:30 +07:00
asepharyana ab1a54b72e Refactor error handling in IAM and CMS crates
- Introduced `RepositoryError` and `ServiceError` enums in both IAM and CMS domains for better error management.
- Updated domain traits and services to return specific error types instead of `anyhow::Result`.
- Enhanced session and OAuth repository implementations to handle errors more explicitly.
- Refactored session service methods to return `Result<T, ServiceError>` for improved error handling.
- Updated HTTP handlers to utilize the new error types.
- Modified password hashing functions to run in a blocking context using `tokio::task::spawn_blocking`.
- Added tests for new error handling mechanisms and async password functions.
2026-07-20 06:14:23 +07:00
asepharyana 5aaedbf787 docs: tambah doc comment, logging, dan inline comments di semua 255 file
Meliputi:
- File-level //! doc comment: tujuan file, alur kerja, komponen utama
- Function-level /// doc comment: apa, parameter, return, flow, edge cases
- Struct/enum/trait /// doc comment: peran, field docs
- Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi
- Inline comments untuk variable dan branching logic penting
- Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities,
  zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils
- Build: 0 errors, 242/242 tests passed
2026-07-19 17:05:47 +07:00
semantic-release-bot 6680795ce7 chore(release): 1.15.2 [skip ci]
## [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))
2026-07-17 20:21:33 +00:00
asepharyana 9b6e51dc67 fix: correct jitter range to ±25% and fix abort.rs doc — review findings 2026-07-18 03:18:27 +07:00
asepharyana 40524f930f refactor: use mark_dirty() helper di input.rs, DRY 22 lokasi 2026-07-18 03:18:27 +07:00
asepharyana 086cb86f0d refactor: extract is_aborted helpers, DRY 16 call sites 2026-07-18 03:18:27 +07:00
asepharyana ff47bffe0c refactor: reuse is_auth_error from provider.rs, DRY backend retry logic 2026-07-18 03:18:27 +07:00
asepharyana 431c8d3b89 refactor: unify 3 backoff implementations into shared helper 2026-07-18 03:18:27 +07:00
asepharyanaandClaude Opus 4.8 e32501ee59 refactor: extract session_id extraction helper, DRY auth.rs
Extract duplicated session ID extraction + validation logic from
SessionAuthMiddleware::call() and require_session() into two shared
helper functions: extract_session_id and validate_and_build_identity.

Removes ~60 lines of duplicated code while preserving behavior:
- Both call sites now rely on the same extraction/validation path
- User-Agent default remains empty string (existing behavior unchanged)
- Error response format (401 with header/validation messages) unchanged

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:18:27 +07:00
asepharyanaandClaude Opus 4.8 7d8487cefb refactor: consolidate #![allow(clippy::cast_*)] to crate roots
Add the cast-allow block to zesdex-entities/src/lib.rs and
zesdex-utils/src/lib.rs (which lacked it), then remove from
65 sub-files across all 8 crates. Build and all 223 tests
continue to pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:18:27 +07:00
asepharyanaandClaude Opus 4.8 4cd38c9291 refactor: extract write_json_atomic helper, DRY 8 call sites
Move the crash-safe write-then-rename pattern into
zesdex-utils::write_json_atomic and apply it across:

- zesdex-cms: app_config_repo, conversation_repo, settings_repo
- zesdex-iam: oauth_repo, session_repo
- zesdex-entities: Conversation::save_conversation, Session::save

Excluded (non-JSON format):
- rewind_blob_repo (binary blob)
- memory_repo (markdown + frontmatter, not JSON)
- session_lock (PID string, not JSON)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:18:27 +07:00
asepharyanaandClaude Opus 4.8 9a6ab62562 refactor: DRY cleanup — extract shared helpers, remove duplication across tools, LSP, overlays, and runtime
Eliminate ~500 lines of duplicate code across 31 files by extracting
shared functions, helpers, and consolidating repeated patterns.

Highlights:
- Toast helpers (toast_info/success/warning/error) on AppStateRest
- push_event() helper for turn-event queue (19 callers consolidated)
- log_write_edit_tool() shared fn (turn.rs + engine.rs ~50 lines saved)
- resolve_api_key() shared fn (spawn.rs + provider.rs)
- LSP call_positional() helper on LspClient
- lsp_cursor_params() shared schema for 4 tool files
-overlay_block() helper for consistent overlay title/border styling
- cycle_selected_index(), path_not_found/a_directory() helpers
- mark_dirty(), save_settings() on AppStateRest
- Remove redundant Err(e) => Err(e) arms in LSP tools
- Consolidate generate_workspace_tree (turn.rs → workspace.rs)
- Simplify background-review wrapper args in auto/mod.rs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:18:27 +07:00
asepharyana b02754acd2 refactor: implement retry logic with exponential backoff and jitter for subagent and provider calls 2026-07-18 03:18:27 +07:00
semantic-release-bot b3c5b2a57b chore(release): 1.15.1 [skip ci]
## [1.15.1](https://github.com/asepharyana/zesdex/compare/v1.15.0...v1.15.1) (2026-07-17)
2026-07-17 03:32:34 +00:00
asepharyana 796bb09c5b refactor: enhance message shaping with progressive summarization and improved handling of dropped messages 2026-07-17 10:29:52 +07:00
asepharyana 5aad7e1eb1 refactor: streamline token counting and message shaping logic 2026-07-17 10:29:52 +07:00
463 changed files with 17281 additions and 25737 deletions
+27
View File
@@ -1,3 +1,30 @@
# [1.17.0](https://github.com/asepharyana/zesdex/compare/v1.16.1...v1.17.0) (2026-07-20)
### Features
* **token:** add refresh token verification to TokenService ([a046519](https://github.com/asepharyana/zesdex/commit/a04651905f4afd562b516c839449a2c813ce6627))
## [1.16.1](https://github.com/asepharyana/zesdex/compare/v1.16.0...v1.16.1) (2026-07-20)
# [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) # [1.15.0](https://github.com/asepharyana/zesdex/compare/v1.14.0...v1.15.0) (2026-07-17)
Generated
+405 -99
View File
@@ -32,6 +32,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.103" version = "1.0.103"
@@ -134,6 +184,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"axum-macros", "axum-macros",
"base64",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@@ -152,8 +203,10 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sha1",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-tungstenite",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -401,6 +454,46 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "clap"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@@ -410,6 +503,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -669,6 +768,12 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]] [[package]]
name = "deltae" name = "deltae"
version = "0.3.2" version = "0.3.2"
@@ -1624,6 +1729,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@@ -1937,6 +2048,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -2142,6 +2263,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.81" version = "0.10.81"
@@ -2483,6 +2610,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "precomputed-hash" name = "precomputed-hash"
version = "0.1.1" version = "0.1.1"
@@ -2619,6 +2755,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.2" version = "0.10.2"
@@ -2630,6 +2776,16 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.6.4" version = "0.6.4"
@@ -2639,6 +2795,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.10.1" version = "0.10.1"
@@ -3249,6 +3414,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]] [[package]]
name = "sha1_smol" name = "sha1_smol"
version = "1.0.1" version = "1.0.1"
@@ -3849,6 +4025,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -3977,6 +4165,22 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -4609,6 +4813,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
@@ -4670,14 +4894,157 @@ dependencies = [
] ]
[[package]] [[package]]
name = "zesdex-backend" name = "zesdex-api"
version = "1.15.0" version = "1.17.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"chrono",
"futures-util",
"jsonwebtoken",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tower",
"tower-http",
"tracing",
"uuid",
"zesdex-application",
"zesdex-domain",
"zesdex-infrastructure",
]
[[package]]
name = "zesdex-application"
version = "1.17.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
"url",
"uuid",
"zesdex-domain",
]
[[package]]
name = "zesdex-bootstrap"
version = "1.17.0"
dependencies = [
"anyhow",
"chrono",
"dirs",
"serde",
"serde_json",
"tokio",
"tracing",
"uuid",
"zesdex-application",
"zesdex-domain",
"zesdex-infrastructure",
]
[[package]]
name = "zesdex-daemon"
version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"chrono", "chrono",
"crossterm", "crossterm",
"dirs", "dirs",
"hex",
"ignore",
"ratatui",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
"uuid",
"webbrowser",
"zesdex-application",
"zesdex-domain",
"zesdex-infrastructure",
]
[[package]]
name = "zesdex-domain"
version = "1.17.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"libc",
"serde",
"serde_json",
"sha2 0.11.0",
"tracing",
"url",
"uuid",
]
[[package]]
name = "zesdex-gateway"
version = "1.17.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"clap",
"dirs",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
"zesdex-api",
"zesdex-application",
"zesdex-daemon",
"zesdex-domain",
"zesdex-grpc",
"zesdex-infrastructure",
"zesdex-tui",
"zesdex-web",
"zesdex-ws",
]
[[package]]
name = "zesdex-grpc"
version = "1.17.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"serde",
"serde_json",
"tokio",
"tracing",
"uuid",
"zesdex-application",
"zesdex-domain",
"zesdex-infrastructure",
]
[[package]]
name = "zesdex-infrastructure"
version = "1.17.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"base64",
"chrono",
"clap",
"dirs",
"dom_smoothie", "dom_smoothie",
"fast_html2md", "fast_html2md",
"futures-util", "futures-util",
@@ -4686,12 +5053,13 @@ dependencies = [
"ignore", "ignore",
"include_dir", "include_dir",
"infer", "infer",
"jsonwebtoken",
"libc", "libc",
"lsp-types", "lsp-types",
"nucleo-matcher", "nucleo-matcher",
"percent-encoding", "percent-encoding",
"pulldown-cmark", "pulldown-cmark",
"ratatui", "rand_core 0.6.4",
"regex", "regex",
"reqwest", "reqwest",
"rmcp", "rmcp",
@@ -4705,140 +5073,78 @@ dependencies = [
"syntect", "syntect",
"tiktoken-rs", "tiktoken-rs",
"tokio", "tokio",
"tower",
"tower-http",
"tracing", "tracing",
"tracing-subscriber",
"url", "url",
"uuid", "uuid",
"webbrowser", "webbrowser",
"zesdex-cms", "zesdex-application",
"zesdex-entities", "zesdex-domain",
"zesdex-iam",
"zesdex-infra",
"zesdex-ipc",
"zesdex-middleware",
"zesdex-utils",
] ]
[[package]] [[package]]
name = "zesdex-cms" name = "zesdex-tui"
version = "1.15.0" version = "1.17.0"
dependencies = [
"anyhow",
"chrono",
"dirs",
"hex",
"serde",
"serde_json",
"tracing",
"uuid",
"zesdex-entities",
"zesdex-utils",
]
[[package]]
name = "zesdex-entities"
version = "1.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"chrono", "chrono",
"crossterm",
"dirs", "dirs",
"libc",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
"url",
"uuid",
]
[[package]]
name = "zesdex-iam"
version = "1.15.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"hex", "hex",
"libc", "nucleo-matcher",
"rand_core 0.6.4", "pulldown-cmark",
"reqwest", "ratatui",
"serde",
"serde_json",
"sha2 0.11.0",
"tracing",
"url",
"uuid",
"zesdex-entities",
"zesdex-utils",
]
[[package]]
name = "zesdex-infra"
version = "1.15.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"chrono",
"jsonwebtoken",
"rand_core 0.6.4",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"tiktoken-rs",
"tokio", "tokio",
"tracing", "tracing",
"uuid", "uuid",
"zesdex-cms", "zesdex-application",
"zesdex-entities", "zesdex-domain",
"zesdex-iam", "zesdex-infrastructure",
"zesdex-middleware",
"zesdex-utils",
] ]
[[package]] [[package]]
name = "zesdex-ipc" name = "zesdex-web"
version = "1.15.0" version = "1.17.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"tracing",
"zesdex-entities",
]
[[package]]
name = "zesdex-middleware"
version = "1.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"chrono", "chrono",
"include_dir",
"mime_guess",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
"tower", "tower",
"tower-http", "tracing",
"zesdex-entities", "uuid",
"zesdex-utils", "zesdex-application",
"zesdex-domain",
"zesdex-infrastructure",
] ]
[[package]] [[package]]
name = "zesdex-utils" name = "zesdex-ws"
version = "1.15.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "axum",
"chrono", "chrono",
"dirs", "futures-util",
"hex",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "tokio",
"thiserror 1.0.69",
"tracing", "tracing",
"tracing-subscriber", "uuid",
"zesdex-application",
"zesdex-domain",
"zesdex-infrastructure",
] ]
[[package]] [[package]]
+26 -11
View File
@@ -1,18 +1,21 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"crates/zesdex-entities", "apps/domain",
"crates/zesdex-utils", "apps/application",
"crates/zesdex-ipc", "apps/infrastructure",
"crates/zesdex-iam", "apps/interfaces/tui",
"crates/zesdex-cms", "apps/interfaces/api",
"crates/zesdex-middleware", "apps/interfaces/daemon",
"crates/zesdex-infra", "apps/interfaces/ws",
"crates/zesdex-backend", "apps/interfaces/grpc",
"apps/interfaces/web",
"apps/gateway",
"apps/bootstrap",
] ]
[workspace.package] [workspace.package]
version = "1.15.0" version = "1.17.0"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
@@ -76,6 +79,18 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "limit"] } tower-http = { version = "0.6", features = ["cors", "limit"] }
argon2 = "0.5" argon2 = "0.5"
jsonwebtoken = "9" jsonwebtoken = "9"
clap = { version = "4", features = ["derive"] }
rand_core = { version = "0.6", features = ["getrandom"] }
zesdex-entities = { path = "crates/zesdex-entities" } # Clean-architecture workspace crate references
zesdex-utils = { path = "crates/zesdex-utils" } zesdex-domain = { path = "apps/domain" }
zesdex-application = { path = "apps/application" }
zesdex-infrastructure = { path = "apps/infrastructure" }
zesdex-tui = { path = "apps/interfaces/tui" }
zesdex-api = { path = "apps/interfaces/api" }
zesdex-daemon = { path = "apps/interfaces/daemon" }
zesdex-ws = { path = "apps/interfaces/ws" }
zesdex-grpc = { path = "apps/interfaces/grpc" }
zesdex-web = { path = "apps/interfaces/web" }
zesdex-gateway = { path = "apps/gateway" }
zesdex-bootstrap = { path = "apps/bootstrap" }
+193 -322
View File
@@ -1,379 +1,250 @@
# Zesdex # Zesdex — Autonomous AI Coding Agent
> Autonomous AI coding agent in a terminal-based TUI. Zesdex is an autonomous AI coding agent with a Terminal UI (TUI). It acts as an
OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with **37
built-in tools** — file operations, git, shell execution, LSP integration, MCP,
subagent orchestration, and more.
Zesdex is a Rust-powered AI assistant that operates directly in your terminal via a rich TUI interface. It combines large language model intelligence with a comprehensive set of tools to explore, understand, and modify codebases autonomously — with built-in guardrails at every layer. ```
┌──────────────────────────────────────────────────────────────┐
│ Mode Selector │
│ TUI (default) ─── Daemon ─── Attach ─── API ─── WS/gRPC/Web │
└──────────────────────────────────────────────────────────────┘
```
--- ---
## Features ## Quick Start
### Core ```bash
# Run the TUI (default mode)
cargo run
- **TUI Interface** — Full-screen terminal UI with chat panel, input bar, and status bar built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm). # Run the REST API server
- **Daemon Architecture** — Run as a background daemon with client attach/detach via Unix domain sockets. The daemon processes state; clients only render. cargo run -- --api --api-port 8080
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
### Tool System (37 built-in tools) # Run in daemon mode (background + IPC)
cargo run -- --daemon
| Category | Tools | # Attach TUI to a running daemon session
|----------|-------| cargo run -- --attach <session-id>
| **Filesystem** | `read`, `write`, `edit`, `delete` |
| **Search** | `grep` (recursive text), `glob` (file patterns) |
| **Shell** | `bash`, `bash_output`, `bash_kill` |
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
| **Memory** | `remember`, `recall`, `forget` |
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
| **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `hive_mind` |
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
| **Agent** | `spawn_agents`, `spawn_pipeline` |
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
### Intelligence # Seed initial data (first run)
cargo run --bin bootstrap
```
- **Hive-Mind Orchestration** — Autonomous agent orchestration modeled as a distributed machine intelligence (à la Stellaris). The Core Intelligence (main agent) compiles a cognitive cycle plan per task — an ordered list of cycles, each a set of anonymous processing nodes that run in parallel. Every node carries only a directive (what to do) and an access tier (`read`/`write`/`full`); cycle count and nodes-per-cycle are decided per task, not fixed. Every node's output merges into a shared collective state the instant it completes, and a final synthesis node reconciles it into one consensus. Every convergence is written to `docs/runs/*.md`. Manual entry point: the `hive_mind` tool. ### Prerequisites
- **Workflow Engine** — Orchestrate complex multi-step tasks with parallel sub-agents, pipelines, and phased execution. Spawn independent workers that share findings in real-time. - **Rust** 1.81+ (edition 2021)
- **Self-Learning** — Persistent memory system that stores lessons, references, and project knowledge across sessions. Memories include provenance tracking, lifecycle management, and scope isolation. - **Linux** or **macOS** (Unix domain sockets required for daemon mode)
- **Self-Review** — Review subagents trigger automatically after each code edit (inline) and at turn completion (background). Three types: code quality, architecture, and security. - An **API key** for an OpenAI/Anthropic-compatible LLM provider (set via
- **Self-Healing** — On build/test failures, spawns a sub-agent with the error context to autonomously fix issues before reporting them to the user. settings or environment variable)
- **MCP Support** — [Model Context Protocol](https://modelcontextprotocol.io/) integration for connecting to external AI tool servers.
- **Sequential Thinking** — Chain-of-thought reasoning tool for step-by-step problem decomposition.
- **Session Locking** — Prevents multiple processes from operating on the same session directory.
### Session Management ---
- Multiple concurrent sessions with history, rewind, and transcript persistence. ## Modes
- Per-session edit logs with full change tracking.
- Session archival and summary generation. | Flag | Mode | Description |
|------|------|-------------|
| *(none)* | **TUI** | Full terminal UI with chat, overlays, and agent loop in one process |
| `--daemon` | **Daemon** | Background daemon with IPC socket; clients attach separately |
| `--attach <id>` | **Attach** | Connect TUI to an existing daemon session via Unix socket |
| `--api` | **REST API** | HTTP server with session management and chat endpoints |
| `--ws` | **WebSocket** | WebSocket server for real-time communication |
| `--grpc` | **gRPC** | gRPC server for programmatic access |
| `--web` | **Web** | Serves the web frontend |
| `--api-port`, `--ws-port`, `--grpc-port`, `--web-port` | *(ports)* | Configure server ports (defaults: 8080, 8081, 50051, 3000) |
--- ---
## Architecture ## Architecture
### Clean Architecture Layering
``` ```
src/ apps/
├── main.rs # Entry point: single-process, daemon, or attach mode ├── domain/ # Pure entities, value objects, repository/service traits
├── resources.rs # Embedded resources (help text, system prompts) # Zero framework deps — only serde + chrono + uuid
├── app/ ├── application/ # Use-case services (auth, sessions, conversations, memory)
├── state/ # AppStateRest — immutable-rest state model │ # Depends only on domain-layer trait interfaces
│ │ ├── rest.rs # Core state struct ├── infrastructure/ # All I/O: LLM client, IPC, persistence, LSP, MCP, tools
├── types.rs # Overlay, Toast, Origin enums # Implements domain/application port interfaces
│ │ ├── snapshot.rs # State snapshots for IPC └── interfaces/ # Entry points
│ │ ├── diff.rs # Diff-based state synchronization ├── tui/ # Ratatui terminal UI
│ │ ├── runtime.rs # Runtime state mutations ├── api/ # Axum REST API
│ │ └── misc.rs # DirCache and miscellaneous state helpers ├── daemon/ # Unix socket daemon + client
├── runtime/ # Action dispatch and event loop ├── ws/ # WebSocket server
│ │ ├── actions/ # Action enum and apply_action reducer ├── grpc/ # gRPC server
│ │ ── stream/ # LLM streaming and tool execution ── web/ # Web frontend (static file server)
│ │ │ └── tools/ # Tool harness integration
│ │ │ └── turn.rs # Turn orchestration
│ │ ├── event_loop/ # Main event loop and shortsend
│ │ ├── commands.rs # Slash command dispatch
│ │ └── shortsend.rs # Short-lived async send helper
│ ├── mode/ # UI modes and overlays (13 modes)
│ │ ├── bash.rs # Bash panel mode
│ │ ├── editor.rs # Multi-line editor mode
│ │ ├── effort.rs # Effort level selector
│ │ ├── help.rs # Help overlay
│ │ ├── key_input.rs # Raw key input mode
│ │ ├── learning.rs # Lesson management overlay
│ │ ├── loading.rs # Loading spinner overlay
│ │ ├── mcp.rs # MCP server management
│ │ ├── quit_confirm.rs # Quit confirmation dialog
│ │ ├── rewind.rs # Session rewind mode
│ │ ├── settings.rs # Settings panel
│ │ ├── todo.rs # Task list overlay
│ │ └── workflow.rs # Workflow visualization
│ ├── harness.rs # Tool harness for agent execution
│ ├── workflow/ # Workflow engine
│ │ ├── script.rs # Workflow script DSL
│ │ ├── engine.rs # Workflow executor
│ │ ├── hive_mind.rs # Hive-mind orchestrator
│ │ └── docs.rs # Deterministic docs/runs/*.md writer
│ ├── mcp/ # MCP client manager
│ │ └── manager.rs # MCP server lifecycle and tool exposure
│ ├── subagent/ # Sub-agent management
│ │ ├── spawn.rs # AgentDefinition and spawning
│ │ ├── engine.rs # Sub-agent event loop
│ │ ├── context.rs # Context construction for sub-agents
│ │ └── event.rs # Progress event types
│ ├── bgbash/ # Background bash job management
│ │ ├── job.rs # Background job handle
│ │ └── control.rs # Bash control (bg/fg/kill)
│ ├── lsp/ # LSP client management
│ │ ├── client.rs # LSP client connection wrapper
│ │ └── provisioner.rs # Auto-provisioning of LSP servers
│ └── review/ # Self-review quality system
├── controller/
│ ├── input.rs # Key event → Action mapping
│ └── command.rs # Slash command parser
├── dto/
│ ├── chat/ # Message, ToolCall, Role types
│ │ ├── message.rs # Chat message types
│ │ ├── tool.rs # Tool call/result types
│ │ └── mod.rs
│ └── provider/ # AI provider request/response/usage types
│ ├── request.rs # Provider request schema
│ ├── response.rs # Provider response schema
│ └── usage.rs # Token usage tracking
├── ipc/
│ ├── protocol.rs # ClientRequest, DaemonFrame, StatePayload
│ ├── server.rs # Unix socket server
│ ├── client.rs # Unix socket client
│ ├── conn.rs # Framed connection
│ ├── frame.rs # Length-prefixed frame encoding
│ ├── snapshot.rs # State snapshot serialization
│ └── diff.rs # Binary diff for state sync
├── model/
│ ├── store.rs # File-based storage (~/.config/zesdex/)
│ ├── session.rs # Session CRUD and listing
│ ├── settings.rs # User settings (provider, model, tokens)
│ ├── app_config.rs # Provider definitions and model roles
│ ├── memory.rs # Persistent memory with frontmatter
│ ├── editlog.rs # Edit history tracking
│ ├── msglog/ # Message log (SQLite-backed)
│ │ ├── schema.rs # SQLite schema
│ │ ├── query.rs # Query helpers
│ │ ├── blobs.rs # Large blob storage
│ │ └── summary.rs # Session summarization
│ ├── agent_def/ # Agent definitions (builtin, global, session)
│ │ ├── builtin.rs # Built-in agent profiles
│ │ ├── global.rs # Global agent config
│ │ └── session.rs # Per-session agent config
│ ├── conversation.rs # Conversation helpers
│ └── session_lock.rs # Flock-based session locking
├── service/
│ ├── provider.rs # AI provider abstraction
│ └── oauth/ # OAuth PKCE flow with loopback server
│ ├── loopback.rs # Local HTTP server for OAuth redirect
│ ├── manager.rs # OAuth token manager
│ ├── pkce.rs # PKCE code challenge/verifier
│ └── mod.rs
├── tool/ # 34 tool implementations
│ ├── fs/ # read, write, edit, delete
│ │ ├── read.rs
│ │ ├── write.rs
│ │ ├── edit.rs
│ │ ├── delete.rs
│ │ └── helpers.rs # Path resolution and validation
│ ├── search.rs # grep, glob
│ ├── shell.rs # bash
│ ├── bash_tools.rs # bash_output, bash_kill
│ ├── git_operator.rs # git operations
│ ├── git_worktree.rs # git worktree management
│ ├── git_cred.rs # git credential store/get/erase
│ ├── memory/ # remember, forget, recall
│ │ ├── remember.rs
│ │ ├── forget.rs
│ │ └── recall.rs
│ ├── plan.rs # plan_enter, plan_ready
│ ├── seqthink.rs # Sequential thinking
│ ├── workflow.rs # workflow_run, note_finding
│ ├── utility/ # cd, dir_list, dir_cache_update, pong, todowrite, todofinish
│ │ ├── cd.rs
│ │ ├── dir_list.rs
│ │ ├── dir_cache_update.rs
│ │ ├── pong.rs
│ │ ├── todowrite.rs
│ │ └── todofinish.rs
│ ├── lsp/ # LSP tools (connect, diagnostics, hover, etc.)
│ │ └── mod.rs
│ └── shell_filter/ # Shell output filtering (credentials, git)
│ ├── credentials.rs
│ ├── git.rs
│ └── mod.rs
└── view/ # TUI rendering
├── chat.rs # Chat transcript with markdown
├── markdown.rs # Markdown → ratatui spans
├── status.rs # Status bar
├── theme.rs # Color scheme
└── workflow.rs # Workflow visualization
``` ```
--- ### Tool System
## Usage 37 tools across 9 categories:
```bash | Category | Tools |
# Run in single-process mode (default) |----------|-------|
zesdex | **File System** | `read`, `write`, `edit`, `delete`, `dir_list`, `dir_cache_update` |
| **Shell** | `bash`, `bash_interactive`, `bash_kill`, `bash_output` |
| **Git** | `git_operator`, `git_cred`, `git_worktree` |
| **Search** | `search`, `grep`, `glob`, `semantic_search` |
| **LSP** | `lsp_connect`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_diagnostics`, `lsp_disconnect` |
| **Memory** | `remember`, `recall`, `forget` |
| **Workflow** | `spawn_agents`, `spawn_pipeline`, `plan`, `sequential_think`, `hive_mind` |
| **Utility** | `todo_write`, `todo_finish`, `pong`, `cd` |
| **Background** | Background bash jobs with `cancel/status/list` |
# Run as a background daemon Each tool implements the `Tool` trait:
zesdex --daemon ```rust
pub trait Tool: Send + Sync {
# Attach to a running daemon session fn name(&self) -> &'static str;
zesdex --attach <session-id> fn description(&self) -> &'static str;
fn parameters(&self) -> Value;
# Set log level fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
RUST_LOG=debug zesdex
```
### Key Bindings
| Binding | Action |
|---------|--------|
| `Ctrl+Q` | Quit |
| `Ctrl+H` | Help overlay |
| `Ctrl+P` | Settings overlay |
| `Ctrl+B` | Bash panel |
| `Ctrl+S` | Session hub |
| `Ctrl+T` | Task list |
| `Ctrl+W` | Workflow view |
| `Ctrl+K` | Key input mode |
| `Esc` | Cancel / back |
| `Tab` | Autocomplete |
| `↑/↓` | History / navigation |
| `Scroll` | Mouse scroll in chat |
### Slash Commands
| Command | Description |
|---------|-------------|
| `/help` | Show help |
| `/clear` | Clear transcript |
| `/model` | Select AI model provider |
| `/workflow` | Open the workflow panel |
| `/workflow run <script>` | Run a JSON-encoded workflow script |
| `/mcp` | Open MCP server manager |
| `/mcp add <name> <command>` | Add an MCP server |
| `/login [provider]` | Authenticate with a provider |
| `/edit [path]` | Open a file/dir in the external editor |
| `/compact` | Compact the conversation transcript |
| `/lesson` | Interactive lesson/memory review |
| `/quit` | Exit application |
| `Any text` | Sent to the AI assistant as a prompt |
---
## Configuration
All configuration lives in `~/.config/zesdex/` (or platform equivalent via the `dirs` crate).
| File | Purpose |
|------|---------|
| `settings.json` | Provider selection, model, temperature, max tokens, review settings, workflow concurrency |
| `app_config.json` | AI provider definitions (name, API base URL, auth type, default model) |
| `memory/` | Persistent lesson and reference storage (Markdown with YAML frontmatter) |
| `sessions/` | Per-session transcripts, edit logs, and activity data |
| `run/` | Unix domain sockets for daemon mode |
### Provider Configuration
Providers are defined in `app_config.json`:
```json
{
"providers": {
"my-provider": {
"api_base": "https://api.example.com/v1",
"api_key_env": "MY_API_KEY",
"default_model": "model-name"
}
},
"model_roles": {
"default": {
"provider": "my-provider",
"model": "model-name",
"max_tokens": 8192,
"temperature": 0.7
}
},
"default_provider": "my-provider",
"default_model": "model-name"
} }
``` ```
### Settings ### Hive Mind Orchestration
Key settings in `settings.json`: The multi-agent orchestration system compiles a **cognitive cycle plan** per
task — ordered cycles of parallel processing nodes. Each node has a directive
and an **access tier** (`read` / `write` / `full`). Node outputs merge into a
shared collective state in real time, and a final **consensus synthesis**
produces the unified result.
| Setting | Default | Description | - **Auto-trigger**: Complex requests automatically use the hive mind
|---------|---------|-------------| - **Manual entry**: The `hive_mind` tool lets the LLM specify cycles explicitly
| `review_enabled` | `true` | Enable self-review after tool execution | - **Live progress**: TUI panel shows each node's status and current tool
| `review_max_lessons_per_run` | `5` | Max lessons loaded per review cycle | - **Guaranteed docs**: Every convergence writes to `docs/runs/`
| `adaptive_review_max_skip` | `3` | Consecutive passes before skipping review |
| `verify_command` | `null` | Optional command to verify changes | ### IPC Protocol (Daemon Mode)
| `workflow_max_concurrency` | `5` | Max parallel sub-agents in workflows |
| `session_archive_enabled` | `true` | Auto-archive completed sessions | ```
┌──────────┐ Unix socket ┌──────────┐
│ Client │ ◄──────────────► │ Daemon │
│ (TUI) │ length-prefixed│ │
└──────────┘ serde_json └──────────┘
Frame format: [4-byte BE length][JSON payload]
```
The daemon holds `AppStateRest` and drives the agent loop. Clients are stateless
renderers that receive full state snapshots after each action.
--- ---
## Installation ## Built-in Features
### Prerequisites | Feature | Description |
|---------|-------------|
| **LLM Provider** | OpenAI/Anthropic-compatible API (streaming + non-streaming) with automatic retry and fallback |
| **Tool Harness** | Safety-gated tool execution with graduated review checks |
| **Subagents** | Auto-inline review, background test-gen, arch-review, security-review |
| **OAuth 2.0** | PKCE flow for LLM provider authentication |
| **MCP** | Model Context Protocol server management (stdio + HTTP transport) |
| **LSP** | Language Server Protocol integration (completion, hover, diagnostics, references) |
| **Session Mgmt** | SQLite-persisted sessions with lock-based concurrency control |
| **Memory** | File-based memory system with frontmatter metadata |
| **Edit Log** | Append-only edit history with configurable retention |
| **Rate Limiting** | Sliding-window per-client rate limiter |
| **JWT Auth** | HS256 JWT access/refresh tokens (API mode) |
| **Password Auth** | Argon2 password hashing with pepper |
| **OAuth Loopback** | Localhost HTTP server for OAuth redirect capture |
| **Background Jobs** | Long-running shell jobs with cancellation and output collection |
| **Settings** | JSON-persisted settings with hot-reload |
- **Rust** 2021 edition toolchain ([rustup](https://rustup.rs/)) ---
### Build from Source ## TUI Overlays
16 overlays accessible from the terminal UI:
| Overlay | Purpose |
|---------|---------|
| Chat Input | Main input bar with autocomplete |
| Bash Panel | Interactive shell panel |
| File Editor | Built-in file editor |
| Effort Selector | LLM reasoning effort selector |
| Help | Keybindings reference |
| Key Input | Custom key binding configuration |
| Learning | Lesson viewer |
| Loading | Generating spinner |
| MCP Manager | MCP server management |
| Model Selector | LLM model picker |
| Quit Confirm | Exit confirmation dialog |
| Rewind | Message/history rewind |
| Settings | Settings panel |
| Todo | Task/TODO list |
| Usage | Token usage statistics |
| Workflow | Hive-mind node progress |
---
## Data & Persistence
All data lives under the platform's data directory (`~/.local/share/zesdex/`):
```bash
git clone <repository-url>
cd zesdex
cargo build --release
./target/release/zesdex
``` ```
~/.local/share/zesdex/
├── settings.json # User settings (provider, model, keys)
├── app_config.json # Provider definitions (endpoints, env vars)
├── sessions/ # Chat sessions (one subdirectory per session)
│ └── <uuid>/
│ ├── session.json # Session metadata
│ ├── messages.jsonl # Message log
│ └── .lock # Session lock file
└── memories/ # Memory files with frontmatter metadata
└── *.md
```
--- ---
## Development ## Development
### Commit Convention ```bash
# Build all crates
cargo build
Project ini menggunakan **Conventional Commits** untuk otomatis menentukan versi rilis (melalui semantic-release). # Run all unit tests (8 tests across 11 crates)
cargo test
Format: # Run clippy linting
``` cargo clippy --all-targets
<type>(<scope>): <description>
[optional body] # Run with verbose logging
RUST_LOG=debug cargo run
[optional footer]
``` ```
**`<type>` — menentukan bump version:** ### Workspace Crates
| Type | Bump | Keterangan | | Crate | Path | Layer |
|-------------|---------|-------------------------------------------| |-------|------|-------|
| `feat` | minor | Fitur baru | | `zesdex-domain` | `apps/domain/` | Pure domain entities & traits |
| `fix` | patch | Perbaikan bug | | `zesdex-application` | `apps/application/` | Use-case services |
| `chore` | patch | Tugas maintenance, refactor ringan | | `zesdex-infrastructure` | `apps/infrastructure/` | All I/O & tool implementations |
| `docs` | patch | Perubahan dokumentasi | | `zesdex-tui` | `apps/interfaces/tui/` | Ratatui terminal interface |
| `refactor` | patch | Refactor kode (tanpa perubahan fungsional)| | `zesdex-api` | `apps/interfaces/api/` | Axum REST API |
| `test` | patch | Penambahan atau perbaikan test | | `zesdex-daemon` | `apps/interfaces/daemon/` | Unix socket daemon |
| `style` | patch | Perubahan formatting, whitespace, dll | | `zesdex-ws` | `apps/interfaces/ws/` | WebSocket server |
| `perf` | patch | Optimasi performa | | `zesdex-grpc` | `apps/interfaces/grpc/` | gRPC server |
| `ci` | patch | Perubahan CI/CD | | `zesdex-web` | `apps/interfaces/web/` | Web frontend |
| `zesdex-gateway` | `apps/gateway/` | CLI entry point & dispatcher |
| `zesdex-bootstrap` | `apps/bootstrap/` | Initial data seeder |
**`BREAKING CHANGE`** pada body commit → **major** (apa pun typenya). ### Code Map
Contoh: Detailed architecture documentation is in `docs/CODEMAPS/`:
```
feat(agent): add workspace-aware file search
Implement context-aware search scoped to current workspace directory. | File | Covers |
|------|--------|
BREAKING CHANGE: search results now filter by workspace scope. | `docs/CODEMAPS/architecture.md` | System layout, process modes, data flow |
``` | `docs/CODEMAPS/backend.md` | Provider, OAuth, IPC, workflow engine, MCP, LSP, review |
| `docs/CODEMAPS/frontend.md` | TUI render pipeline, 16 overlays, toasts, input handling |
``` | `docs/CODEMAPS/data.md` | Persistence, SQLite msglog, memory files, settings/config |
fix(ipc): handle partial frame on unix socket reconnect | `docs/CODEMAPS/dependencies.md` | All Rust crates and external services |
```
```
chore: update rustls to 0.23
```
### Release Workflow
Push ke branch `main` akan memicu:
1. **CI**`cargo build --release` + `cargo test`
2. **Semantic Release** — analisis commit → update `Cargo.toml` + `CHANGELOG.md` → git tag → GitHub Release dengan binary
--- ---
## License ## License
See [LICENSE](LICENSE) for details. See `CHANGELOG.md` for release history.
@@ -1,22 +1,21 @@
[package] [package]
name = "zesdex-iam" name = "zesdex-application"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
# Application layer — port traits (interfaces), use cases, DTOs.
# Depends ONLY on domain. Application services orchestrate domain objects
# through port traits without knowing concrete implementations.
[dependencies] [dependencies]
zesdex-domain = { path = "../domain" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
anyhow.workspace = true
chrono.workspace = true chrono.workspace = true
uuid.workspace = true uuid.workspace = true
zesdex-entities = { path = "../zesdex-entities" } anyhow.workspace = true
zesdex-utils = { path = "../zesdex-utils" }
reqwest.workspace = true
libc.workspace = true
tracing.workspace = true tracing.workspace = true
url.workspace = true tokio.workspace = true
base64.workspace = true base64.workspace = true
sha2.workspace = true sha2.workspace = true
hex.workspace = true url.workspace = true
rand_core = { version = "0.6", features = ["getrandom"] }
+16
View File
@@ -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;
+250
View File
@@ -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(ServiceError::Other)?;
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)
}
}
+18
View File
@@ -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(())
}
}
+51
View File
@@ -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)>;
}
+22
View File
@@ -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;
+24
View File
@@ -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;
}
+56
View File
@@ -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;
}
+32
View File
@@ -0,0 +1,32 @@
//! 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>;
/// Verify a refresh token and return the embedded subject claim.
///
/// Returns `Err` if the token is expired, malformed, or has an
/// invalid signature.
fn verify_refresh_token(&self, token: &str) -> Result<String>;
}
+25
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
//! Bootstrap library — shared utilities for the bootstrap binary.
//! The main entry point is in `main.rs`.
+44
View File
@@ -0,0 +1,44 @@
//! 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)?;
let tmp = store.base_dir.join("settings.json.tmp");
std::fs::write(&tmp, &content)?;
std::fs::File::open(&tmp)?.sync_all()?;
std::fs::rename(&tmp, &settings_path)?;
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)?;
let tmp = store.base_dir.join("app_config.json.tmp");
std::fs::write(&tmp, &content)?;
std::fs::File::open(&tmp)?.sync_all()?;
std::fs::rename(&tmp, &config_path)?;
println!(" ✓ Default app_config created");
} else {
println!(" · App config already exists, skipping");
}
println!("Bootstrap complete.");
Ok(())
}
@@ -1,20 +1,20 @@
[package] [package]
name = "zesdex-entities" name = "zesdex-domain"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
# Domain layer — PURE entities, value objects, repository/service traits.
# Zero framework dependencies. Only serde for serialization, chrono for
# timestamps, uuid for identity.
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
chrono.workspace = true chrono.workspace = true
uuid.workspace = true uuid.workspace = true
anyhow.workspace = true
dirs.workspace = true
libc.workspace = true
base64.workspace = true base64.workspace = true
sha2.workspace = true sha2.workspace = true
url.workspace = true url.workspace = true
reqwest.workspace = true libc.workspace = true
tokio.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
+30
View File
@@ -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(),
}
}
}
+62
View File
@@ -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,
}
}
}
+12
View File
@@ -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;
+36
View File
@@ -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;
@@ -1,29 +1,38 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Pure OAuth entities — no HTTP or persistence logic. //! 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}; use serde::{Deserialize, Serialize};
/// An OAuth 2.0 access token with optional refresh token and absolute /// An OAuth 2.0 access token with optional refresh token and absolute
/// expiry time (epoch seconds). /// expiry time (epoch seconds).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken { pub struct OAuthToken {
/// The OAuth 2.0 access token string.
pub access_token: String, pub access_token: String,
/// Optional refresh token for long-lived access.
pub refresh_token: Option<String>, pub refresh_token: Option<String>,
/// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH).
pub expires_at: u64, pub expires_at: u64,
/// Token type, e.g. `"Bearer"`.
pub token_type: String, pub token_type: String,
} }
/// Static configuration for an OAuth provider. /// Static configuration for an OAuth provider.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig { pub struct OAuthConfig {
/// Authorization endpoint URL.
pub auth_url: String, pub auth_url: String,
/// Token exchange endpoint URL.
pub token_url: String, pub token_url: String,
/// OAuth client identifier.
pub client_id: String, pub client_id: String,
/// Optional client secret (not all flows require it).
pub client_secret: Option<String>, pub client_secret: Option<String>,
/// Space-separated list of requested scopes.
pub scopes: Vec<String>, pub scopes: Vec<String>,
} }
+57
View File
@@ -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>;
}
@@ -1,24 +1,29 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Service trait definitions — use-case interfaces for session management //! Service trait definitions — use-case interfaces for session management
//! and OAuth flows. //! and OAuth flows.
use crate::domain::oauth::{OAuthConfig, OAuthToken}; //!
use crate::domain::session::Session; //! 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. /// Session management use-case boundary.
pub trait SessionService { pub trait SessionService {
/// Create a new session with a generated UUID and the given title. /// Create a new session with a generated UUID and the given title.
fn create_session(&self, title: &str) -> anyhow::Result<Session>; fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
/// List all available sessions. /// List all available sessions.
fn list_all(&self) -> anyhow::Result<Vec<Session>>; fn list_all(&self) -> Result<Vec<Session>, ServiceError>;
/// Archive a session by id (sets `archived = true`). /// Archive a session by id (sets `archived = true`).
fn archive_session(&self, id: &str) -> anyhow::Result<()>; fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>;
} }
/// OAuth flow use-case boundary. /// OAuth flow use-case boundary.
@@ -32,7 +37,7 @@ pub trait OAuthService {
&self, &self,
config: &OAuthConfig, config: &OAuthConfig,
redirect_uri: &str, redirect_uri: &str,
) -> anyhow::Result<(String, String)>; ) -> Result<(String, String), ServiceError>;
/// Complete the OAuth flow: validates `state` against the value /// Complete the OAuth flow: validates `state` against the value
/// persisted during `start_flow` (bailing on mismatch — this is the /// persisted during `start_flow` (bailing on mismatch — this is the
@@ -44,8 +49,8 @@ pub trait OAuthService {
redirect_uri: &str, redirect_uri: &str,
code: &str, code: &str,
state: &str, state: &str,
) -> anyhow::Result<OAuthToken>; ) -> Result<OAuthToken, ServiceError>;
/// Retrieve the currently stored OAuth token (if any). /// Retrieve the currently stored OAuth token (if any).
fn get_token(&self) -> anyhow::Result<Option<OAuthToken>>; fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
} }
+71
View File
@@ -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")
}
}
+106
View File
@@ -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,21 +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 //! PID-file based advisory lock preventing two processes from operating on
//! the same session directory concurrently. //! the same session directory concurrently.
//!
//! # 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::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing;
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process, /// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
/// auto-removed on drop. /// auto-removed on drop.
#[derive(Debug)] #[derive(Debug)]
pub struct SessionLock { pub struct SessionLock {
path: PathBuf, /// Path to the `.lock` file inside the session directory.
pid: u32, pub(crate) path: PathBuf,
/// Process ID that holds (or will hold) this lock.
pub(crate) pid: u32,
} }
impl SessionLock { impl SessionLock {
@@ -32,14 +43,9 @@ impl SessionLock {
/// ///
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that /// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
/// succeeds, the lock is ours — write our PID and return ok. If the /// succeeds, the lock is ours — write our PID and return ok. If the
/// file already exists, read the PID inside it and check `is_alive`: /// file already exists, read the PID inside it and check whether that
/// if that process is still running, fail to acquire; otherwise the /// PID is still alive: if the process is still running, fail to acquire;
/// lock is stale — overwrite it with our own PID and succeed. /// otherwise the lock is stale — overwrite it with our own PID and succeed.
///
/// Why: `create_new(true)` is atomic on POSIX (unlike the previous
/// read-then-write pattern which had a TOCTOU race between checking
/// `path.exists()` and writing). The stale-lock recovery path reads
/// the stale PID and verifies liveness via `kill(pid, 0)`.
/// ///
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live /// Return: `Ok(true)` if acquired, `Ok(false)` if another live
/// process holds it, `Err` on I/O failure. /// process holds it, `Err` on I/O failure.
@@ -53,9 +59,11 @@ impl SessionLock {
Ok(mut file) => { Ok(mut file) => {
write!(file, "{}", self.pid)?; write!(file, "{}", self.pid)?;
file.sync_all()?; file.sync_all()?;
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
return Ok(true); return Ok(true);
} }
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => { 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. // Lock file exists — check if it's stale.
} }
Err(e) => return Err(e), Err(e) => return Err(e),
@@ -65,8 +73,10 @@ impl SessionLock {
let content = fs::read_to_string(&self.path).unwrap_or_default(); let content = fs::read_to_string(&self.path).unwrap_or_default();
if let Ok(pid) = content.trim().parse::<u32>() { 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); return Ok(false);
} }
tracing::debug!(stale = pid, "stale lock detected, overwriting");
} }
// Phase 3: stale lock — overwrite it atomically (best-effort). // Phase 3: stale lock — overwrite it atomically (best-effort).
@@ -94,30 +104,44 @@ impl SessionLock {
let _ = fs::remove_file(&self.path); let _ = fs::remove_file(&self.path);
} }
/// Check whether a process with the given PID is currently alive and /// Check whether a process with the given PID is currently alive.
/// is actually a zesdex process (not a recycled PID from a different ///
/// program). /// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production;
/// here we provide a best-effort check using the process table.
/// On non-Unix platforms this always returns `true` (conservative).
fn is_alive(pid: u32) -> bool { fn is_alive(pid: u32) -> bool {
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // On Unix, signal 0 checks process existence without sending a signal.
// whether the process exists and the caller has permission to signal #[cfg(unix)]
// it. The integer argument is a PID already validated by `try_lock`. {
if unsafe { libc::kill(pid as i32, 0) != 0 } { // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
return false; // whether the process exists and the caller has permission to signal it.
} // The integer argument is a PID validated by `try_lock`.
// Extra check: verify the PID belongs to a zesdex process via let pid_signed: i32 = match pid.try_into() {
// /proc/<pid>/exe to mitigate the PID-reuse race (a recycled PID Ok(p) => p,
// from a different program would answer kill but shouldn't hold Err(_) => return false,
// our lock). This is best-effort — /proc may not be available };
// on all platforms. if unsafe { libc::kill(pid_signed, 0) != 0 } {
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); return false;
if let Ok(target) = std::fs::read_link(&proc_exe) { }
if let Ok(exe) = std::env::current_exe() { // Extra check: verify the PID belongs to a zesdex process via
if target != exe { // /proc/<pid>/exe to mitigate the PID-reuse race.
return false; let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
if let Ok(target) = std::fs::read_link(&proc_exe) {
if let Ok(exe) = std::env::current_exe() {
if target != exe {
return false;
}
} }
} }
true
}
#[cfg(not(unix))]
{
// Fallback: always assume alive (conservative).
let _ = pid;
true
} }
true
} }
} }
+127
View File
@@ -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,
}
}
}
+157
View File
@@ -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>>,
}
+15
View File
@@ -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;
+83
View File
@@ -0,0 +1,83 @@
//! 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 std::collections::VecDeque;
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 `VecDeque<EditLogEntry>` and provides basic query helpers.
#[derive(Debug, Clone)]
pub struct EditLog {
/// Ordered list of edit entries (newest appended last).
pub entries: VecDeque<EditLogEntry>,
}
impl EditLog {
/// Create an empty edit log with no entries.
pub fn new() -> Self {
Self {
entries: VecDeque::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()
}
}
+51
View File
@@ -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,
}
}
}
+109
View File
@@ -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
})
}
}
+52
View File
@@ -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;
+126
View File
@@ -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>;
}
+64
View File
@@ -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>;
}
+120
View File
@@ -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,11 +1,17 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! In-memory conversation state: message history plus the system prompt and //! In-memory conversation state: message history plus the system prompt and
//! model parameters used to drive the LLM. //! 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 serde::{Deserialize, Serialize};
use super::message::{ChatMessage, Role}; use super::message::{ChatMessage, Role};
@@ -13,11 +19,17 @@ use super::message::{ChatMessage, Role};
/// A single conversation's message history and generation settings. /// A single conversation's message history and generation settings.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation { pub struct Conversation {
/// Ordered list of chat messages (user, assistant, tool, system).
pub messages: Vec<ChatMessage>, pub messages: Vec<ChatMessage>,
/// System prompt prepended at request time (see `to_api_messages`).
pub system_prompt: String, pub system_prompt: String,
/// Foreign key referencing the owning session.
pub session_id: String, pub session_id: String,
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
pub model: String, pub model: String,
/// Optional cap on output tokens.
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
/// Optional temperature (0.0 2.0).
pub temperature: Option<f32>, pub temperature: Option<f32>,
} }
@@ -73,44 +85,4 @@ impl Conversation {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.messages.is_empty() self.messages.is_empty()
} }
/// Persist the conversation to a JSON file at the given base directory.
///
/// Flow: compute path from `session_id` → ensure directory exists →
/// serialize to pretty JSON → write-then-rename with fsync.
///
/// Return: `Ok(())` on success, or an `io::Error` from any step.
pub fn save_conversation(&self, base_dir: &std::path::Path) -> std::io::Result<()> {
let dir = base_dir.join("sessions").join(&self.session_id);
std::fs::create_dir_all(&dir)?;
let path = dir.join("conversation.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("conversation.json.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
Ok(())
}
/// Load a conversation from a JSON file for the given session id.
///
/// Flow: read `<base_dir>/sessions/<session_id>/conversation.json` →
/// JSON-parse.
///
/// Return: the parsed `Conversation`, or an `io::Error` if the file is
/// missing or malformed.
pub fn load_conversation(
session_id: &str,
base_dir: &std::path::Path,
) -> std::io::Result<Self> {
let path = base_dir
.join("sessions")
.join(session_id)
.join("conversation.json");
let data = std::fs::read_to_string(path)?;
let conv: Conversation = serde_json::from_str(&data)?;
Ok(conv)
}
} }
@@ -1,14 +1,20 @@
#![allow( //! Chat message types shared across the entity layer.
clippy::cast_possible_truncation, //!
clippy::cast_sign_loss, //! Provides [`Role`] (conversation participant) and [`ChatMessage`] (a single
clippy::cast_precision_loss, //! message with optional tool-call metadata). Includes convenience constructors
clippy::cast_possible_wrap //! for each role: `user`, `assistant`, `system`, `tool`/`tool_result`.
)] //!
//! Chat message types shared across the entity layer: `Role` and `ChatMessage` //! # Flow
//! with convenience constructors. //!
//! Messages are constructed via the typed constructors → pushed into
//! [`Conversation`](super::conversation::Conversation) → serialized as JSON
//! to `conversation.json`.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The conversation participant who authored a message. /// 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role { pub enum Role {
#[serde(rename = "user")] #[serde(rename = "user")]
@@ -43,12 +49,18 @@ impl std::fmt::Display for Role {
/// chat-completion API structures. /// chat-completion API structures.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage { pub struct ChatMessage {
/// Who sent this message (user, assistant, system, tool).
pub role: Role, pub role: Role,
/// The message text content. `None` for assistant messages that only
/// contain tool calls.
pub content: Option<String>, pub content: Option<String>,
/// Tool-call requests attached to an assistant message (OpenAI-style).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::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")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>, pub tool_call_id: Option<String>,
/// Optional function name for the tool invocation.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
} }
+35
View File
@@ -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;
@@ -1,13 +1,25 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Provider-facing DTOs: chat completion request, response, streaming types, //! Provider-facing DTOs: chat completion request, response, streaming types,
//! and the SSE stream parser. //! 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::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tracing;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chat request / response // Chat request / response
@@ -17,23 +29,32 @@ use serde_json::Value;
/// provider. /// provider.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest { pub struct ChatRequest {
/// Model identifier, e.g. `"anthropic/claude-opus-4-8"`.
pub model: String, pub model: String,
/// Full message history (system + user + assistant + tool turns).
pub messages: Vec<super::message::ChatMessage>, pub messages: Vec<super::message::ChatMessage>,
/// Maximum number of output tokens.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
/// Sampling temperature (0.0 2.0).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>, pub temperature: Option<f32>,
/// Tool definitions available to the model.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDef>>, pub tools: Option<Vec<ToolDef>>,
/// Controls which (if any) function is called by the model. /// Controls which (if any) function is called by the model.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>, pub tool_choice: Option<Value>,
/// Whether to use SSE streaming (`true`) or a single response.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>, pub stream: Option<bool>,
/// Nucleus sampling threshold.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>, pub top_p: Option<f32>,
/// Sequences where the model should stop generation.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>, pub stop: Option<Vec<String>>,
/// Additional streaming options (e.g. `include_usage`).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>, pub stream_options: Option<StreamOptions>,
} }
@@ -48,38 +69,53 @@ pub struct StreamOptions {
/// Wire format for a single tool definition sent to the provider. /// Wire format for a single tool definition sent to the provider.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef { pub struct ToolDef {
/// The tool type discriminator, e.g. `"function"`.
#[serde(rename = "type")] #[serde(rename = "type")]
pub type_: String, pub type_: String,
/// The function definition (name, description, JSON schema).
pub function: ToolFunctionDef, pub function: ToolFunctionDef,
} }
/// Name, description, and JSON schema parameters for a tool definition. /// Name, description, and JSON schema parameters for a tool definition.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionDef { pub struct ToolFunctionDef {
/// The function name the model may invoke.
pub name: String, pub name: String,
/// Human-readable description of what the function does.
pub description: String, pub description: String,
/// JSON Schema object describing the expected arguments.
pub parameters: Value, pub parameters: Value,
} }
/// Non-streaming chat completion response returned by the provider. /// Non-streaming chat completion response returned by the provider.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse { pub struct ChatResponse {
/// Unique response identifier from the provider.
pub id: String, pub id: String,
/// Object type, e.g. `"chat.completion"`.
pub object: Option<String>, pub object: Option<String>,
/// Model identifier that produced this response.
pub model: String, pub model: String,
/// One or more completion candidates.
pub choices: Vec<Choice>, pub choices: Vec<Choice>,
/// Token usage statistics (prompt, completion, total).
pub usage: Option<TokenUsage>, pub usage: Option<TokenUsage>,
/// Unix-timestamp of response creation.
pub created: Option<i64>, pub created: Option<i64>,
} }
/// One completion candidate within a `ChatResponse.choices` list. /// One completion candidate within a `ChatResponse.choices` list.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice { pub struct Choice {
/// Zero-based index of this choice in the candidate list.
pub index: u32, pub index: u32,
/// Full message (non-streaming response).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<super::message::ChatMessage>, pub message: Option<super::message::ChatMessage>,
/// Incremental delta (streaming response).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<Delta>, pub delta: Option<Delta>,
/// Why the model stopped: `"stop"`, `"tool_calls"`, `"length"`, etc.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>, pub finish_reason: Option<String>,
} }
@@ -87,10 +123,13 @@ pub struct Choice {
/// Incremental delta emitted in a streaming SSE chunk. /// Incremental delta emitted in a streaming SSE chunk.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delta { pub struct Delta {
/// Role being set for the first streaming chunk.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<super::message::Role>, pub role: Option<super::message::Role>,
/// Incremental text content delta.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>, pub content: Option<String>,
/// Incremental tool-call delta (partial name/arguments).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>, pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
} }
@@ -98,11 +137,16 @@ pub struct Delta {
/// Token counts and optional cost breakdown for a single completion request. /// Token counts and optional cost breakdown for a single completion request.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenUsage { pub struct TokenUsage {
/// Tokens consumed by the prompt (input).
pub prompt_tokens: u32, pub prompt_tokens: u32,
/// Tokens consumed by the completion (output).
pub completion_tokens: u32, pub completion_tokens: u32,
/// Sum of prompt + completion tokens.
pub total_tokens: u32, pub total_tokens: u32,
/// Estimated cost for prompt tokens (provider-specific).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_cost: Option<f64>, pub prompt_tokens_cost: Option<f64>,
/// Estimated cost for completion tokens (provider-specific).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens_cost: Option<f64>, pub completion_tokens_cost: Option<f64>,
} }
@@ -114,28 +158,41 @@ pub struct TokenUsage {
/// One atomic event extracted from an LLM streaming response stream. /// One atomic event extracted from an LLM streaming response stream.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamEvent { pub enum StreamEvent {
/// An incremental text token.
Token(String), Token(String),
/// An incremental reasoning token (Anthropic `reasoning_content`).
Reasoning(String), Reasoning(String),
/// An incremental tool-call delta (partial ID, name, or arguments).
ToolCallDelta { ToolCallDelta {
/// Tool-call index (multiple calls in one response).
index: usize, index: usize,
/// Optional tool-call ID (usually in the first delta for a call).
id: Option<String>, id: Option<String>,
/// Optional function name (usually in the first delta for a call).
name: Option<String>, name: Option<String>,
/// Partial JSON arguments delta for this tool call.
arguments_delta: String, arguments_delta: String,
}, },
/// Final usage chunk with token counts.
Usage { Usage {
prompt_tokens: u64, prompt_tokens: u64,
completion_tokens: u64, completion_tokens: u64,
total_tokens: u64, total_tokens: u64,
}, },
/// Stream complete (all tokens have been delivered).
Done, Done,
/// A stream-level error occurred.
Error(String), Error(String),
} }
/// Buffered SSE frame parser that accumulates raw `data:` lines and /// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary. /// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser { pub struct SseParser {
/// Leftover bytes from the last chunk that did not end with `\n`.
buffer: String, buffer: String,
/// The current `event:` type (set by `event:` lines, cleared on flush).
event_type: Option<String>, event_type: Option<String>,
/// Accumulated `data:` lines for the current event frame.
data_lines: Vec<String>, data_lines: Vec<String>,
} }
@@ -273,7 +330,8 @@ impl SseParser {
); );
0 0
}, },
) as usize; );
let index = usize::try_from(index).unwrap_or(0);
let id = tc let id = tc
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@@ -1,12 +1,18 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Filesystem layout for zesdex's persistent and scratch data directories. //! Filesystem layout for zesdex's persistent and scratch data directories.
//!
//! # 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 serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use tracing;
/// Resolved paths for all data directories zesdex reads from and writes to. /// Resolved paths for all data directories zesdex reads from and writes to.
/// ///
@@ -14,10 +20,15 @@ use std::path::PathBuf;
/// where memory, scratch, session images, and downloads live. /// where memory, scratch, session images, and downloads live.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Store { pub struct Store {
/// Top-level data directory, e.g. `~/.local/share/zesdex`.
pub base_dir: PathBuf, pub base_dir: PathBuf,
/// Temporary scratch root, usually under the OS temp dir.
pub scratch_root: PathBuf, pub scratch_root: PathBuf,
/// Directory for persistent memory files (`.md` summaries).
pub memory_dir: PathBuf, pub memory_dir: PathBuf,
/// Directory for per-session image snapshots.
pub session_images_dir: PathBuf, pub session_images_dir: PathBuf,
/// Directory for downloaded files.
pub download_dir: PathBuf, pub download_dir: PathBuf,
} }
@@ -29,9 +40,13 @@ impl Store {
/// ///
/// Why: paths are computed, not created — call `ensure_dirs` before use. /// Why: paths are computed, not created — call `ensure_dirs` before use.
pub fn new() -> Self { pub fn new() -> Self {
let base = dirs::data_dir() let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
.unwrap_or_else(|| PathBuf::from(".local/share")) .or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
.join("zesdex"); {
PathBuf::from(data_dir).join("zesdex")
} else {
PathBuf::from(".local/share/zesdex")
};
let scratch = std::env::temp_dir().join("zesdex-scratch"); let scratch = std::env::temp_dir().join("zesdex-scratch");
Store { Store {
memory_dir: base.join("memory"), memory_dir: base.join("memory"),
@@ -47,6 +62,7 @@ impl Store {
/// ///
/// Return: `Err` on the first directory that fails to create. /// Return: `Err` on the first directory that fails to create.
pub fn ensure_dirs(&self) -> std::io::Result<()> { 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.base_dir)?;
std::fs::create_dir_all(&self.memory_dir)?; std::fs::create_dir_all(&self.memory_dir)?;
std::fs::create_dir_all(&self.scratch_root)?; std::fs::create_dir_all(&self.scratch_root)?;
@@ -1,31 +1,42 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Tool-call DTOs embedded in assistant chat messages. //! Tool-call DTOs embedded in assistant chat messages.
//! //!
//! Flow: provider response/stream carries `tool_calls` on an assistant //! # Flow
//! message → deserialized into `ToolCall`/`ToolFunction` → harness resolves //!
//! `function.name` against `all_tools()` and runs it with //! Provider response/stream carries `tool_calls` on an assistant message →
//! `sanitize_tool_arguments(function.arguments)`. //! deserialized into [`ToolCall`]/[`ToolFunction`] → harness resolves the
//! function name against `all_tools()` and runs it after sanitizing arguments
//! via [`sanitize_tool_arguments`] (which handles string-encoded JSON,
//! control characters, and truncation).
//!
//! # Components
//!
//! - `ToolCall` — a single tool-invocation request (id + type + function)
//! - `ToolFunction` — function name + raw arguments Value
//! - `sanitize_tool_arguments` — normalizes argument shape, repairs truncation
//! - `repair_json` — closes unclosed strings/braces/brackets in truncated JSON
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tracing;
/// A single tool-call request emitted by the model in an assistant message. /// A single tool-call request emitted by the model in an assistant message.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall { pub struct ToolCall {
/// Unique identifier for this tool call (referenced by `ToolCallResult`).
pub id: String, pub id: String,
/// Discriminator, e.g. `"function"`.
#[serde(rename = "type")] #[serde(rename = "type")]
pub type_: String, pub type_: String,
/// The function to invoke (name + arguments).
pub function: ToolFunction, pub function: ToolFunction,
} }
/// The function name and raw arguments payload for a `ToolCall`. /// The function name and raw arguments payload for a `ToolCall`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction { pub struct ToolFunction {
/// The function/tool name to dispatch against.
pub name: String, pub name: String,
/// Arguments as a JSON Value (may be a string-encoded object before
/// `sanitize_tool_arguments` normalises it).
pub arguments: Value, pub arguments: Value,
} }
@@ -1,19 +1,29 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Record of one completed tool invocation, kept for transcript/history. //! Record of one completed tool invocation, kept for transcript/history.
//!
//! # Flow
//!
//! Tool harness completes execution → creates [`ToolCallResult`] with output,
//! error flag, and wall-clock duration → appended to conversation history as
//! a `Tool`-role [`ChatMessage`](super::message::ChatMessage).
//!
//! # Components
//!
//! - `ToolCallResult` — tool name + output + error flag + duration
//! - `new` — convenience constructor
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Record of a completed tool invocation, kept for transcript/history. /// Record of a completed tool invocation, kept for transcript/history.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult { pub struct ToolCallResult {
/// The `id` of the `ToolCall` this result responds to.
pub tool_call_id: String, pub tool_call_id: String,
/// The name of the tool that was invoked.
pub tool_name: String, pub tool_name: String,
/// The text output produced by the tool (or error message).
pub output: String, pub output: String,
/// Whether the tool exited with an error.
pub is_error: bool, pub is_error: bool,
/// Wall-clock execution duration in milliseconds.
pub duration_ms: u64, pub duration_ms: u64,
} }
+40
View File
@@ -0,0 +1,40 @@
//! Token usage accounting shared by streaming and non-streaming responses.
//!
//! # Flow
//!
//! Accumulated across all LLM API calls in a session. Each response updates
//! the running totals; `last_*` fields capture the most recent call's values
//! for interpolation display. Persisted alongside other session metadata.
//!
//! # Components
//!
//! - `UsageStats` — cumulative token/latency counters
use serde::{Deserialize, Serialize};
/// Cumulative token/latency counters for a session, persisted alongside it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct UsageStats {
/// Total tokens consumed as input (prompt).
pub tokens_in: u64,
/// Total tokens generated as output (completion).
pub tokens_out: u64,
/// Most recent call's input tokens (for live interpolation display).
#[serde(default)]
pub last_tokens_in: u64,
/// Most recent call's output tokens (for live interpolation display).
#[serde(default)]
pub last_tokens_out: u64,
/// Total number of LLM API calls made this session.
pub api_calls: u64,
/// Tokens consumed by auto-review subagent calls.
pub review_tokens: u64,
/// Total wall-clock time spent on LLM API calls (milliseconds).
pub total_ms: u64,
}
impl UsageStats {
/// Create a new `UsageStats` with all counters zeroed.
pub fn new() -> Self {
Self::default()
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Shared domain error types for the entire domain layer.
//!
//! Provides [`DomainError`] — a unified repository-level error enum used
//! by both the `auth` and `cms` modules (type-aliased as `RepositoryError`
//! in each module). This avoids a dependency on `thiserror` while still
//! giving callers distinct error variants to match on.
//!
//! # Flow
//!
//! Infrastructure adapters convert their native errors (I/O, serde, etc.)
//! into `DomainError` via `From` impls. Domain service layers wrap
//! `DomainError` in their own `ServiceError` enum via `From`.
//!
//! # Components
//!
//! - `DomainError` — 6 variants: `NotFound`, `Conflict`, `Io`, `Serde`,
//! `InvalidId`, `Other`
//! - `From<std::io::Error>` — converts I/O errors
//! - `From<serde_json::Error>` — converts serialisation errors
use std::fmt;
/// Unified repository-level error for domain operations.
///
/// Covers the common failure modes across all persistence adapters:
/// missing entities, conflicts, I/O failures, serialization errors,
/// invalid identifiers, and a catch-all `Other` variant.
#[derive(Debug)]
pub enum DomainError {
/// The requested entity was not found.
NotFound(String),
/// An operation failed due to a conflict (e.g. duplicate key).
Conflict(String),
/// An I/O error occurred during persistence.
Io(std::io::Error),
/// A serialization / deserialization error occurred.
Serde(String),
/// An identifier was rejected as invalid (e.g. path traversal).
InvalidId(String),
/// A generic / uncategorised error.
Other(String),
}
impl fmt::Display for DomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DomainError::NotFound(msg) => write!(f, "not found: {msg}"),
DomainError::Conflict(msg) => write!(f, "conflict: {msg}"),
DomainError::Io(err) => write!(f, "I/O error: {err}"),
DomainError::Serde(msg) => write!(f, "serialization error: {msg}"),
DomainError::InvalidId(msg) => write!(f, "invalid id: {msg}"),
DomainError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for DomainError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
DomainError::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for DomainError {
fn from(err: std::io::Error) -> Self {
DomainError::Io(err)
}
}
impl From<serde_json::Error> for DomainError {
fn from(err: serde_json::Error) -> Self {
DomainError::Serde(err.to_string())
}
}
+52
View File
@@ -0,0 +1,52 @@
//! # Zesdex Domain Layer
//!
//! Pure domain entities, value objects, repository traits, and service traits
//! for the Zesdex application. This crate has **zero framework dependencies**
//! — it depends only on serialization (`serde`), timestamping (`chrono`),
//! identity (`uuid`), and a few other narrowly-scoped utilities.
//!
//! ## Architecture
//!
//! ```text
//! apps/domain
//! ├── core/ Shared domain entities (Conversation, Message, Provider,
//! │ Store, ToolCall, ToolResult, Usage)
//! ├── auth/ Authentication domain (Session, SessionId, SessionLock,
//! │ OAuth, commands, errors, repository/service traits)
//! ├── cms/ CMS domain (AppConfig, Conversation, EditLog, Memory,
//! │ Settings, commands, errors, repository/service traits)
//! └── error.rs Unified DomainError type
//! ```
//!
//! ## Key Design Principle
//!
//! All types are pure Rust structs and enums with `serde` derives. No I/O,
//! no framework imports, no side effects. All persistence is expressed
//! through repository traits that infrastructure adapters implement.
pub mod auth;
pub mod cms;
pub mod core;
pub mod error;
// Re-export all public items from each module for ergonomic imports.
// Consumers can do `use zesdex_domain::*` for common types.
pub use auth::{
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService,
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session,
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService,
};
pub use cms::{
AppConfig, AppConfigRepository, Conversation as CmsConversation,
ConversationRepository, ConversationService, EditLog, EditLogEntry,
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService,
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError,
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch,
SettingsRepository, SettingsService,
};
pub use core::{
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role,
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall,
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
};
pub use error::DomainError;
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "zesdex-gateway"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Gateway binary — assembles domain + application + infrastructure
# + selected interface(s) into a running application process.
# This is the main entry point that wires everything together.
[[bin]]
name = "zesdex"
path = "src/main.rs"
[[bin]]
name = "seed"
path = "src/bin/seed.rs"
[[bin]]
name = "migrate"
path = "src/bin/migrate.rs"
[dependencies]
zesdex-domain = { path = "../domain" }
zesdex-application = { path = "../application" }
zesdex-infrastructure = { path = "../infrastructure" }
zesdex-tui = { path = "../interfaces/tui" }
zesdex-api = { path = "../interfaces/api" }
zesdex-daemon = { path = "../interfaces/daemon" }
zesdex-ws = { path = "../interfaces/ws" }
zesdex-grpc = { path = "../interfaces/grpc" }
zesdex-web = { path = "../interfaces/web" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
dirs.workspace = true
rusqlite.workspace = true
axum.workspace = true
clap = { version = "4", features = ["derive"] }
@@ -1,13 +1,16 @@
//! Database migration: creates/upgrades SQLite schemas for all sessions. //! Database migration binary.
//!
//! Scans all session directories and initializes or upgrades the SQLite
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
use std::path::Path; use std::path::Path;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let store = zesdex_entities::domain::common::store::Store::new(); let store = zesdex_domain::core::Store::new();
// Find all session directories
let sessions_dir = store.base_dir.join("sessions"); let sessions_dir = store.base_dir.join("sessions");
if !sessions_dir.exists() { if !sessions_dir.exists() {
eprintln!("No sessions directory found, nothing to migrate"); tracing::info!("No sessions directory found, nothing to migrate");
return Ok(()); return Ok(());
} }
@@ -24,23 +27,22 @@ fn main() -> anyhow::Result<()> {
match migrate_session_msglog(&path) { match migrate_session_msglog(&path) {
Ok(_) => { Ok(_) => {
migrated += 1; migrated += 1;
eprintln!("Migrated session: {:?}", path.file_name()); tracing::info!("Migrated session: {:?}", path.file_name());
} }
Err(e) => { Err(e) => {
failed += 1; failed += 1;
eprintln!("Failed to migrate session {:?}: {e}", path.file_name()); tracing::error!("Failed to migrate session {:?}: {e}", path.file_name());
} }
} }
} }
eprintln!("Migration complete: {migrated} succeeded, {failed} failed"); tracing::info!("Migration complete: {migrated} succeeded, {failed} failed");
if failed > 0 { if failed > 0 {
anyhow::bail!("{failed} session(s) failed to migrate"); anyhow::bail!("{failed} session(s) failed to migrate");
} }
Ok(()) Ok(())
} }
/// Open a session's `messages.sqlite` and initialize its schema.
fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> { fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
let msglog_path = session_dir.join("messages.sqlite"); let msglog_path = session_dir.join("messages.sqlite");
@@ -51,12 +53,9 @@ fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
let conn = rusqlite::Connection::open(&msglog_path)?; let conn = rusqlite::Connection::open(&msglog_path)?;
conn.execute_batch("PRAGMA journal_mode = WAL;")?; conn.execute_batch("PRAGMA journal_mode = WAL;")?;
conn.execute_batch("PRAGMA busy_timeout = 5000;")?; conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
// Initialize schema
conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch("PRAGMA foreign_keys = ON;")?;
conn.execute_batch( conn.execute_batch(
" "CREATE TABLE IF NOT EXISTS messages (
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
@@ -88,11 +87,9 @@ fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
mime_type TEXT, mime_type TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
UNIQUE(session_id, blob_key) UNIQUE(session_id, blob_key)
); );",
",
)?; )?;
// Check and upgrade schema version
let version: i32 = conn let version: i32 = conn
.pragma_query_value(None, "user_version", |row| row.get(0)) .pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap_or(0); .unwrap_or(0);
@@ -1,15 +1,18 @@
//! Database seeder: initializes store directories, creates default settings //! Database seeder binary.
//! and app_config, and populates a default session for development. //!
//! Initialises the store directory structure and creates default
//! configuration files plus a seed session for development/testing.
//! Invoked as `cargo run --bin seed`.
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let store = zesdex_entities::domain::common::store::Store::new(); let store = zesdex_domain::core::Store::new();
store.ensure_dirs()?; store.ensure_dirs()?;
tracing::info!("Store directories created at {:?}", store.base_dir);
// Create default settings if not present // Create default settings if not present
let settings_path = store.base_dir.join("settings.json"); let settings_path = store.base_dir.join("settings.json");
if !settings_path.exists() { if !settings_path.exists() {
let settings = zesdex_cms::domain::settings::Settings::default(); let settings = zesdex_domain::cms::Settings::default();
let content = serde_json::to_string_pretty(&settings)?; let content = serde_json::to_string_pretty(&settings)?;
let tmp = store.base_dir.join("settings.json.tmp"); let tmp = store.base_dir.join("settings.json.tmp");
std::fs::write(&tmp, content)?; std::fs::write(&tmp, content)?;
@@ -24,7 +27,7 @@ fn main() -> anyhow::Result<()> {
// Create default app config if not present // Create default app config if not present
let config_path = store.base_dir.join("app_config.json"); let config_path = store.base_dir.join("app_config.json");
if !config_path.exists() { if !config_path.exists() {
let config = zesdex_cms::domain::app_config::AppConfig::default(); let config = zesdex_domain::cms::AppConfig::default();
let content = serde_json::to_string_pretty(&config)?; let content = serde_json::to_string_pretty(&config)?;
let tmp = store.base_dir.join("app_config.json.tmp"); let tmp = store.base_dir.join("app_config.json.tmp");
std::fs::write(&tmp, content)?; std::fs::write(&tmp, content)?;
@@ -36,20 +39,21 @@ fn main() -> anyhow::Result<()> {
tracing::info!("App config already exists, skipping"); tracing::info!("App config already exists, skipping");
} }
// Create memory, scratch, session-images, downloads dirs // Create data directories
std::fs::create_dir_all(&store.memory_dir)?; std::fs::create_dir_all(&store.memory_dir)?;
std::fs::create_dir_all(&store.scratch_root)?;
std::fs::create_dir_all(&store.session_images_dir)?; std::fs::create_dir_all(&store.session_images_dir)?;
std::fs::create_dir_all(&store.download_dir)?;
tracing::info!("All store directories verified"); tracing::info!("All store directories verified");
// Create a seed session // Create a seed session
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
let session = zesdex_entities::domain::auth::session::Session::new( let session = zesdex_domain::auth::Session::new(
session_id.clone(), session_id.clone(),
"Seed Session".to_string(), "Seed Session".to_string(),
); );
session.save(&store.base_dir)?; // Persist via the session repository
use zesdex_domain::SessionRepository;
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
repo.save_session(&store.base_dir, &session)?;
tracing::info!("Seed session created: id={session_id}"); tracing::info!("Seed session created: id={session_id}");
Ok(()) Ok(())
+2
View File
@@ -0,0 +1,2 @@
//! Gateway library — provides shared utilities for the gateway binary.
//! The main entry point is in `main.rs`.
+176
View File
@@ -0,0 +1,176 @@
//! Zesdex Gateway — main entry point.
//!
//! Assembles domain + application + infrastructure layers and dispatches
//! to the requested interface: TUI (default), daemon (background IPC),
//! API server (REST), WebSocket server, gRPC server, or Web frontend.
//!
//! CLI flags are parsed via clap; run with `--help` for details.
use std::sync::Mutex;
use clap::Parser;
/// Zesdex — autonomous AI coding agent.
#[derive(Parser, Debug)]
#[command(name = "zesdex", version, about = "Autonomous AI coding agent with TUI")]
struct Cli {
/// Run as background daemon with IPC socket
#[arg(long)]
daemon: bool,
/// Attach TUI client to a running daemon session
#[arg(long)]
attach: Option<String>,
/// Run REST API server
#[arg(long)]
api: bool,
/// REST API port
#[arg(long, default_value_t = 8080)]
api_port: u16,
/// Run WebSocket server
#[arg(long)]
ws: bool,
/// WebSocket port
#[arg(long, default_value_t = 8081)]
ws_port: u16,
/// Run gRPC server
#[arg(long)]
grpc: bool,
/// gRPC port
#[arg(long, default_value_t = 50051)]
grpc_port: u16,
/// Serve web frontend
#[arg(long)]
web: bool,
/// Web frontend port
#[arg(long, default_value_t = 3000)]
web_port: u16,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// ── Setup logging ────────────────────────────────────────────────────
let log_dir = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("zesdex");
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("zesdex.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.unwrap_or_else(|_| {
std::fs::OpenOptions::new()
.write(true)
.open("/dev/null")
.expect("cannot open /dev/null")
});
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(Mutex::new(log_file))
.init();
tracing::info!("zesdex gateway starting");
// ── Dispatch to interface ────────────────────────────────────────────
// Validate mutually exclusive flags
let mode_count = [cli.daemon, cli.api, cli.ws, cli.grpc, cli.web]
.iter()
.filter(|&&b| b)
.count()
+ if cli.attach.is_some() { 1 } else { 0 };
if mode_count > 1 {
anyhow::bail!(
"Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive"
);
}
if cli.daemon {
tracing::info!("starting in daemon mode");
zesdex_daemon::server::run_daemon()?;
} else if let Some(session_id) = cli.attach {
tracing::info!("starting in attach mode for session {session_id}");
zesdex_daemon::client::run_attach(&session_id)?;
} else if cli.api {
tracing::info!("starting in API server mode");
run_api_server(cli.api_port)?;
} else if cli.ws {
tracing::info!("starting in WebSocket server mode");
run_ws_server(cli.ws_port)?;
} else if cli.grpc {
tracing::info!("starting in gRPC server mode");
run_grpc_server(cli.grpc_port)?;
} else if cli.web {
tracing::info!("starting in web server mode");
run_web_server(cli.web_port)?;
} else {
// Default: run TUI single-process mode
tracing::info!("starting in TUI single-process mode");
run_tui_single_process()?;
}
Ok(())
}
/// Run the TUI in single-process mode (TUI + agent in one process).
fn run_tui_single_process() -> anyhow::Result<()> {
zesdex_tui::run_single_process()
}
/// Run the REST API server.
fn run_api_server(port: u16) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let store = zesdex_domain::core::Store::new();
store.ensure_dirs()?;
let state = zesdex_api::ApiState::new(
store.base_dir.clone(),
"dev-secret",
"",
"deepseek-v4-flash-free",
Some("https://opencode.ai/zen/v1".to_string()),
);
let app = zesdex_api::build_router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("REST API server listening on http://{addr}/api/v1/health");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok::<_, anyhow::Error>(())
})?;
Ok(())
}
/// Run the WebSocket server.
fn run_ws_server(port: u16) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { zesdex_ws::run_server(port).await })?;
Ok(())
}
/// Run the gRPC server.
fn run_grpc_server(port: u16) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { zesdex_grpc::run_server(port).await })?;
Ok(())
}
/// Serve the web frontend.
fn run_web_server(port: u16) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { zesdex_web::run_server(port, None).await })?;
Ok(())
}
@@ -1,20 +1,16 @@
[package] [package]
name = "zesdex-backend" name = "zesdex-infrastructure"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
# Infrastructure layer — concrete implementations of domain repository
# traits, application port traits, and all platform services.
# Depends on domain + application; NEVER on interfaces.
[dependencies] [dependencies]
# Workspace crates zesdex-domain = { path = "../domain" }
zesdex-entities = { path = "../zesdex-entities" } zesdex-application = { path = "../application" }
zesdex-utils = { path = "../zesdex-utils" }
zesdex-ipc = { path = "../zesdex-ipc" }
zesdex-iam = { path = "../zesdex-iam" }
zesdex-cms = { path = "../zesdex-cms" }
zesdex-middleware = { path = "../zesdex-middleware" }
zesdex-infra = { path = "../zesdex-infra" }
# External deps
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_yaml_ng.workspace = true serde_yaml_ng.workspace = true
@@ -23,10 +19,7 @@ uuid.workspace = true
anyhow.workspace = true anyhow.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true
reqwest.workspace = true reqwest.workspace = true
ratatui.workspace = true
crossterm.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
base64.workspace = true base64.workspace = true
sha2.workspace = true sha2.workspace = true
@@ -52,15 +45,10 @@ dom_smoothie.workspace = true
fast_html2md.workspace = true fast_html2md.workspace = true
scraper.workspace = true scraper.workspace = true
include_dir.workspace = true include_dir.workspace = true
rand_core = { version = "0.6", features = ["getrandom"] }
[[bin]] axum.workspace = true
name = "zesdex" tower.workspace = true
path = "src/main.rs" tower-http.workspace = true
argon2.workspace = true
[[bin]] jsonwebtoken.workspace = true
name = "seed" clap.workspace = true
path = "src/bin/seed.rs"
[[bin]]
name = "migrate"
path = "src/bin/migrate.rs"
+50
View File
@@ -0,0 +1,50 @@
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
use serde::{Deserialize, Serialize};
/// Standard JWT claims with optional session binding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
pub sub: String,
pub exp: u64,
pub iat: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
impl JwtClaims {
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
let iat = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
sub,
exp,
iat,
session_id,
}
}
}
/// Sign a set of claims into a JWT string using HS256.
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
let token = jsonwebtoken::encode(&header, &claims, &key)?;
Ok(token)
}
/// Verify a JWT string and return its claims.
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.validate_exp = true;
validation.required_spec_claims = ["sub", "exp", "iat"]
.iter()
.map(|&s| s.to_string())
.collect();
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
Ok(token_data.claims)
}
+6
View File
@@ -0,0 +1,6 @@
//! Auth service implementations: JWT signing/verification, Argon2 password
//! hashing, and OAuth loopback server.
pub mod jwt;
pub mod oauth_loopback;
pub mod password;
@@ -1,13 +1,5 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects. //! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
//!
//! Ported from `zesdex-backend::service::oauth::loopback` to centralise OAuth
//! primitives in the `zesdex-iam` crate.
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
@@ -19,40 +11,30 @@ pub struct LoopbackServer {
} }
impl LoopbackServer { impl LoopbackServer {
/// Bind to an OS-assigned free port on localhost.
///
/// Return: `Err` if the loopback interface can't be bound.
pub fn bind() -> std::io::Result<Self> { pub fn bind() -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?; let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
Ok(LoopbackServer { listener, port }) Ok(LoopbackServer { listener, port })
} }
/// The redirect URI to hand to the OAuth authorization endpoint.
pub fn redirect_uri(&self) -> String { pub fn redirect_uri(&self) -> String {
format!("http://127.0.0.1:{}/callback", self.port) format!("http://127.0.0.1:{}/callback", self.port)
} }
/// Block until one HTTP request arrives, then extract the `code` query param pub fn wait_for_code(
/// and validate that the `state` param matches the expected value. &self,
/// timeout_ms: u64,
/// Flow: accept one connection → apply read timeout → parse request line expected_state: &str,
/// → verify state matches → respond 200/400 depending on whether the code ) -> std::io::Result<String> {
/// was found and state matched.
///
/// Return: `Err(InvalidData)` if no `code` param is present or the state
/// doesn't match `expected_state`.
pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
let (mut stream, _) = self.listener.accept()?; let (mut stream, _) = self.listener.accept()?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?; stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
Self::read_callback(&mut stream, expected_state) Self::read_callback(&mut stream, expected_state)
} }
/// Read and parse a single HTTP callback request off `stream`, replying with a status page. fn read_callback(
/// stream: &mut TcpStream,
/// Why: writes the HTTP response before returning so the browser tab expected_state: &str,
/// shows a result regardless of whether the code was found. ) -> std::io::Result<String> {
fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
let mut buf = [0u8; 4096]; let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?; let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]); let request = String::from_utf8_lossy(&buf[..n]);
@@ -60,12 +42,25 @@ impl LoopbackServer {
let state = Self::extract_state(&request); let state = Self::extract_state(&request);
let state_ok = state.as_deref() == Some(expected_state); let state_ok = state.as_deref() == Some(expected_state);
let response = match (code.as_ref(), state_ok) { let response = match (code.as_ref(), state_ok) {
(Some(_), true) => "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization complete. You may close this tab.", (Some(_), true) => {
(Some(_), false) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nState mismatch — possible CSRF attack.", "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
(None, _) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing authorization code.", Authorization complete. You may close this tab."
}
(Some(_), false) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
State mismatch possible CSRF attack."
}
(None, _) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
Missing authorization code."
}
}; };
let _ = stream.write_all(response.as_bytes()); if let Err(e) = stream.write_all(response.as_bytes()) {
let _ = stream.flush(); tracing::warn!("OAuth loopback write error: {e}");
}
if let Err(e) = stream.flush() {
tracing::warn!("OAuth loopback flush error: {e}");
}
if !state_ok { if !state_ok {
return Err(std::io::Error::new( return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData, std::io::ErrorKind::InvalidData,
@@ -73,16 +68,10 @@ impl LoopbackServer {
)); ));
} }
code.ok_or_else(|| { code.ok_or_else(|| {
std::io::Error::new( std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
std::io::ErrorKind::InvalidData,
"code not found in callback",
)
}) })
} }
/// Extract and percent-decode the `code` query parameter from an HTTP request line.
///
/// Return: `None` if the request is malformed or has no `code` param.
fn extract_code(request: &str) -> Option<String> { fn extract_code(request: &str) -> Option<String> {
let line = request.lines().next()?; let line = request.lines().next()?;
let path = line.split(' ').nth(1)?; let path = line.split(' ').nth(1)?;
@@ -96,9 +85,6 @@ impl LoopbackServer {
None None
} }
/// Extract the `state` query parameter from an HTTP request line.
///
/// Return: `None` if the request is malformed or has no `state` param.
fn extract_state(request: &str) -> Option<String> { fn extract_state(request: &str) -> Option<String> {
let line = request.lines().next()?; let line = request.lines().next()?;
let path = line.split(' ').nth(1)?; let path = line.split(' ').nth(1)?;
@@ -114,10 +100,6 @@ impl LoopbackServer {
} }
/// Percent-decode a string (e.g. `%20` -> space). /// Percent-decode a string (e.g. `%20` -> space).
///
/// Why: invalid escape sequences (missing/non-hex digits) are passed through
/// literally as `%` rather than erroring, since this only handles a redirect
/// query param, not untrusted binary data.
fn urlencoding(s: &str) -> String { fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len()); let mut result = String::with_capacity(s.len());
let mut chars = s.chars(); let mut chars = s.chars();
@@ -127,7 +109,10 @@ fn urlencoding(s: &str) -> String {
chars.next().and_then(|c| c.to_digit(16)), chars.next().and_then(|c| c.to_digit(16)),
chars.next().and_then(|c| c.to_digit(16)), chars.next().and_then(|c| c.to_digit(16)),
) { ) {
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)), (Some(hi), Some(lo)) => {
let byte: u8 = (hi as u8) * 16 + lo as u8;
result.push(char::from(byte));
}
_ => { _ => {
result.push('%'); result.push('%');
} }
+39
View File
@@ -0,0 +1,39 @@
//! Argon2 password hashing and verification utilities.
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rand_core::OsRng;
/// Hash a plaintext password using Argon2id with a random salt.
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
let password = password.to_string();
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
Ok(hash.to_string())
})
.await
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
}
/// Verify a plaintext password against a previously-hashed PHC string.
pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
let password = password.to_string();
let hash = hash.to_string();
tokio::task::spawn_blocking(move || {
let parsed_hash = PasswordHash::new(&hash)
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
let argon2 = Argon2::default();
let valid = argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
Ok(valid)
})
.await
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
}
+78
View File
@@ -0,0 +1,78 @@
//! Background bash control — list, cancel, and inspect background processes.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use tracing::error;
use super::job::BashJob;
/// Global accessor for the shared BashControl singleton.
///
/// Used by the Bash tool (to register jobs) and BashKill (to look them up).
pub fn bash_control() -> &'static BashControl {
static BASH_CONTROL: OnceLock<BashControl> = OnceLock::new();
BASH_CONTROL.get_or_init(BashControl::new)
}
/// Central registry of all running background bash jobs.
pub struct BashControl {
jobs: Mutex<HashMap<String, Arc<BashJob>>>,
}
impl BashControl {
pub fn new() -> Self {
BashControl {
jobs: Mutex::new(HashMap::new()),
}
}
}
impl Default for BashControl {
fn default() -> Self {
Self::new()
}
}
impl BashControl {
/// Register a new background job.
pub fn register(&self, job: Arc<BashJob>) {
if let Ok(mut guard) = self.jobs.lock() {
guard.insert(job.id.clone(), job);
}
}
/// Cancel a job by ID.
pub fn cancel(&self, id: &str) -> bool {
if let Ok(mut guard) = self.jobs.lock() {
if let Some(job) = guard.remove(id) {
job.cancel();
return true;
}
}
false
}
/// List all active jobs.
pub fn list(&self) -> Vec<(String, String, bool)> {
let mut guard = match self.jobs.lock() {
Ok(g) => g,
Err(poisoned) => {
error!("bgbash jobs mutex poisoned, recovering");
poisoned.into_inner()
}
};
guard.retain(|_, j| j.is_running());
guard
.iter()
.map(|(id, job)| (id.clone(), job.command.clone(), job.is_running()))
.collect()
}
/// Clean up completed jobs.
pub fn prune(&self) {
if let Ok(mut guard) = self.jobs.lock() {
guard.retain(|_, j| j.is_running());
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Background bash job — spawns a `bash -c` subprocess and tracks its life.
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing::error;
/// A handle to a spawned background bash job.
pub struct BashJob {
pub id: String,
pub command: String,
pub process: Mutex<Option<Child>>,
pub cancelled: AtomicBool,
}
/// Spawn a background bash job and return a handle.
///
/// The job runs until completion or until `cancel()` is called.
pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
let child = Command::new("bash")
.arg("-c")
.arg(&cmd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok();
let job = Arc::new(BashJob {
id: uuid::Uuid::new_v4().to_string(),
command: cmd,
process: Mutex::new(child),
cancelled: AtomicBool::new(false),
});
// Spawn a monitor thread using try_wait() polling so the lock is never
// held across a blocking wait, allowing cancel() to acquire the lock.
let job_clone = Arc::clone(&job);
std::thread::spawn(move || {
loop {
let mut exited = false;
{
let mut guard = match job_clone.process.lock() {
Ok(g) => g,
Err(poisoned) => {
error!("bgbash job mutex poisoned, recovering");
poisoned.into_inner()
}
};
if let Some(ref mut child) = *guard {
match child.try_wait() {
Ok(Some(_)) => exited = true,
Ok(None) => {} // still running
Err(e) => {
error!("bgbash wait error: {e}");
exited = true;
}
}
} else {
exited = true; // no child process
}
} // lock is dropped here — cancel() can now acquire it
if exited || job_clone.cancelled.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
});
job
}
impl BashJob {
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
if let Ok(mut guard) = self.process.lock() {
if let Some(ref mut child) = *guard {
let _ = child.kill();
let _ = child.wait();
}
}
}
pub fn is_running(&self) -> bool {
if self.cancelled.load(Ordering::SeqCst) {
return false;
}
let Ok(mut guard) = self.process.lock() else {
return false;
};
guard.as_mut().is_some_and(|c| matches!(c.try_wait(), Ok(None)))
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Background bash job management — spawn, track, and query long-running
//! shell processes.
pub mod control;
pub mod job;
+3
View File
@@ -0,0 +1,3 @@
//! Tool gate — per-tool access control and permissions.
pub mod patterns;
+37
View File
@@ -0,0 +1,37 @@
//! Tool usage patterns — detect dangerous or suspicious tool invocations.
/// Check whether a tool invocation matches a known dangerous pattern.
///
/// Returns a description of the risk if the pattern matches, or `None`
/// if the invocation appears safe.
pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> {
match tool_name {
"bash" => {
let cmd = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
// Detect git push with --force
if cmd.contains("git push") && cmd.contains("--force") {
return Some("Force-pushing to git is destructive and may lose history".to_string());
}
// Detect rm -rf /
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
return Some("Recursive deletion of the root filesystem is never allowed".to_string());
}
}
"delete" => {
let path = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
if path == "/" || path.starts_with("/etc") {
return Some(format!("Deleting '{}' is too dangerous", path));
}
}
_ => {
tracing::debug!("no guard pattern registered for tool: {tool_name}");
}
}
None
}
+36
View File
@@ -0,0 +1,36 @@
//! IPC client — connects to the daemon's Unix socket and sends/receives
//! framed JSON messages.
use std::os::unix::net::UnixStream;
use std::sync::Mutex;
/// A thread-safe IPC client connected to a Zesdex daemon over a Unix socket.
pub struct IpcClient {
conn: Mutex<crate::ipc::conn::Connection>,
}
impl IpcClient {
pub fn connect_unix(path: &str) -> anyhow::Result<Self> {
let stream = UnixStream::connect(path)?;
let conn = crate::ipc::conn::Connection::new(stream);
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn send<T: serde::Serialize>(&self, msg: &T) -> anyhow::Result<()> {
let mut guard = self
.conn
.lock()
.expect("IpcClient mutex poisoned");
guard.send(msg)
}
pub fn receive<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<Option<T>> {
let mut guard = self
.conn
.lock()
.expect("IpcClient mutex poisoned");
guard.receive()
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Connection wrapper around a Unix socket stream,
//! pairing a buffered reader with a raw writer.
use std::io::BufReader;
use std::os::unix::net::UnixStream;
/// A framed JSON connection over a Unix socket.
pub struct Connection {
reader: BufReader<UnixStream>,
writer: UnixStream,
}
impl Connection {
pub fn new(stream: UnixStream) -> Self {
let reader = BufReader::new(
stream
.try_clone()
.expect("UnixStream::try_clone should never fail on Linux"),
);
let writer = stream;
Self { reader, writer }
}
pub fn send<T: serde::Serialize>(&mut self, msg: &T) -> anyhow::Result<()> {
let json = serde_json::to_vec(msg)?;
crate::ipc::frame::write_frame(&mut self.writer, &json)?;
Ok(())
}
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> anyhow::Result<Option<T>> {
let raw = crate::ipc::frame::read_frame(&mut self.reader)?;
match raw {
None => Ok(None),
Some(bytes) => {
let msg: T = serde_json::from_slice(&bytes)?;
Ok(Some(msg))
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Length-prefixed framing for Unix-socket IPC.
//!
//! Every message on the wire is encoded as:
//! ```text
//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
//! ```
use anyhow::Context;
use std::io::{Read, Write};
const MAX_PAYLOAD: u32 = 64 * 1024 * 1024;
/// Read one length-prefixed frame from `reader`.
pub fn read_frame(reader: &mut impl Read) -> anyhow::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
match reader.read_exact(&mut len_buf) {
Ok(()) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(None);
}
Err(e) => return Err(e).context("failed to read frame length prefix"),
}
let payload_len = u32::from_be_bytes(len_buf) as usize;
if payload_len > MAX_PAYLOAD as usize {
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
}
let mut payload = vec![0u8; payload_len];
reader.read_exact(&mut payload)?;
Ok(Some(payload))
}
/// Write one length-prefixed frame to `writer`.
pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> anyhow::Result<()> {
let payload_len: u32 = data.len().try_into()?;
if payload_len > MAX_PAYLOAD {
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
}
let len_bytes = payload_len.to_be_bytes();
writer.write_all(&len_bytes)?;
writer.write_all(data)?;
writer.flush()?;
Ok(())
}
+7
View File
@@ -0,0 +1,7 @@
//! Unix-socket IPC layer for daemon/client communication.
pub mod client;
pub mod conn;
pub mod frame;
pub mod protocol;
pub mod server;
+85
View File
@@ -0,0 +1,85 @@
//! Wire types for the Zesdex IPC protocol.
use serde::{Deserialize, Serialize};
/// A resolved key press sent from the daemon to the client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum KeyAction {
Char(char),
Enter,
Escape,
Backspace,
Delete,
Tab,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Function(u8),
}
/// A message sent from the TUI client to the daemon over the IPC socket.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientRequest {
Tick,
KeyPress {
key: KeyAction,
ctrl: bool,
alt: bool,
shift: bool,
},
Submit(String),
Paste(String),
Resize(u16, u16),
Close,
ScrollUp,
ScrollDown,
}
/// A single chat message within a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEntry {
pub role: String,
pub content: String,
pub timestamp: i64,
}
/// A transient toast notification sent to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastEntry {
pub kind: String,
pub message: String,
pub created_at: i64,
pub lifetime_ms: u64,
}
/// Full UI state snapshot pushed from the daemon to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatePayload {
pub session_id: String,
pub messages: Vec<MessageEntry>,
pub edit_count: u32,
pub message_count: usize,
pub overlay: Option<String>,
pub toasts: Vec<ToastEntry>,
pub dirty: bool,
pub input_buffer: String,
pub input_cursor: usize,
}
/// A frame sent from the daemon to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DaemonFrame {
StateUpdate(Box<StatePayload>),
StreamToken(String),
SystemNote {
kind: String,
message: String,
},
ClipboardCopy(String),
Closed,
}
+26
View File
@@ -0,0 +1,26 @@
//! IPC server — binds a Unix socket and accepts incoming client connections.
use std::os::unix::net::UnixListener;
use std::path::Path;
/// A Unix-socket IPC server.
pub struct IpcServer {
listener: UnixListener,
}
impl IpcServer {
pub fn bind_unix(path: &str) -> anyhow::Result<Self> {
let p = Path::new(path);
if p.exists() {
std::fs::remove_file(p)?;
}
let listener = UnixListener::bind(path)?;
Ok(Self { listener })
}
pub fn accept(&self) -> anyhow::Result<crate::ipc::conn::Connection> {
let (stream, _addr) = self.listener.accept()?;
Ok(crate::ipc::conn::Connection::new(stream))
}
}
+350
View File
@@ -0,0 +1,350 @@
//! # Zesdex Infrastructure Layer
//!
//! ALL concrete implementations of domain repository traits, application port
//! traits, and platform services. This is the outermost ring of the Clean
//! Architecture onion — it depends on `zesdex-domain` and `zesdex-application`
//! but NEVER on interface/presentation crates.
//!
//! ## Architecture
//!
//! ```text
//! src/
//! ├── lib.rs — Foundational types + re-exports
//! ├── utils.rs — CastOr, write_json_atomic, slugify
//! ├── persistence/ — Repository implementations (IAM, CMS, SQLite)
//! ├── auth/ — JWT, Argon2, OAuth loopback
//! ├── llm/ — LLM provider HTTP client
//! ├── ipc/ — Unix-socket IPC protocol
//! ├── lsp/ — Native LSP client + provisioner
//! ├── mcp/ — Model Context Protocol bridge
//! ├── bgbash/ — Background bash job management
//! ├── tools/ — All 37 agent-invocable tools
//! ├── subagent/ — Subagent spawning & execution engine
//! ├── workflow/ — Hive-mind orchestration engine
//! ├── review/ — Post-edit auto-review subagent
//! ├── guard/ — Tool-gate access control
//! └── middleware/ — Axum HTTP middleware (auth, cors, rate-limit)
//! ```
pub mod auth;
pub mod bgbash;
pub mod guard;
pub mod ipc;
pub mod llm;
pub mod lsp;
pub mod mcp;
pub mod middleware;
pub mod persistence;
pub mod review;
pub mod subagent;
pub mod tools;
pub mod utils;
pub mod workflow;
// ---------------------------------------------------------------------------
// Re-exports from domain
// ---------------------------------------------------------------------------
pub use zesdex_domain::*;
// ---------------------------------------------------------------------------
// Foundation types — these replace `crate::app::state::*` references
// from the legacy backend code.
// ---------------------------------------------------------------------------
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
/// invoking a tool, used to scope permissions and tag log/output paths.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum Origin {
/// The main agent turn loop.
Main,
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
SubAgent,
/// The auto-inline review step after an edit.
Reviewer,
}
impl Origin {
/// Short string tag for this origin, used in filenames and logs.
pub fn tag(self) -> String {
match self {
Origin::Main => "main",
Origin::SubAgent => "subagent",
Origin::Reviewer => "reviewer",
}
.to_string()
}
}
/// Severity/category of a toast notification, used to pick its color.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind {
Info,
Success,
Warning,
Error,
Lesson,
}
/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Toast {
pub kind: ToastKind,
pub message: String,
pub created_at: i64,
pub lifetime_ms: u64,
}
impl Toast {
/// Create a toast with a default 5-second lifetime, stamped with now.
pub fn new(kind: ToastKind, message: String) -> Self {
Toast {
kind,
message,
created_at: chrono::Utc::now().timestamp_millis(),
lifetime_ms: 5000,
}
}
/// Whether this toast's lifetime has elapsed as of `now_ms`.
pub fn expired(&self, now_ms: i64) -> bool {
let lifetime = self.lifetime_ms as i64;
now_ms - self.created_at > lifetime
}
}
/// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame.
#[derive(Clone)]
pub struct DirCache {
entries: Arc<tokio::sync::RwLock<Vec<PathBuf>>>,
}
impl DirCache {
pub fn new() -> Self {
DirCache {
entries: Arc::new(tokio::sync::RwLock::new(Vec::new())),
}
}
pub async fn set(&self, paths: Vec<PathBuf>) {
let mut w = self.entries.write().await;
*w = paths;
}
}
impl Default for DirCache {
fn default() -> Self {
Self::new()
}
}
/// A shared, whole-workspace file-path index used for `@file` mention
/// autocomplete.
#[derive(Clone)]
pub struct MentionIndex {
entries: Arc<std::sync::RwLock<Vec<String>>>,
}
impl MentionIndex {
pub fn new() -> Self {
MentionIndex {
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
}
}
pub fn set(&self, paths: Vec<String>) {
if let Ok(mut w) = self.entries.write() {
*w = paths;
}
}
pub fn push(&self, path: String) {
if let Ok(mut w) = self.entries.write() {
w.push(path);
}
}
pub fn snapshot(&self) -> Vec<String> {
self.entries.read().map(|r| r.clone()).unwrap_or_default()
}
}
impl Default for MentionIndex {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// TurnEvent & runtime types
// ---------------------------------------------------------------------------
/// Events emitted onto the turn-event queue while an agent turn runs,
/// consumed by the event loop to update state and drive re-renders.
#[derive(Debug, Clone)]
pub enum TurnEvent {
AssistantMessage(ChatMessage),
ToolResult {
tool_call_id: String,
tool_name: String,
output: String,
is_error: bool,
path: Option<String>,
},
SystemNote {
kind: String,
message: String,
},
StreamStart,
StreamToken(String),
StreamDone(ChatMessage),
Usage {
tokens_in: u64,
tokens_out: u64,
},
ReviewUsage {
tokens_in: u64,
tokens_out: u64,
},
Compacted(Vec<ChatMessage>),
Error(String),
Done,
WorkflowAgentUpdate {
agent_id: String,
agent_name: String,
status: crate::AgentStatus,
},
}
/// A tool call awaiting execution, along with which execution model
/// (inline, deferred, async) it should run under.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingTool {
pub tool_name: String,
pub args: serde_json::Value,
pub execution_model: ExecutionModel,
}
/// How a pending tool call should be executed when the turn resumes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExecutionModel {
Inline,
Deferred,
AsyncTokio,
}
/// Reference to a background bash job tracked in session state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BashJobRef {
pub id: String,
pub command: String,
pub started_at: i64,
pub running: bool,
}
/// Per-session runtime state: message history, pending tool queue,
/// background bash jobs, lesson/review counters.
#[derive(Debug, Clone)]
pub struct SessionRuntime {
pub messages: Vec<ChatMessage>,
pub tool_call_results: Vec<ToolCallResult>,
pub pending_tool_queue: Vec<PendingTool>,
pub bash_jobs: Vec<BashJobRef>,
pub subagent_queue: usize,
pub edit_count: u32,
pub consecutive_empty_reviews: u32,
pub session_start: i64,
pub lesson_count: u32,
pub lessons_user: u32,
pub lessons_feedback: u32,
pub lessons_project: u32,
pub lessons_reference: u32,
pub lessons_active: u32,
pub lessons_stale: u32,
pub lessons_contradicted: u32,
pub lessons_human: u32,
pub lessons_verified: u32,
pub lessons_unverified: u32,
pub review_count: u32,
pub session_dir: PathBuf,
pub usage: UsageStats,
pub hive_mind_converged: bool,
}
impl SessionRuntime {
pub fn new(session_dir: PathBuf) -> Self {
SessionRuntime {
messages: Vec::new(),
tool_call_results: Vec::new(),
pending_tool_queue: Vec::new(),
bash_jobs: Vec::new(),
subagent_queue: 0,
edit_count: 0,
consecutive_empty_reviews: 0,
session_start: chrono::Utc::now().timestamp_millis(),
lesson_count: 0,
lessons_user: 0,
lessons_feedback: 0,
lessons_project: 0,
lessons_reference: 0,
lessons_active: 0,
lessons_stale: 0,
lessons_contradicted: 0,
lessons_human: 0,
lessons_verified: 0,
lessons_unverified: 0,
review_count: 0,
session_dir,
usage: UsageStats::default(),
hive_mind_converged: false,
}
}
pub fn push_message(&mut self, msg: ChatMessage) {
self.messages.push(msg);
}
}
/// Simple ASCII progress display for a long-running operation.
#[derive(Debug, Clone)]
pub struct ProgressState {
pub current: u64,
pub total: u64,
pub message: String,
pub start_time: i64,
}
/// Agent status for workflow engine progress tracking.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AgentStatus {
Pending,
Running,
Completed,
Failed(String),
Cancelled,
}
impl std::fmt::Display for AgentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentStatus::Pending => write!(f, "pending"),
AgentStatus::Running => write!(f, "running"),
AgentStatus::Completed => write!(f, "completed"),
AgentStatus::Failed(msg) => write!(f, "failed: {msg}"),
AgentStatus::Cancelled => write!(f, "cancelled"),
}
}
}
// ---------------------------------------------------------------------------
// Tool types — needed by all tool modules
// ---------------------------------------------------------------------------
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
// Re-export commonly needed types at the crate root
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult};
+5
View File
@@ -0,0 +1,5 @@
//! LLM provider HTTP client for OpenAI/Anthropic-compatible chat completion APIs.
pub mod provider;
pub use provider::{resolve_api_key, LlmClient};
+486
View File
@@ -0,0 +1,486 @@
//! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs,
//! supporting both non-streaming and SSE-streaming requests with automatic retry.
use rand_core::RngCore;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use zesdex_domain::core::{
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
};
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
pub const DEFAULT_API_KEY: &str = "";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
// ---------------------------------------------------------------------------
// Retry helpers
// ---------------------------------------------------------------------------
fn backoff_seconds(attempt: u32, cap: u64) -> Duration {
let base = 2u64.pow(attempt.saturating_sub(1));
let delay = std::cmp::min(base, cap);
// ±25% jitter
let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0;
Duration::from_secs_f64(delay as f64 * jitter_factor)
}
/// Is the error an auth / billing failure that retrying won't fix?
pub fn is_auth_error(err_str: &str) -> bool {
let err_lower = err_str.to_lowercase();
(err_str.contains("API error 401")
|| err_str.contains("API error 402")
|| err_str.contains("API error 403"))
|| err_lower.contains("unauthorized")
|| err_lower.contains("forbidden")
|| err_lower.contains("authentication failed")
}
fn is_rate_limit(err_str: &str) -> bool {
err_str.contains("API error 429") || err_str.to_lowercase().contains("rate limit")
}
fn backoff_for_error(attempt: u32, err_str: &str) -> Duration {
if is_rate_limit(err_str) {
backoff_seconds(attempt, 60)
} else {
backoff_seconds(attempt, 30)
}
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
/// Blocking HTTP client for a single LLM provider endpoint.
pub struct LlmClient {
pub client: reqwest::blocking::Client,
pub api_key: String,
pub base_url: String,
pub model: String,
}
impl LlmClient {
pub fn new(mut api_key: String, model: String, base_url: Option<String>) -> Self {
if api_key.is_empty() {
api_key = DEFAULT_API_KEY.to_string();
}
let model = if model.is_empty() {
DEFAULT_MODEL.to_string()
} else {
model
};
let client = match reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(
"failed to build reqwest client with connect timeout: {}. \
retrying without connect timeout",
e,
);
match reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
{
Ok(c) => c,
Err(e2) => {
tracing::warn!("also failed: {e2}. using default client");
reqwest::blocking::Client::new()
}
}
}
};
LlmClient {
client,
api_key,
base_url: base_url
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
model,
}
}
pub fn chat_with_tools_non_streaming(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
max_tokens: Option<u32>,
temperature: Option<f32>,
abort_flag: Option<&AtomicBool>,
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
let req = ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(max_tokens.unwrap_or(4096)),
temperature: Some(temperature.unwrap_or(0.7)),
tools,
stream: Some(false),
stop: None,
stream_options: None,
tool_choice: None,
top_p: None,
};
let url = format!("{}/chat/completions", self.base_url);
let max_retries = 10;
let mut attempt = 0u32;
loop {
attempt += 1;
if let Some(flag) = abort_flag {
if flag.load(std::sync::atomic::Ordering::Relaxed) {
anyhow::bail!("aborted");
}
}
let mut http_req = self
.client
.post(&url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req =
http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let result =
(|| -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
let resp = http_req.json(&req).send().map_err(|e| {
if e.is_timeout() {
anyhow::anyhow!(
"API request timed out after {REQUEST_TIMEOUT:?}. \
Check your network or try again."
)
} else if e.is_connect() {
anyhow::anyhow!(
"Could not connect to {}. \
Is the URL correct and is the service reachable?",
self.base_url
)
} else {
anyhow::anyhow!("API request failed: {e}")
}
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
}
let data: ChatResponse = resp.json()?;
let usage = data.usage.map(|u| {
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens))
});
let message = data
.choices
.into_iter()
.next()
.and_then(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
Ok((message, usage))
})();
match result {
Ok((msg, usage)) => return Ok((msg, usage)),
Err(e) => {
let err_str = e.to_string();
if attempt >= max_retries || is_auth_error(&err_str) {
return Err(e);
}
let delay = backoff_for_error(attempt, &err_str);
std::thread::sleep(delay);
}
}
}
}
pub fn chat_with_tools_streaming(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
temperature: Option<f32>,
max_tokens: Option<u32>,
mut on_event: impl FnMut(&StreamEvent) -> bool,
abort_flag: Option<&AtomicBool>,
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
let tools_for_fallback = tools.clone();
let req = ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(max_tokens.unwrap_or(4096)),
temperature: Some(temperature.unwrap_or(0.7)),
tools,
stream: Some(true),
stop: None,
stream_options: Some(StreamOptions {
include_usage: true,
}),
tool_choice: None,
top_p: None,
};
let url = format!("{}/chat/completions", self.base_url);
let max_retries_stream = 5;
let mut attempt = 0u32;
let mut meaningful_content = false;
loop {
attempt += 1;
let mut captured_content = false;
let mut wrapped = |event: &StreamEvent| -> bool {
match event {
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
captured_content = true;
}
_ => {
tracing::debug!("unhandled stream event type in wrapped closure");
}
}
on_event(event)
};
match self.try_stream_once(&req, &url, &mut wrapped) {
Ok(result) => return Ok(result),
Err(e) => {
let err_str = e.to_string();
if is_auth_error(&err_str) {
return Err(e);
}
if captured_content || (attempt >= max_retries_stream) {
meaningful_content = captured_content || meaningful_content;
break;
}
if attempt >= max_retries_stream {
return Err(e);
}
let delay = backoff_for_error(attempt, &err_str);
std::thread::sleep(delay);
}
}
}
if meaningful_content {
if let Some(flag) = abort_flag {
if flag.load(std::sync::atomic::Ordering::Relaxed) {
return Err(anyhow::anyhow!("aborted"));
}
}
return self.chat_with_tools_non_streaming(
messages,
tools_for_fallback,
max_tokens,
temperature,
abort_flag,
);
}
Err(anyhow::anyhow!(
"streaming request failed after {max_retries_stream} attempts"
))
}
fn try_stream_once(
&self,
req: &ChatRequest,
url: &str,
on_event: &mut dyn FnMut(&StreamEvent) -> bool,
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
use std::io::Read;
let mut http_req = self
.client
.post(url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req =
http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let resp = http_req.json(req).send().map_err(|e| {
if e.is_timeout() {
anyhow::anyhow!(
"API request timed out after {REQUEST_TIMEOUT:?}. \
Check your network or try again."
)
} else if e.is_connect() {
anyhow::anyhow!(
"Could not connect to {}. \
Is the URL correct and is the service reachable?",
self.base_url
)
} else {
anyhow::anyhow!("API request failed: {e}")
}
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
}
struct StreamedTurn {
content: String,
tool_calls: Vec<zesdex_domain::core::ToolCall>,
done_received: bool,
}
impl StreamedTurn {
fn new() -> Self {
StreamedTurn {
content: String::new(),
tool_calls: Vec::new(),
done_received: false,
}
}
fn apply_event(&mut self, event: &StreamEvent) {
match event {
StreamEvent::Token(t) => self.content.push_str(t),
StreamEvent::Reasoning(_) => {}
StreamEvent::ToolCallDelta {
index: _,
id,
name,
arguments_delta,
} => {
let existing = self.tool_calls.iter_mut().find(|tc| {
if let Some(ref id_val) = id {
tc.id == *id_val
} else {
false
}
});
if let Some(tc) = existing {
if let Some(ref n) = name {
tc.function.name = n.clone();
}
} else {
self.tool_calls.push(
zesdex_domain::core::ToolCall {
id: id.clone().unwrap_or_default(),
type_: "function".to_string(),
function: zesdex_domain::core::ToolFunction {
name: name.clone().unwrap_or_default(),
arguments: serde_json::Value::String(arguments_delta.clone()),
},
},
);
}
}
_ => {
tracing::debug!("unhandled stream event in apply_event: {event:?}");
}
}
}
fn build_assistant_message(self) -> ChatMessage {
ChatMessage {
role: zesdex_domain::core::Role::Assistant,
content: if self.content.is_empty() {
None
} else {
Some(self.content)
},
tool_calls: if self.tool_calls.is_empty() {
None
} else {
Some(self.tool_calls)
},
tool_call_id: None,
name: None,
}
}
}
let mut turn = StreamedTurn::new();
let mut usage: Option<(u64, u64)> = None;
let mut parser = SseParser::new();
let mut reader = resp;
let mut byte_buf: Vec<u8> = Vec::new();
let mut chunk_buf = [0u8; 4096];
loop {
let n = reader.read(&mut chunk_buf)?;
if n == 0 {
break;
}
byte_buf.extend_from_slice(&chunk_buf[..n]);
let valid_len = match std::str::from_utf8(&byte_buf) {
Ok(s) => s.len(),
Err(e) => e.valid_up_to(),
};
if valid_len == 0 {
continue;
}
let text =
String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
byte_buf.drain(..valid_len);
for event in parser.feed(&text) {
if !on_event(&event) {
anyhow::bail!("aborted");
}
match &event {
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
..
} => {
usage = Some((*prompt_tokens, *completion_tokens));
}
StreamEvent::Error(msg) => {
anyhow::bail!("stream error: {msg}");
}
StreamEvent::Done => {
turn.apply_event(&event);
turn.done_received = true;
return Ok((turn.build_assistant_message(), usage));
}
other => {
tracing::debug!("unhandled stream event type: {other:?}");
turn.apply_event(other);
}
}
}
}
Ok((turn.build_assistant_message(), usage))
}
}
/// Resolve the API key for the currently configured provider, falling back
/// through settings -> env var -> provider default.
pub fn resolve_api_key(
settings: &zesdex_domain::cms::Settings,
app_config: &zesdex_domain::cms::AppConfig,
) -> String {
let provider = &settings.provider;
let mut api_key = settings
.api_keys
.get(provider)
.cloned()
.unwrap_or_default();
if api_key.is_empty() {
if let Some(provider_cfg) = app_config.providers.get(provider) {
api_key = provider_cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
.or_else(|| provider_cfg.default_api_key.clone())
.unwrap_or_default();
}
}
api_key
}
+124
View File
@@ -0,0 +1,124 @@
//! LSP client — sends JSON-RPC requests to language servers.
use anyhow::Result;
use serde_json::Value;
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::Mutex;
use tracing::{debug, info};
/// Mutable inner state of an LSP client, protected by a mutex so that
/// `send_request` and `shutdown` can be called via `&self` (required by
/// [`LspManager`](super::manager::LspManager)).
struct LspClientInner {
process: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
request_id: u64,
}
/// A minimal but functional LSP client.
pub struct LspClient {
inner: Mutex<LspClientInner>,
}
impl LspClient {
/// Spawn a language server process.
pub fn start(command: &str, args: &[String]) -> Result<Self> {
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("no stdin on LSP process"))?;
let stdout = BufReader::new(child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout on LSP process"))?);
info!("LSP client spawned: {command}");
Ok(LspClient {
inner: Mutex::new(LspClientInner {
process: child,
stdin,
stdout,
request_id: 0,
}),
})
}
/// Send a JSON-RPC request and read the response.
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
let mut inner = match self.inner.lock() {
Ok(g) => g,
Err(poisoned) => {
tracing::error!("LSP client mutex poisoned, recovering");
poisoned.into_inner()
}
};
inner.request_id += 1;
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": inner.request_id,
"method": method,
"params": params.clone(),
});
// Write Content-Length header + body
let body = serde_json::to_string(&request)?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
inner.stdin.write_all(header.as_bytes())?;
inner.stdin.write_all(body.as_bytes())?;
inner.stdin.flush()?;
debug!("LSP request: {method} (id={})", inner.request_id);
// Read Content-Length header
let mut content_length = 0usize;
loop {
let mut line = String::new();
inner.stdout.read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() {
break; // end of headers
}
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
content_length = len_str.parse::<usize>()?;
}
}
// Read the JSON body
let mut buf = vec![0u8; content_length];
inner.stdout.read_exact(&mut buf)?;
let response: Value = serde_json::from_slice(&buf)?;
debug!("LSP response for {method}: response received");
Ok(response)
}
/// Gracefully shut down the server.
pub fn shutdown(&self) -> Result<()> {
let null = Value::Null;
if let Err(e) = self.send_request("shutdown", &null) {
tracing::warn!("LSP shutdown error: {e}");
}
if let Err(e) = self.send_request("exit", &null) {
tracing::warn!("LSP exit error: {e}");
}
if let Ok(mut inner) = self.inner.lock() {
let _ = inner.process.wait();
}
info!("LSP client shut down");
Ok(())
}
}
impl Drop for LspClient {
fn drop(&mut self) {
if let Ok(mut inner) = self.inner.lock() {
if let Err(e) = inner.process.kill() {
tracing::warn!("LSP process kill error: {e}");
}
let _ = inner.process.wait();
}
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Manages multiple LSP server processes, keyed by language ID.
//!
//! Each language (e.g. "rust", "python") maps to one `LspClient`.
//! The manager provides a unified `request` method that dispatches
//! to the correct client by language.
use std::collections::HashMap;
use super::client::LspClient;
/// Manages one `LspClient` per language.
pub struct LspManager {
clients: HashMap<String, LspClient>,
}
impl LspManager {
pub fn new() -> Self {
LspManager {
clients: HashMap::new(),
}
}
}
impl Default for LspManager {
fn default() -> Self {
Self::new()
}
}
impl LspManager {
pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> {
let client = LspClient::start(command, args)?;
self.clients.insert(language.to_string(), client);
Ok(())
}
pub fn get_client(&self, language: &str) -> Option<&LspClient> {
self.clients.get(language)
}
pub fn shutdown_all(&mut self) {
for client in self.clients.values() {
let _ = client.shutdown();
}
self.clients.clear();
}
pub fn languages(&self) -> Vec<String> {
self.clients.keys().cloned().collect()
}
pub fn is_empty(&self) -> bool {
self.clients.is_empty()
}
}
+6
View File
@@ -0,0 +1,6 @@
//! Native LSP client integration — manage language server processes and
//! dispatch requests for completion, hover, diagnostics, etc.
pub mod client;
pub mod manager;
pub mod provisioner;
@@ -0,0 +1,16 @@
//! Configuration for LSP language server provisioning.
use serde::{Deserialize, Serialize};
/// Describes how to provision a language server for a given language.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspProvisionerConfig {
/// Language identifier, e.g. "rust", "python".
pub language: String,
/// The command to start the language server.
pub command: String,
/// Arguments for the command.
pub args: Vec<String>,
/// How to install the language server (if not found).
pub install_hint: Option<String>,
}
@@ -0,0 +1,48 @@
//! Discovers installed language servers on the system PATH.
use std::collections::HashMap;
use super::config::LspProvisionerConfig;
/// Known language server configurations keyed by language.
fn known_configs() -> HashMap<&'static str, (&'static str, Vec<&'static str>)> {
let mut m = HashMap::new();
m.insert("rust", ("rust-analyzer", vec![]));
m.insert("python", ("pyright-langserver", vec!["--stdio"]));
m.insert("typescript", ("typescript-language-server", vec!["--stdio"]));
m.insert("javascript", ("typescript-language-server", vec!["--stdio"]));
m.insert("go", ("gopls", vec![]));
m
}
/// Check if a command is available on PATH.
fn command_exists(cmd: &str) -> bool {
std::env::var_os("PATH")
.and_then(|path| {
std::env::split_paths(&path).find_map(|dir| {
let full_path = dir.join(cmd);
if full_path.is_file() {
Some(())
} else {
None
}
})
})
.is_some()
}
/// Discover which language servers are already on PATH.
pub fn discover_installed() -> Vec<LspProvisionerConfig> {
let mut configs = Vec::new();
for (lang, (cmd, args)) in known_configs() {
if command_exists(cmd) {
configs.push(LspProvisionerConfig {
language: lang.to_string(),
command: cmd.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
install_hint: None,
});
}
}
configs
}
@@ -0,0 +1,32 @@
//! Installs language servers (non-interactive, via package managers or
//! direct download).
/// Install a language server for the given language.
///
/// Returns a success message or an error describing why installation failed.
pub fn install_language_server(language: &str) -> anyhow::Result<String> {
match language {
"rust" => {
// rust-analyzer is typically installed via rustup
let output = std::process::Command::new("rustup")
.args(["component", "add", "rust-analyzer"])
.output()?;
if output.status.success() {
Ok("rust-analyzer installed via rustup".to_string())
} else {
anyhow::bail!("failed to install rust-analyzer: {}", String::from_utf8_lossy(&output.stderr))
}
}
"python" => {
let output = std::process::Command::new("npm")
.args(["install", "-g", "pyright"])
.output()?;
if output.status.success() {
Ok("pyright installed via npm".to_string())
} else {
anyhow::bail!("failed to install pyright: {}", String::from_utf8_lossy(&output.stderr))
}
}
lang => anyhow::bail!("no install method known for language '{lang}'"),
}
}
@@ -0,0 +1,46 @@
//! High-level manager that discovers, installs (if needed), and starts
//! LSP servers.
use crate::lsp::manager::LspManager;
use super::discovery::discover_installed;
use super::install::install_language_server;
/// Auto-provision language servers for the given list of languages.
///
/// Flow: discover already-installed servers → for each requested language
/// not yet available, attempt auto-install → start each server.
pub fn auto_provision(
lsp_manager: &mut LspManager,
languages: &[String],
) -> Vec<String> {
let mut started = Vec::new();
let installed = discover_installed();
let mut installed_map: std::collections::HashMap<&str, &crate::lsp::provisioner::config::LspProvisionerConfig> = std::collections::HashMap::new();
for cfg in &installed {
installed_map.insert(cfg.language.as_str(), cfg);
}
for lang in languages {
if let Some(cfg) = installed_map.get(lang.as_str()) {
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
started.push(lang.clone());
}
} else {
// Not installed — try auto-install
if install_language_server(lang).is_ok() {
// Re-discover after install
let refreshed = discover_installed();
for cfg in refreshed {
if cfg.language == *lang {
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
started.push(lang.clone());
}
break;
}
}
}
}
}
started
}
@@ -0,0 +1,7 @@
//! LSP language server provisioner — discovers, installs, and manages
//! language server executables.
pub mod config;
pub mod discovery;
pub mod install;
pub mod manager;
+60
View File
@@ -0,0 +1,60 @@
//! Manages MCP server connections — start, stop, list, and dispatch
//! tool calls to remote MCP servers.
use std::collections::HashMap;
/// Metadata for a connected MCP server.
#[derive(Debug, Clone)]
pub struct McpServerHandle {
pub name: String,
pub transport: String,
}
/// Manages MCP server connections.
#[derive(Clone)]
pub struct McpManager {
servers: HashMap<String, McpServerHandle>,
}
impl McpManager {
pub fn new() -> Self {
McpManager {
servers: HashMap::new(),
}
}
}
impl Default for McpManager {
fn default() -> Self {
Self::new()
}
}
impl McpManager {
pub fn register(&mut self, name: &str, transport: &str) {
self.servers.insert(
name.to_string(),
McpServerHandle {
name: name.to_string(),
transport: transport.to_string(),
},
);
}
pub fn unregister(&mut self, name: &str) {
self.servers.remove(name);
}
pub fn list(&self) -> Vec<McpServerHandle> {
self.servers.values().cloned().collect()
}
pub fn get(&self, name: &str) -> Option<&McpServerHandle> {
self.servers.get(name)
}
pub fn is_empty(&self) -> bool {
self.servers.is_empty()
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Model Context Protocol (MCP) — bridge between agent tools and external MCP
//! servers using the rmcp crate.
pub mod manager;
pub mod transport;
+44
View File
@@ -0,0 +1,44 @@
//! MCP transport layer — manages child-process and HTTP-based transport
//! for connecting to MCP servers.
use std::process::{Child, Command, Stdio};
/// A running MCP server process connected via stdio.
pub struct McpTransport {
process: Option<Child>,
}
impl McpTransport {
pub fn start_child_process(command: &str, args: &[String]) -> anyhow::Result<Self> {
let child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
Ok(McpTransport {
process: Some(child),
})
}
pub fn stop(&mut self) -> anyhow::Result<()> {
if let Some(mut child) = self.process.take() {
if let Err(e) = child.kill() {
tracing::warn!("MCP transport kill error: {e}");
}
let _ = child.wait();
}
Ok(())
}
}
impl Drop for McpTransport {
fn drop(&mut self) {
if let Some(mut child) = self.process.take() {
if let Err(e) = child.kill() {
tracing::warn!("MCP transport kill error: {e}");
}
let _ = child.wait();
}
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Authentication middleware — session-lock based auth for Axum.
//!
//! Validates `X-Session-Id` header against the `SessionRepository` before
//! forwarding the request to the inner service.
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tower::{Layer, Service};
use zesdex_domain::auth::{SessionId, SessionRepository};
/// Identity extracted from a validated session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionIdentity {
pub session_id: String,
pub user_agent: String,
pub connected_at: i64,
}
impl SessionIdentity {
pub fn new(session_id: String, user_agent: String) -> Self {
let connected_at = chrono::Utc::now().timestamp();
Self {
session_id,
user_agent,
connected_at,
}
}
}
/// Tower Layer that produces SessionAuthMiddleware services.
///
/// Holds a reference to the `SessionRepository` and the base directory
/// needed to validate session IDs.
#[derive(Debug, Clone)]
pub struct SessionAuthLayer<R: SessionRepository + Send + Sync + 'static> {
base_dir: PathBuf,
repo: Arc<R>,
}
impl<R: SessionRepository + Send + Sync + 'static> SessionAuthLayer<R> {
pub fn new(base_dir: PathBuf, repo: Arc<R>) -> Self {
Self { base_dir, repo }
}
}
impl<S, R> Layer<S> for SessionAuthLayer<R>
where
R: SessionRepository + Send + Sync + 'static,
{
type Service = SessionAuthMiddleware<S, R>;
fn layer(&self, inner: S) -> Self::Service {
SessionAuthMiddleware {
inner,
base_dir: self.base_dir.clone(),
repo: self.repo.clone(),
}
}
}
/// Tower Service that validates X-Session-Id before forwarding.
#[derive(Debug, Clone)]
pub struct SessionAuthMiddleware<S, R: SessionRepository + Send + Sync + 'static> {
inner: S,
base_dir: PathBuf,
repo: Arc<R>,
}
impl<S, ReqBody, R> Service<Request<ReqBody>> for SessionAuthMiddleware<S, R>
where
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
R: SessionRepository + Send + Sync + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
let session_id = req
.headers()
.get("X-Session-Id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Validate the session against the repository.
match session_id {
Some(sid) => match SessionId::new(&sid) {
Ok(id) => match self.repo.load_session(&self.base_dir, &id) {
Ok(_session) => {
// Session is valid — forward the request.
let fut = self.inner.call(req);
return Box::pin(fut);
}
Err(_) => { /* fall through to 401 */ }
},
Err(_) => { /* fall through to 401 */ }
},
None => { /* fall through to 401 */ }
}
Box::pin(async move {
Ok((StatusCode::UNAUTHORIZED, "missing or invalid X-Session-Id header").into_response())
})
}
}
@@ -0,0 +1,23 @@
//! CORS layer factory for the daemon HTTP server.
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
/// Return a permissive CorsLayer for local daemon IPC.
pub fn default_cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(AllowOrigin::any())
.allow_methods([
"GET".parse().unwrap(),
"POST".parse().unwrap(),
"PUT".parse().unwrap(),
"DELETE".parse().unwrap(),
"PATCH".parse().unwrap(),
"OPTIONS".parse().unwrap(),
])
.allow_headers(AllowHeaders::any())
.expose_headers([
"Content-Type".parse().unwrap(),
"X-Session-Id".parse().unwrap(),
"X-Request-Id".parse().unwrap(),
])
}
@@ -0,0 +1,5 @@
//! Axum middleware tower for the HTTP API layer.
pub mod auth;
pub mod cors;
pub mod rate_limit;
@@ -0,0 +1,60 @@
//! Simple in-memory rate limiter for Axum.
use std::collections::HashMap;
use std::sync::Mutex;
/// In-memory sliding-window rate limiter.
#[derive(Debug)]
pub struct RateLimiter {
windows: Mutex<HashMap<String, Vec<i64>>>,
}
impl RateLimiter {
pub fn new() -> Self {
RateLimiter {
windows: Mutex::new(HashMap::new()),
}
}
pub fn check_rate_limit(
&self,
client_id: &str,
max_requests: u32,
window_secs: u64,
) -> anyhow::Result<bool> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let cutoff = now.saturating_sub(window_secs as i64);
let mut windows = self.windows.lock().map_err(|e| {
anyhow::anyhow!("rate limiter lock poisoned: {e}")
})?;
let timestamps = windows.entry(client_id.to_string()).or_insert_with(Vec::new);
timestamps.retain(|&ts| ts >= cutoff);
if timestamps.len() >= max_requests as usize {
return Ok(false);
}
timestamps.push(now);
Ok(true)
}
pub fn reset(&self) -> anyhow::Result<()> {
let mut windows = self
.windows
.lock()
.map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?;
windows.clear();
Ok(())
}
}
impl Default for RateLimiter {
fn default() -> Self {
Self::new()
}
}
@@ -1,38 +1,22 @@
//! JSON filebacked `AppConfigRepository`. //! JSON filebacked `AppConfigRepository` with Claude credential auto-detection.
//!
//! Path: `<base_dir>/app_config.json`
//!
//! On load, auto-detects Claude credentials from the environment or
//! `~/.claude/settings.json` and merges them into the provider map.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::io::Write;
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use zesdex_domain::cms::{AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError};
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig}; use crate::utils::write_json_atomic;
use crate::domain::repository::AppConfigRepository;
/// Persists `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`. /// File-based `AppConfigRepository` that reads/writes `app_config.json`.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct JsonAppConfigRepository; pub struct JsonAppConfigRepository;
impl JsonAppConfigRepository { impl JsonAppConfigRepository {
/// Create a new repository instance.
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
} }
/// Configuration structure inside `~/.claude/settings.json`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeEnv { struct ClaudeEnv {
#[serde(alias = "ANTHROPIC_BASE_URL")] #[serde(alias = "ANTHROPIC_BASE_URL")]
@@ -46,7 +30,6 @@ struct ClaudeSettings {
env: Option<ClaudeEnv>, env: Option<ClaudeEnv>,
} }
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
fn claude_credentials_from_file() -> Option<(String, String)> { fn claude_credentials_from_file() -> Option<(String, String)> {
let path = dirs::home_dir()?.join(".claude").join("settings.json"); let path = dirs::home_dir()?.join(".claude").join("settings.json");
let content = std::fs::read_to_string(&path).ok()?; let content = std::fs::read_to_string(&path).ok()?;
@@ -57,15 +40,12 @@ fn claude_credentials_from_file() -> Option<(String, String)> {
Some((base_url, key)) Some((base_url, key))
} }
/// Try to read Claude credentials from environment variables.
fn claude_credentials_from_env() -> Option<(String, String)> { fn claude_credentials_from_env() -> Option<(String, String)> {
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
let key = std::env::var("ANTHROPIC_API_KEY").ok()?; let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, key)) Some((base_url, key))
} }
/// Return a `ProviderConfig` for the Claude provider, checking both
/// `~/.claude/settings.json` and the process environment.
fn detect_claude_settings_provider() -> Option<ProviderConfig> { fn detect_claude_settings_provider() -> Option<ProviderConfig> {
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?; let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
Some(ProviderConfig { Some(ProviderConfig {
@@ -77,27 +57,21 @@ fn detect_claude_settings_provider() -> Option<ProviderConfig> {
} }
impl AppConfigRepository for JsonAppConfigRepository { impl AppConfigRepository for JsonAppConfigRepository {
fn load(&self, base_dir: &Path) -> Result<AppConfig> { fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
let path = base_dir.join("app_config.json"); let path = base_dir.join("app_config.json");
let mut cfg: AppConfig = match std::fs::read_to_string(&path) { let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s) Ok(s) => serde_json::from_str(&s)?,
.map_err(|e| anyhow::anyhow!("failed to parse app_config.json: {e}"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("app_config.json not found, using defaults");
AppConfig::default() AppConfig::default()
} }
Err(e) => { Err(e) => return Err(RepositoryError::Io(e)),
return Err(anyhow::anyhow!("failed to read app_config.json: {e}"));
}
}; };
// Merge any default providers not present in the loaded config
let defaults = AppConfig::default(); let defaults = AppConfig::default();
for (name, provider) in defaults.providers { for (name, provider) in defaults.providers {
cfg.providers.entry(name).or_insert(provider); cfg.providers.entry(name).or_insert(provider);
} }
// Auto-detect Claude provider
if let Some(claude_provider) = detect_claude_settings_provider() { if let Some(claude_provider) = detect_claude_settings_provider() {
cfg.providers cfg.providers
.entry("claude".to_string()) .entry("claude".to_string())
@@ -120,7 +94,6 @@ impl AppConfigRepository for JsonAppConfigRepository {
}); });
} }
// Set as default provider only if user hasn't picked a custom default
if cfg.default_provider == defaults.default_provider { if cfg.default_provider == defaults.default_provider {
cfg.default_provider = "claude".to_string(); cfg.default_provider = "claude".to_string();
cfg.default_model = "claude-opus-4-8".to_string(); cfg.default_model = "claude-opus-4-8".to_string();
@@ -130,36 +103,10 @@ impl AppConfigRepository for JsonAppConfigRepository {
Ok(cfg) Ok(cfg)
} }
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> { fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
std::fs::create_dir_all(base_dir) std::fs::create_dir_all(base_dir)?;
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
let path = base_dir.join("app_config.json"); let path = base_dir.join("app_config.json");
let tmp = base_dir.join("app_config.json.tmp"); write_json_atomic(&path, config, None)?;
let json =
serde_json::to_string_pretty(config).context("failed to serialize app config")?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"failed to rename '{}' -> '{}'",
tmp.display(),
path.display()
)
})?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
tracing::debug!("app_config saved to '{}'", path.display());
Ok(()) Ok(())
} }
} }
@@ -0,0 +1,34 @@
//! JSON filebacked `ConversationRepository`.
//! Stores `Conversation` at `<session_dir>/conversation.json`.
use std::path::Path;
use zesdex_domain::cms::{Conversation, ConversationRepository, RepositoryError};
use crate::utils::write_json_atomic;
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonConversationRepository;
impl JsonConversationRepository {
pub fn new() -> Self {
Self
}
}
impl ConversationRepository for JsonConversationRepository {
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError> {
let path = session_dir.join("conversation.json");
let data = std::fs::read_to_string(&path)?;
let conv: Conversation = serde_json::from_str(&data)?;
Ok(conv)
}
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> {
std::fs::create_dir_all(session_dir)?;
let path = session_dir.join("conversation.json");
write_json_atomic(&path, conversation, None)?;
Ok(())
}
}
@@ -0,0 +1,88 @@
//! JSONL filebacked `EditLogRepository`.
//! Stores `EditLog` as an append-only newline-delimited JSON file.
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use zesdex_domain::cms::{EditLog, EditLogEntry, EditLogRepository, RepositoryError};
/// Maximum number of edit entries held in memory at once.
const MAX_MEMORY_ENTRIES: usize = 10_000;
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
#[derive(Debug, Clone, Default)]
pub struct JsonlEditLogRepository;
impl JsonlEditLogRepository {
pub fn new() -> Self {
Self
}
fn load_from_disk(path: &Path) -> VecDeque<EditLogEntry> {
let Ok(file) = std::fs::File::open(path) else {
return VecDeque::new();
};
let reader = BufReader::new(file);
let mut entries: VecDeque<EditLogEntry> = VecDeque::new();
for line in reader.lines() {
let Ok(line) = line else {
continue;
};
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
if entries.len() >= MAX_MEMORY_ENTRIES {
entries.pop_front();
}
entries.push_back(entry);
}
}
entries
}
}
impl EditLogRepository for JsonlEditLogRepository {
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
let path = session_dir.join("edits.jsonl");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let entries = Self::load_from_disk(&path);
if !path.exists() {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
}
Ok(EditLog { entries })
}
fn append(
&self,
session_dir: &Path,
log: &mut EditLog,
entry: EditLogEntry,
) -> Result<(), RepositoryError> {
let path = session_dir.join("edits.jsonl");
let line = serde_json::to_string(&entry)? + "\n";
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(line.as_bytes())?;
file.sync_all()?;
}
log.entries.push_back(entry);
if log.entries.len() > MAX_MEMORY_ENTRIES {
log.entries.pop_front();
}
Ok(())
}
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
log.entries.clone().into_iter().collect()
}
}
@@ -1,59 +1,53 @@
//! Markdown filebacked `MemoryRepository`. //! Markdown filebacked `MemoryRepository`.
//!
//! Each memory is stored as a `.md` file with YAML-ish frontmatter. //! Each memory is stored as a `.md` file with YAML-ish frontmatter.
//! Filenames are derived from the memory's `name` via slugification.
//!
//! Frontmatter fields parsed from `---\n...\n---\n` header:
//! name, description, kind, created_at, updated_at, lifecycle,
//! outcome, scope, before, after, provenances
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result}; use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError};
use crate::domain::memory::Memory; /// File-based `MemoryRepository` that stores memories as `.md` files with
use crate::domain::repository::MemoryRepository; /// YAML-ish frontmatter.
/// Persists `Memory` as markdown files with YAML-ish frontmatter.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct MarkdownMemoryRepository; pub struct MarkdownMemoryRepository;
impl MarkdownMemoryRepository { impl MarkdownMemoryRepository {
/// Create a new repository instance.
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
/// Build the frontmatter lines for a memory. /// Escape newlines in field values so they do not break the
/// line-oriented frontmatter parser.
fn escape_newlines(s: &str) -> String {
s.replace('\n', "\\n")
}
/// Unescape `\n` back to actual newlines after frontmatter parsing.
fn unescape_newlines(s: &str) -> String {
s.replace("\\n", "\n")
}
fn build_frontmatter(memory: &Memory) -> String { fn build_frontmatter(memory: &Memory) -> String {
let outcome_line = memory let outcome_line = memory
.outcome .outcome
.as_ref() .as_ref()
.map(|o| format!("outcome: {o}\n")) .map(|o| format!("outcome: {}\n", Self::escape_newlines(o)))
.unwrap_or_default(); .unwrap_or_default();
let scope_line = memory let scope_line = memory
.scope .scope
.as_ref() .as_ref()
.map(|s| format!("scope: {s}\n")) .map(|s| format!("scope: {}\n", Self::escape_newlines(s)))
.unwrap_or_default(); .unwrap_or_default();
let before_line = memory let before_line = memory
.before_snippet .before_snippet
.as_ref() .as_ref()
.map(|s| format!("before: {s}\n")) .map(|s| format!("before: {}\n", Self::escape_newlines(s)))
.unwrap_or_default(); .unwrap_or_default();
let after_line = memory let after_line = memory
.after_snippet .after_snippet
.as_ref() .as_ref()
.map(|s| format!("after: {s}\n")) .map(|s| format!("after: {}\n", Self::escape_newlines(s)))
.unwrap_or_default(); .unwrap_or_default();
let prov_line = if memory.provenances.is_empty() { let prov_line = if memory.provenances.is_empty() {
String::new() String::new()
@@ -78,18 +72,19 @@ impl MarkdownMemoryRepository {
) )
} }
/// Parse frontmatter lines into a `HashMap`.
fn parse_frontmatter(front: &str) -> HashMap<String, String> { fn parse_frontmatter(front: &str) -> HashMap<String, String> {
front front
.lines() .lines()
.filter_map(|l| { .filter_map(|l| {
let mut it = l.splitn(2, ':'); let mut it = l.splitn(2, ':');
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string())) Some((
it.next()?.trim().to_string(),
it.next()?.trim().to_string(),
))
}) })
.collect() .collect()
} }
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
fn parse(content: &str) -> std::io::Result<Memory> { fn parse(content: &str) -> std::io::Result<Memory> {
let content = content.strip_prefix("---\n").unwrap_or(content); let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
@@ -117,29 +112,41 @@ impl MarkdownMemoryRepository {
.get("updated_at") .get("updated_at")
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(0), .unwrap_or(0),
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), outcome: front
.get("outcome")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
lifecycle: front lifecycle: front
.get("lifecycle") .get("lifecycle")
.cloned() .cloned()
.unwrap_or_else(|| "new".to_string()), .unwrap_or_else(|| "new".to_string()),
scope: front.get("scope").cloned().filter(|s| !s.is_empty()), scope: front
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), .get("scope")
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), .cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
before_snippet: front
.get("before")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
after_snippet: front
.get("after")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
provenances: front provenances: front
.get("provenances") .get("provenances")
.cloned() .cloned()
.map(|s| { .map(|s| s.split(", ").map(String::from).collect())
s.split(", ")
.map(std::string::ToString::to_string)
.collect()
})
.unwrap_or_default(), .unwrap_or_default(),
}) })
} }
} }
impl MemoryRepository for MarkdownMemoryRepository { impl MemoryRepository for MarkdownMemoryRepository {
fn list(&self, memory_dir: &Path) -> Result<Vec<String>> { fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let Ok(entries) = std::fs::read_dir(memory_dir) else { let Ok(entries) = std::fs::read_dir(memory_dir) else {
return Ok(Vec::new()); return Ok(Vec::new());
}; };
@@ -148,7 +155,6 @@ impl MemoryRepository for MarkdownMemoryRepository {
.filter(|e| e.path().extension().is_some_and(|x| x == "md")) .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| { .filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string(); let name = e.file_name().to_string_lossy().to_string();
// Skip special summary file
if name == "MEMORY.md" { if name == "MEMORY.md" {
return None; return None;
} }
@@ -159,20 +165,18 @@ impl MemoryRepository for MarkdownMemoryRepository {
Ok(slugs) Ok(slugs)
} }
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory> { fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
let path = Memory::path(memory_dir, name); let path = Memory::path(memory_dir, name);
let content = std::fs::read_to_string(&path) let content = std::fs::read_to_string(&path)?;
.with_context(|| format!("failed to read memory '{name}' at '{}'", path.display()))?;
let memory = Self::parse(&content) let memory = Self::parse(&content)
.map_err(|e| anyhow::anyhow!("failed to parse memory '{name}': {e}"))?; .map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?;
Ok(memory) Ok(memory)
} }
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()> { fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, &memory.name); let path = Memory::path(memory_dir, &memory.name);
let parent = path.parent().unwrap(); let parent = path.parent().unwrap();
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)?;
.with_context(|| format!("failed to create memory dir '{}'", parent.display()))?;
let frontmatter = Self::build_frontmatter(memory); let frontmatter = Self::build_frontmatter(memory);
let content = format!("---\n{frontmatter}---\n\n{}", memory.content); let content = format!("---\n{frontmatter}---\n\n{}", memory.content);
@@ -183,39 +187,23 @@ impl MemoryRepository for MarkdownMemoryRepository {
.create(true) .create(true)
.truncate(true) .truncate(true)
.write(true) .write(true)
.open(&tmp) .open(&tmp)?;
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(content.as_bytes())?; f.write_all(content.as_bytes())?;
f.sync_all()?; f.sync_all()?;
} }
std::fs::rename(&tmp, &path).with_context(|| { std::fs::rename(&tmp, &path)?;
format!(
"failed to rename '{}' -> '{}'",
tmp.display(),
path.display()
)
})?;
if let Some(p) = path.parent() { if let Some(p) = path.parent() {
if let Ok(d) = std::fs::File::open(p) { if let Ok(d) = std::fs::File::open(p) {
let _ = d.sync_all(); let _ = d.sync_all();
} }
} }
tracing::debug!("memory saved to '{}'", path.display());
Ok(()) Ok(())
} }
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> { fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, name); let path = Memory::path(memory_dir, name);
if path.exists() { if path.exists() {
std::fs::remove_file(&path).with_context(|| { std::fs::remove_file(&path)?;
format!("failed to delete memory '{name}' at '{}'", path.display())
})?;
tracing::debug!("memory deleted: '{}'", path.display());
} else {
tracing::warn!(
"memory '{name}' not found at '{}', skipping delete",
path.display()
);
} }
Ok(()) Ok(())
} }
@@ -0,0 +1,16 @@
//! File-based repository implementations for CMS domain entities.
//!
//! ## Repositories
//! - `JsonSettingsRepository` — reads/writes `settings.json`
//! - `JsonAppConfigRepository` — reads/writes `app_config.json`
//! - `JsonConversationRepository` — reads/writes `conversation.json`
//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files
//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl`
//! - `FileRewindBlobRepository` — stores blobs as files
pub mod app_config_repo;
pub mod conversation_repo;
pub mod edit_log_repo;
pub mod memory_repo;
pub mod rewind_blob_repo;
pub mod settings_repo;
@@ -0,0 +1,112 @@
//! Filesystem-backed `RewindBlobRepository`.
//! Blob bytes are stored at `<session_dir>/blobs/<hex(key)>.bin`.
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use zesdex_domain::cms::{RepositoryError, RewindBlobRepository};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobIndexEntry {
key: String,
mime_type: Option<String>,
created_at: i64,
}
/// Concrete filesystem rewind-blob repository.
#[derive(Debug, Clone, Default)]
pub struct FileRewindBlobRepository;
impl FileRewindBlobRepository {
pub fn new() -> Self {
Self
}
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
session_dir.join("blobs")
}
fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
}
fn index_path(session_dir: &Path) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join("index.jsonl")
}
}
impl RewindBlobRepository for FileRewindBlobRepository {
fn store_blob(
&self,
session_dir: &Path,
blob_key: &str,
data: &[u8],
mime_type: Option<&str>,
) -> Result<(), RepositoryError> {
let blobs_dir = Self::blobs_dir(session_dir);
std::fs::create_dir_all(&blobs_dir)?;
let path = Self::blob_file_path(session_dir, blob_key);
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, &path)?;
let entry = BlobIndexEntry {
key: blob_key.to_string(),
mime_type: mime_type.map(String::from),
created_at: chrono::Utc::now().timestamp_millis(),
};
let index_path = Self::index_path(session_dir);
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)?;
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
f.sync_all()?;
Ok(())
}
fn retrieve_blob(
&self,
session_dir: &Path,
blob_key: &str,
) -> Result<Option<Vec<u8>>, RepositoryError> {
let path = Self::blob_file_path(session_dir, blob_key);
if !path.exists() {
return Ok(None);
}
let data = std::fs::read(&path)?;
Ok(Some(data))
}
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let index_path = Self::index_path(session_dir);
let Ok(content) = std::fs::read_to_string(&index_path) else {
return Ok(Vec::new());
};
let mut first_seen_order: Vec<String> = Vec::new();
let mut latest_by_key: std::collections::HashMap<String, BlobIndexEntry> =
std::collections::HashMap::new();
for line in content.lines() {
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
continue;
};
if !latest_by_key.contains_key(&entry.key) {
first_seen_order.push(entry.key.clone());
}
latest_by_key.insert(entry.key.clone(), entry);
}
let mut entries: Vec<BlobIndexEntry> = first_seen_order
.into_iter()
.filter_map(|k| latest_by_key.get(&k).cloned())
.collect();
entries.sort_by_key(|e| e.created_at);
Ok(entries.into_iter().map(|e| e.key).collect())
}
}
@@ -0,0 +1,44 @@
//! JSON filebacked `SettingsRepository`.
//! Path: `<base_dir>/settings.json`
use std::path::Path;
use zesdex_domain::cms::{RepositoryError, Settings, SettingsRepository};
use crate::utils::write_json_atomic;
/// Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonSettingsRepository;
impl JsonSettingsRepository {
pub fn new() -> Self {
Self
}
}
impl SettingsRepository for JsonSettingsRepository {
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError> {
let path = base_dir.join("settings.json");
match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(settings) => Ok(settings),
Err(e) => {
tracing::warn!("settings.json at '{:?}' failed to parse ({e}); falling back to defaults", path);
Ok(Settings::default())
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(Settings::default())
}
Err(e) => Err(RepositoryError::Io(e)),
}
}
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> {
std::fs::create_dir_all(base_dir)?;
let path = base_dir.join("settings.json");
write_json_atomic(&path, settings, None)?;
Ok(())
}
}
@@ -0,0 +1,8 @@
//! Filesystem-backed repository implementations for IAM entities.
//!
//! Implements domain repository traits using JSON file persistence for
//! sessions, OAuth tokens, and PID-file session locks.
pub mod oauth_repo;
pub mod session_lock_repo;
pub mod session_repo;
@@ -0,0 +1,39 @@
//! Filesystem-backed `OAuthRepository` implementation.
//!
//! Tokens are stored as a single JSON file with write-then-rename + fsync
//! for crash safety, and restrictive owner-only mode `0o600` on Unix.
use std::path::Path;
use zesdex_domain::auth::{OAuthRepository, OAuthToken, RepositoryError};
use crate::utils::write_json_atomic;
/// Concrete filesystem OAuth token repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemOAuthRepository;
impl FileSystemOAuthRepository {
pub fn new() -> Self {
FileSystemOAuthRepository
}
}
impl OAuthRepository for FileSystemOAuthRepository {
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_json_atomic(path, token, Some(0o600))?;
Ok(())
}
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError> {
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(path)?;
let token: OAuthToken = serde_json::from_str(&data)?;
Ok(Some(token))
}
}

Some files were not shown because too many files have changed in this diff Show More