Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29585cc630 | ||
|
|
5c65f8f6ab | ||
|
|
7e2eda3bb4 | ||
|
|
e6718fa118 | ||
|
|
f98030f28a | ||
|
|
93e5464370 | ||
|
|
f5b04214cf | ||
|
|
008f737e1f | ||
|
|
2100e7ab54 | ||
|
|
c53fdfa7c9 | ||
|
|
5912a35bbb | ||
|
|
d86d6aeef2 | ||
|
|
30cc579773 | ||
|
|
a54f1644f5 | ||
|
|
23e68dc979 | ||
|
|
8c99c6b1ca | ||
|
|
9b28a829e5 | ||
|
|
b5ebd07fa8 | ||
|
|
7e6ed34f0d | ||
|
|
34322e492a | ||
|
|
3e260e6077 | ||
|
|
b7006d1ad8 | ||
|
|
0ee0cc4350 | ||
|
|
e91fda3691 | ||
|
|
e4e0379c45 | ||
|
|
c165dff114 | ||
|
|
319198d2f0 | ||
|
|
f26e6ac9c6 | ||
|
|
f75c3860e9 | ||
|
|
170b1d3d19 | ||
|
|
5d65c78fb4 | ||
|
|
8098133ba5 | ||
|
|
2e81412605 | ||
|
|
14d3556179 | ||
|
|
d134678530 | ||
|
|
02a55235cf | ||
|
|
1a6268bde5 | ||
|
|
7030e26b3a | ||
|
|
d4d423a4ca | ||
|
|
3930d2e2c9 | ||
|
|
49e79322b4 | ||
|
|
1b130e69b3 | ||
|
|
145184d091 | ||
|
|
c582dc1461 | ||
|
|
4b4730cff7 | ||
|
|
fd58b4661f | ||
|
|
7e8a160b0e | ||
|
|
edc007619b | ||
|
|
a14a2275ff | ||
|
|
82b51f7921 | ||
|
|
a2c7e5d44c | ||
|
|
03538a455a | ||
|
|
07e84e43b3 | ||
|
|
9d1544d799 | ||
|
|
a27e8151b3 | ||
|
|
88c2a7d000 | ||
|
|
3b25e3898f | ||
|
|
d23d3855ec | ||
|
|
104af3abb9 | ||
|
|
62fa85867c | ||
|
|
f75ff740ac | ||
|
|
e349a35716 | ||
|
|
f1f58b9996 | ||
|
|
f4c02fd64e | ||
|
|
e982cbeb04 | ||
|
|
20ce81a6be | ||
|
|
21e3ccc891 |
@@ -1,48 +0,0 @@
|
||||
# Strict Rust compiler configuration for zesdex
|
||||
# Forces all warnings as errors, enables maximum optimization for release builds.
|
||||
|
||||
[target.'cfg(all())']
|
||||
# Treat all warnings as errors — zero tolerance policy
|
||||
rustflags = [
|
||||
# Force all lints to error level (stricter than -Dwarnings)
|
||||
"-W", "unused",
|
||||
"-W", "dead_code",
|
||||
"-W", "unreachable_code",
|
||||
"-W", "unused_imports",
|
||||
"-W", "unused_variables",
|
||||
"-W", "unused_mut",
|
||||
"-W", "unused_must_use",
|
||||
"-W", "unused_unsafe",
|
||||
"-W", "unused_extern_crates",
|
||||
|
||||
# Safety-critical
|
||||
"-W", "trivial_casts",
|
||||
"-W", "trivial_numeric_casts",
|
||||
|
||||
# Deprecation and future compat
|
||||
"-W", "deprecated",
|
||||
"-W", "deprecated_in_future",
|
||||
"-W", "keyword_idents",
|
||||
"-W", "noop_method_call",
|
||||
|
||||
# Type system
|
||||
"-W", "invalid_type_param_default",
|
||||
"-W", "unused_labels",
|
||||
"-W", "while_true",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
# Maximum speed optimizations for release builds
|
||||
opt-level = 3
|
||||
debug = false
|
||||
debug-assertions = false
|
||||
overflow-checks = true
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
[profile.dev]
|
||||
# Keep dev fast but still strict
|
||||
opt-level = 0
|
||||
debug = true
|
||||
+15
-11
@@ -7,7 +7,7 @@ on:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
BUN_INSTALL: "${{ github.workspace }}/.bun"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -16,16 +16,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: clippy
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Clippy workspace
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- name: Type-check
|
||||
run: bun run check
|
||||
|
||||
- name: Test workspace
|
||||
run: cargo test --workspace
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
- name: Build CLI bundle
|
||||
run: bun run build
|
||||
|
||||
- name: Smoke — CLI help
|
||||
run: ./dist/zesdex --help
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build & Deploy (Nix)
|
||||
name: Build & Deploy (Bun)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -25,26 +25,27 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v22
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
determinate: false
|
||||
extra-conf: |
|
||||
sandbox = false
|
||||
accept-flake-config = true
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Cache Nix
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||
- name: Install deps
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build zesdex
|
||||
id: build
|
||||
- name: Type-check
|
||||
run: bun run check
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
nix build .#default --impure --option sandbox false --print-build-logs
|
||||
STORE_PATH=$(readlink result)
|
||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "Build OK: $STORE_PATH"
|
||||
bun run build
|
||||
test -x dist/zesdex && echo "binary OK ($(stat -c%s dist/zesdex) bytes)"
|
||||
|
||||
- name: Setup SSH key
|
||||
if: env.VPS_HOST != '' && env.VPS_HOST != 'null'
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
@@ -56,6 +57,7 @@ jobs:
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy zesdex to VPS
|
||||
if: env.VPS_HOST != '' && env.VPS_HOST != 'null'
|
||||
run: |
|
||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||
ssh "$VPS_USER@$VPS_HOST" "nix copy --to file:///nix/store $STORE_PATH && nix-env --install --force $STORE_PATH --profile /nix/var/nix/profiles/zesdex && systemctl restart zesdex"
|
||||
scp dist/zesdex "$VPS_USER@$VPS_HOST:/usr/local/bin/zesdex"
|
||||
ssh "$VPS_USER@$VPS_HOST" "chmod +x /usr/local/bin/zesdex && systemctl restart zesdex"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Publish to FlakeHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
flakehub-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: DeterminateSystems/determinate-nix-action@main
|
||||
- uses: DeterminateSystems/flakehub-push@main
|
||||
with:
|
||||
visibility: public
|
||||
rolling: true
|
||||
@@ -16,14 +16,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --workspace
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type-check
|
||||
run: bun run check
|
||||
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
run: bun test
|
||||
|
||||
release:
|
||||
name: Semantic Release
|
||||
@@ -34,24 +37,21 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install semantic-release
|
||||
- name: Install semantic-release (isolated prefix — do NOT touch repo package.json)
|
||||
run: |
|
||||
npm install -D \
|
||||
mkdir -p /tmp/sr-tools
|
||||
npm install --prefix /tmp/sr-tools \
|
||||
semantic-release \
|
||||
@semantic-release/exec \
|
||||
@semantic-release/git \
|
||||
@semantic-release/changelog \
|
||||
@semantic-release/github
|
||||
echo "/tmp/sr-tools/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run semantic-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx semantic-release
|
||||
run: semantic-release
|
||||
|
||||
+6
-2
@@ -2,9 +2,13 @@ target/
|
||||
.env
|
||||
.claude/settings.local.json
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
.superpowers/
|
||||
docs/lesson/
|
||||
.kilo/
|
||||
.hermes/
|
||||
.hermes/
|
||||
# Runtime edit-log artifacts
|
||||
dist/
|
||||
apps/**/edit-log/
|
||||
edit-log/
|
||||
dist/
|
||||
|
||||
+24
-10
@@ -4,15 +4,29 @@
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
["@semantic-release/exec", {
|
||||
"prepareCmd": "sed -i 's/^version = \"[^\"]*\"/version = \"${nextRelease.version}\"/' Cargo.toml && cargo check"
|
||||
}],
|
||||
["@semantic-release/git", {
|
||||
"assets": ["Cargo.toml", "CHANGELOG.md"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}],
|
||||
["@semantic-release/github", {
|
||||
"assets": []
|
||||
}]
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "sed -i 's/\"version\": \"[^\"]*\"/\"version\": \"${nextRelease.version}\"/' package.json && bun install --frozen-lockfile && bun run check && bun run build"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": ["package.json", "bun.lock", "CHANGELOG.md"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"path": "dist/zesdex",
|
||||
"label": "zesdex-${nextRelease.version}-linux-amd64"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Kilo when working with code in the zesdex repository.
|
||||
This file provides guidance to AI agents (Kilo / Hermes) working in the zesdex repository.
|
||||
|
||||
## Overview
|
||||
|
||||
Zesdex is an **AI-native autonomous coding agent**, implemented in **TypeScript / Bun**.
|
||||
It was rebuilt to be a **pure CLI/TUI tool** — there are **no network server interfaces**
|
||||
(REST API / WebSocket / gRPC / web / daemon were all removed). The only two entry points:
|
||||
|
||||
- `zesdex` — interactive **OpenTUI** (primary interface, wired to the real agent).
|
||||
- `zesdex --headless "<prompt>"` — one-shot non-interactive turn (headless mode).
|
||||
|
||||
## Best Practice Conventions
|
||||
|
||||
Zesdex follows Kana Engineering Best Practices:
|
||||
1. **Clean Architecture** — strict `domain` / `application` / `infrastructure` layering
|
||||
under `src/`, with thin `interfaces/` for the TUI + CLI.
|
||||
- `domain` has zero I/O and zero framework dependencies.
|
||||
- `application` depends only on `domain` (ports + turn service).
|
||||
- `infrastructure` implements domain ports (LLM client, tool executor, file repos).
|
||||
- DTOs/value objects cross layer boundaries, not entities.
|
||||
|
||||
1. **Clean Architecture** — Strict domain/application/infrastructure/presentation layering.
|
||||
- Domain has ZERO framework dependencies.
|
||||
- Application depends only on domain.
|
||||
- Infrastructure implements domain traits.
|
||||
- DTOs cross layer boundaries, NOT entities.
|
||||
2. **Clean Code** — functions under ~40 lines, one level of abstraction per function,
|
||||
descriptive names, no flag arguments, no commented-out dead code.
|
||||
|
||||
2. **Clean Code** — Functions under ~40 lines, one level of abstraction per function, descriptive names, no flag arguments, no commented-out code.
|
||||
3. **Documentation** — every exported `function`, `class`, `interface`, and `type`
|
||||
needs a `/** */` doc comment explaining what, flow, why, and return value.
|
||||
|
||||
3. **Documentation** — Every pub fn, struct, enum, and trait needs a doc comment (///) explaining what, flow, why, and return value.
|
||||
4. **Commit Convention** — Conventional Commits in Bahasa Indonesia:
|
||||
`feat(scope):`, `fix(scope):`, `chore:`, `docs:`.
|
||||
|
||||
4. **Commit Convention** — Conventional Commits in Bahasa Indonesia: `feat(scope):`, `fix(scope):`, `chore:`, `docs:`.
|
||||
5. **Error Handling** — throw `Error` with clear messages; the turn loop wraps LLM
|
||||
errors and recovers. Log with `console.*` (redirected to `~/.local/share/zesdex/zesdex.log`).
|
||||
|
||||
5. **Error Handling** — `anyhow::Result` and `anyhow::bail!` throughout. Log with `tracing` (never stderr).
|
||||
6. **Testing** — Bun tests (`bun test`) using `describe/test/expect`. Tests are
|
||||
F.I.R.S.T. Logic-pure modules (TUI state/action/command/controller) are unit-tested.
|
||||
|
||||
6. **Testing** — `#[cfg(test)] mod tests` blocks inline in production files. Tests are F.I.R.S.T. (Fast, Independent, Repeatable, Self-validating, Timely).
|
||||
7. **No Compiler Bypasses** — no `@ts-ignore` / `any` where a real type exists.
|
||||
|
||||
7. **No Compiler Bypasses** — Never use `#[allow(...)]`, `#[expect(...)]`, or `#[allow(dead_code)]`. Fix the underlying code.
|
||||
8. **Boy Scout Rule** — leave every module cleaner than you found it.
|
||||
|
||||
8. **Boy Scout Rule** — Leave every module cleaner than you found it.
|
||||
## Stack
|
||||
|
||||
## Available Agents
|
||||
- Bun 1.3.14 (packageManager pinned in `package.json`).
|
||||
- TypeScript strict mode, `tsconfig.json` uses `paths` aliases (`@zesdex/*`) →
|
||||
`src/*`. `bun build ... --compile` bundles into a single `dist/zesdex` binary.
|
||||
|
||||
- `@rust-engineer` — Rust clean architecture specialist (subagent).
|
||||
- `@code-reviewer` — Code review specialist (subagent).
|
||||
## Commands
|
||||
|
||||
## Available Commands
|
||||
|
||||
- `/check` — Run cargo check, clippy, and tests.
|
||||
- `/audit` — Code quality audit against clean-architecture best practices.
|
||||
- `/doc` — Generate or update doc comments.
|
||||
- `bun run check` — `tsc --noEmit` typecheck.
|
||||
- `bun run test` — run all Bun tests.
|
||||
- `bun run build` — compile `dist/zesdex` binary.
|
||||
- `bun run tui` — run the interactive TUI.
|
||||
- `bun run headless "<prompt>"` — one-shot agent turn.
|
||||
- `bun run bootstrap` — seed default settings/app_config.
|
||||
|
||||
+111
@@ -1,3 +1,114 @@
|
||||
# [1.25.0](https://github.com/asepharyana/zesdex/compare/v1.24.0...v1.25.0) (2026-09-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tui:** enhance LLM interaction with max_tokens and temperature overrides; add tests for parallel delegation and workflow script parsing ([5c65f8f](https://github.com/asepharyana/zesdex/commit/5c65f8f6ab093dbfad682939a9fcae79a8a68661))
|
||||
|
||||
# [1.24.0](https://github.com/asepharyana/zesdex/compare/v1.23.2...v1.24.0) (2026-09-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tui:** add new flow helpers for bash exit code handling and verification prompts ([f98030f](https://github.com/asepharyana/zesdex/commit/f98030f28a20e3d9ba807bb98ddbf142511a3964))
|
||||
* **tui:** enhance autocomplete functionality and UI interactions ([008f737](https://github.com/asepharyana/zesdex/commit/008f737e1f5d0eef6a07ebe0fff2c9730bd44eb4))
|
||||
* **tui:** enhance command execution on autocomplete and improve UI responsiveness ([f5b0421](https://github.com/asepharyana/zesdex/commit/f5b04214cf385ba2e4a02f6ce202436ffab23c35))
|
||||
* **tui:** enhance model selection and improve UI components ([c53fdfa](https://github.com/asepharyana/zesdex/commit/c53fdfa7c99887e8f3dfe68e7f1b2e766de039b1))
|
||||
* **tui:** implement interactive menu system for model and config selection ([2100e7a](https://github.com/asepharyana/zesdex/commit/2100e7ab54460f28b67c3bb07855eef8f04eb9ec))
|
||||
* **tui:** implement live model fetching and enhance model selection menus ([93e5464](https://github.com/asepharyana/zesdex/commit/93e54643703ce7f18286bd6a988e0fab18cc9e66))
|
||||
* **tui:** implement message compaction and summarization logic for efficient conversation handling ([e6718fa](https://github.com/asepharyana/zesdex/commit/e6718fa11825cccc74a9d100755226b7dfefc106))
|
||||
|
||||
## [1.23.2](https://github.com/asepharyana/zesdex/compare/v1.23.1...v1.23.2) (2026-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tui:** sliding-window stream reconciler + UI polish ([d86d6ae](https://github.com/asepharyana/zesdex/commit/d86d6aeef2dcd4924ced14f21502a84f7e916a26))
|
||||
|
||||
## [1.23.1](https://github.com/asepharyana/zesdex/compare/v1.23.0...v1.23.1) (2026-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tui:** repair layout overflow + streaming dup; polish UI/state ([a54f164](https://github.com/asepharyana/zesdex/commit/a54f1644f5943399568194b4f129185a9d841f1d))
|
||||
|
||||
# [1.23.0](https://github.com/asepharyana/zesdex/compare/v1.22.0...v1.23.0) (2026-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** perbaiki base64url encode JWT — string di-base64 dulu sebelum replace chars ([1b130e6](https://github.com/asepharyana/zesdex/commit/1b130e69b36603f894bcd45469b9feef28b18562))
|
||||
* **ci:** Release workflow install semantic-release tanpa merusak package.json ([8c99c6b](https://github.com/asepharyana/zesdex/commit/8c99c6b1ca94e1d2b80dd59bb96eb24b7b3b2dfa))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** port Fase 5 REST API — Bun.serve router + JWT middleware + auth/session/conversation/chat handlers + composition root ([49e7932](https://github.com/asepharyana/zesdex/commit/49e79322b4aa1824467e1c871cf68809c1a424ee))
|
||||
* **auth:** port 3f auth infrastructure — Argon2 passwords + HS256 JWT tokens ([7e8a160](https://github.com/asepharyana/zesdex/commit/7e8a160b0e918d51dc559f55fa19395368a4d84e))
|
||||
* **auth:** tambah OAuth loopback server untuk capture authorization-code redirect ([fd58b46](https://github.com/asepharyana/zesdex/commit/fd58b4661fd0928dd461c74a5cb7bc7870ca9692))
|
||||
* **cli:** port Fase 4 CLI — parser arg, mode dispatch, single-process composition root + REPL loop, bootstrap seed ([c582dc1](https://github.com/asepharyana/zesdex/commit/c582dc1461024d5037b8351a0df6b9b11e967782))
|
||||
* **cli:** wire mode dispatch ke server interfaces — --api/--ws/--grpc/--web start server; add tsconfig paths ([02a5523](https://github.com/asepharyana/zesdex/commit/02a55235cfadb3e5daf3774a04f6fc6abe5da78d))
|
||||
* **daemon:** port Fase 5 daemon — IPC agent-driver loop (Submit/Close) + streaming tokens; wire CLI --daemon/--attach ([14d3556](https://github.com/asepharyana/zesdex/commit/14d3556179685587e62873f81a57125037ac5e5a))
|
||||
* **infra:** port tool system ke TypeScript (37 tools + registry + executor) ([82b51f7](https://github.com/asepharyana/zesdex/commit/82b51f7921e33e912f1244152df509522e44cae3))
|
||||
* **ipc:** port 3g IPC + bgbash — framed Unix-socket server/client + background job registry, wire bash tools ([4b4730c](https://github.com/asepharyana/zesdex/commit/4b4730cff7ee2a5c43f8906d17282d95de84cf7a))
|
||||
* **rewrite:** bootstrap monorepo Bun + port domain layer ke TypeScript ([07e84e4](https://github.com/asepharyana/zesdex/commit/07e84e43b386388f75f8293879e49dff1db461ab))
|
||||
* **rewrite:** tambah application layer — port traits & use cases TypeScript ([03538a4](https://github.com/asepharyana/zesdex/commit/03538a455a93ff554abb45a1e54809c9579763fd))
|
||||
* **rewrite:** tambah infrastructure — LLM client + full file persistence ([a2c7e5d](https://github.com/asepharyana/zesdex/commit/a2c7e5d44c784508b4fe735ec3434df2407f76de))
|
||||
* **subagent:** port 3c subagent engine — run_agent loop + spawn/delegate/parallel ([a14a227](https://github.com/asepharyana/zesdex/commit/a14a2275ff90059874166aec8c03bd44e6c7efee))
|
||||
* **tui:** port Fase 4 TUI logic layer — InputState/Action dispatcher/key controller/slash commands + lightweight ANSI renderer + run loop; 20 test ([8098133](https://github.com/asepharyana/zesdex/commit/8098133ba50135c7a5f9a063221ffdb860e064cb))
|
||||
* **web): port Fase 5 static file server w/ traversal protection; feat(grpc:** port health stub ([1a6268b](https://github.com/asepharyana/zesdex/commit/1a6268bde58c67b9d3cbabce08306d53f0b3791a))
|
||||
* **workflow:** port 3d workflow + hive-mind engine — parse, execute, cycle, synthesis, docs ([edc0076](https://github.com/asepharyana/zesdex/commit/edc007619b63d0d91fdad79a6d534c112b8a3981))
|
||||
* **ws:** port Fase 5 WS server — Bun native WebSocket, ZESDEX_WS_TOKEN guard, prompt turn proxy + streaming token/done/error ([d4d423a](https://github.com/asepharyana/zesdex/commit/d4d423a4cae62562da4ca4f0231f6a4f90481473))
|
||||
|
||||
# [1.22.0](https://github.com/asepharyana/zesdex/compare/v1.21.2...v1.22.0) (2026-08-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent:** wire auto-review (review_enabled no-op -> nyata) ([a27e815](https://github.com/asepharyana/zesdex/commit/a27e8151b39da4c8759e8e922ef132212327e413))
|
||||
|
||||
## [1.21.2](https://github.com/asepharyana/zesdex/compare/v1.21.1...v1.21.2) (2026-08-28)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** symbol index tak pegang mutex global saat rebuild I/O ([3b25e38](https://github.com/asepharyana/zesdex/commit/3b25e3898f9ba2fc1c58b991ad95a8c0bbe98601))
|
||||
|
||||
## [1.21.1](https://github.com/asepharyana/zesdex/compare/v1.21.0...v1.21.1) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** semantic_search symbol index workspace-aware ([104af3a](https://github.com/asepharyana/zesdex/commit/104af3abb9e626c5d00ea87d523248d606d523ee))
|
||||
|
||||
# [1.21.0](https://github.com/asepharyana/zesdex/compare/v1.20.2...v1.21.0) (2026-08-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent:** hive-mind consensus synthesis pakai LLM nyata ([f75ff74](https://github.com/asepharyana/zesdex/commit/f75ff740ac2fa63340656e1e8215440f5e48063d))
|
||||
|
||||
## [1.20.2](https://github.com/asepharyana/zesdex/compare/v1.20.1...v1.20.2) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** recall search + memory_dir fallback + bersihkan dead llm_client ([f1f58b9](https://github.com/asepharyana/zesdex/commit/f1f58b9996f8eb58871d44fdd41c2d863874f253))
|
||||
|
||||
## [1.20.1](https://github.com/asepharyana/zesdex/compare/v1.20.0...v1.20.1) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** subagent patuhi tool-calling contract + truncation char-safe ([e982cbe](https://github.com/asepharyana/zesdex/commit/e982cbeb041baea9cd500e2a29862a7eca9e6e17))
|
||||
|
||||
# [1.20.0](https://github.com/asepharyana/zesdex/compare/v1.19.6...v1.20.0) (2026-08-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent:** subagent tool paralel + auto-load AGENTS.md + verify cek setelah edit ([21e3ccc](https://github.com/asepharyana/zesdex/commit/21e3ccc891ab886044a5f0a770db710769d1287b))
|
||||
|
||||
## [1.19.6](https://github.com/asepharyana/zesdex/compare/v1.19.5...v1.19.6) (2026-08-27)
|
||||
|
||||
|
||||
|
||||
Generated
-5121
File diff suppressed because it is too large
Load Diff
-95
@@ -1,95 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"apps/domain",
|
||||
"apps/application",
|
||||
"apps/infrastructure",
|
||||
"apps/interfaces/tui",
|
||||
"apps/interfaces/api",
|
||||
"apps/interfaces/daemon",
|
||||
"apps/interfaces/ws",
|
||||
"apps/interfaces/grpc",
|
||||
"apps/interfaces/web",
|
||||
"apps/gateway",
|
||||
"apps/bootstrap",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.19.6"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
[workspace.lints.rust]
|
||||
unused = "deny"
|
||||
dead_code = "deny"
|
||||
unreachable_code = "deny"
|
||||
unused_imports = "deny"
|
||||
unused_variables = "deny"
|
||||
unused_mut = "deny"
|
||||
unused_must_use = "deny"
|
||||
deprecated = "deny"
|
||||
trivial_casts = "deny"
|
||||
trivial_numeric_casts = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -2 }
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
|
||||
ratatui = "0.30.2"
|
||||
crossterm = "0.29"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
thiserror = "1"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.11"
|
||||
hex = "0.4"
|
||||
libc = "0.2"
|
||||
dirs = "6"
|
||||
regex = "1"
|
||||
globset = "0.4"
|
||||
ignore = "0.4"
|
||||
nucleo-matcher = "0.3"
|
||||
futures-util = "0.3"
|
||||
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||
tiktoken-rs = "0.12"
|
||||
similar = "3"
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
infer = "0.19"
|
||||
webbrowser = "1"
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
dom_smoothie = "0.18.0"
|
||||
fast_html2md = "0.0.62"
|
||||
scraper = "0.27.0"
|
||||
include_dir = "0.7"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "limit"] }
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
# Clean-architecture workspace crate references
|
||||
zesdex-domain = { path = "apps/domain" }
|
||||
zesdex-application = { path = "apps/application" }
|
||||
zesdex-infrastructure = { path = "apps/infrastructure" }
|
||||
zesdex-tui = { path = "apps/interfaces/tui" }
|
||||
zesdex-api = { path = "apps/interfaces/api" }
|
||||
zesdex-daemon = { path = "apps/interfaces/daemon" }
|
||||
zesdex-ws = { path = "apps/interfaces/ws" }
|
||||
zesdex-grpc = { path = "apps/interfaces/grpc" }
|
||||
zesdex-web = { path = "apps/interfaces/web" }
|
||||
zesdex-gateway = { path = "apps/gateway" }
|
||||
zesdex-bootstrap = { path = "apps/bootstrap" }
|
||||
+13
-14
@@ -1,30 +1,29 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Zesdex — Multi-stage Docker build
|
||||
# ===================================
|
||||
# Stage 1: Build with Rust toolchain
|
||||
FROM rust:1.85-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libsqlite3-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Zesdex — Multi-stage Docker build (Bun)
|
||||
# ========================================
|
||||
# Stage 1: Build with Bun → compile single self-contained CLI binary
|
||||
FROM oven/bun:1.2 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
COPY bun.lock ./
|
||||
COPY package.json tsconfig.json ./
|
||||
COPY src ./src
|
||||
|
||||
# Build with release profile (treats warnings as errors via lints)
|
||||
RUN cargo build --release -p zesdex-gateway --bin zesdex
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Compile a standalone self-contained executable (bundles JS + native addons).
|
||||
RUN bun build src/interfaces/cli/index.ts --compile --outfile=/app/zesdex
|
||||
|
||||
# Stage 2: Minimal runtime image
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libsqlite3-0 && \
|
||||
ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/zesdex /usr/local/bin/zesdex
|
||||
COPY --from=builder /app/zesdex /usr/local/bin/zesdex
|
||||
|
||||
ENV ZESDEX_DATA_DIR=/data
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/zesdex"]
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# TODO — Rewrite Zesdex Rust → TypeScript/Bun
|
||||
|
||||
> Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo
|
||||
> TypeScript/Bun. Bahasa Indonesia, commit pakai Conventional Commits.
|
||||
>
|
||||
> **Status:** Fase 0–6 **semua selesai**. Rust source dihapus, build CI/Docker/release pakai Bun, `flake.lock` Rust/Nix dibersihkan, wire-shape lama terbaca. Base: `bun run check` bersih, 70 test hijau.
|
||||
|
||||
---
|
||||
|
||||
## Prinsip & peta pemetaan
|
||||
|
||||
| Rust | TS/Bun |
|
||||
|---|---|
|
||||
| `serde_json` | `JSON.stringify/parse` + `zod` |
|
||||
| `chrono` | `Date` / `Date.now()` (epoch ms) |
|
||||
| `uuid` | `crypto.randomUUID()` |
|
||||
| `sha2`, `base64 URL_SAFE_NO_PAD` | `node:crypto` / `Buffer.toString('base64url')` |
|
||||
| `reqwest` | `fetch` / bun HTTP |
|
||||
| `rusqlite` | `bun:sqlite` |
|
||||
| `argon2` | `@node-rs/argon2` |
|
||||
| `jsonwebtoken` | `node:crypto` HMAC HS256 |
|
||||
| `rmcp` | `@modelcontextprotocol/sdk` |
|
||||
| `ratatui`/`crossterm` | library TUI Bun / ink |
|
||||
| `tiktoken-rs` | `@dqbd/tiktoken` (cl100k_base) |
|
||||
| `clap` | `Bun.argv` / `commander` |
|
||||
| `tokio` semaphore | Bun async + concurrency pool |
|
||||
| `libc` PID lock | `node:fs` `O_EXCL` + `process.kill` liveness |
|
||||
| Rust generics `S<R: Repo>` | DI via constructor interface |
|
||||
| `Arc<Mutex<T>>` | objek mutable (array/objek di ToolCtx) |
|
||||
|
||||
### Struktur target
|
||||
|
||||
```
|
||||
apps/
|
||||
packages/ # ✅ sudah ada: domain, application, infrastructure
|
||||
interfaces/
|
||||
cli/ # gateway + TUI [baru]
|
||||
api/ # REST API [baru]
|
||||
daemon/ # daemon + IPC [baru]
|
||||
ws/ # WebSocket server [baru]
|
||||
grpc/ # stub [baru]
|
||||
web/ # static server [baru]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Selesai
|
||||
|
||||
### Fase 0 — Bootstrap monorepo
|
||||
- [x] `package.json` root (workspaces), `tsconfig.json` strict, `.gitignore`
|
||||
- [x] Paket `domain`, `application`, `infrastructure` (workspace `@zesdex/*`)
|
||||
- [x] `bun install` + `bun test` jalan
|
||||
|
||||
### Fase 1 — Domain layer
|
||||
- [x] `core`: ChatMessage, Conversation, ChatRequest/Response/StreamEvent, SseParser, ToolCall, repairJson, sanitizeToolArguments, ToolCallResult, UsageStats, Store
|
||||
- [x] `auth`: Session, SessionId (path-traversal), OAuthToken/Config, repo/service trait
|
||||
- [x] `cms`: AppConfig, ProviderConfig, ModelRole, Settings+resolveEffectiveModel, SettingsPatch, Memory, EditLog, repo/service trait
|
||||
- [x] `agent`: Origin, Toast, AgentStatus, TurnEvent, SessionRuntime, AgentTurnParams, AgentProgress, ExecutionModel
|
||||
- [x] `workflow`/`subagent`: WorkflowScript/NodeDirective, AccessTier, prompt builders
|
||||
- [x] 27 test domain
|
||||
|
||||
### Fase 2 — Application layer
|
||||
- [x] `ports`: ProviderService, PasswordService, TokenService, AuthService
|
||||
- [x] `AgentTurnServiceImpl` (loop 50 iter, auto-compact 60k, adaptive token, temp, ErrorTracker, bounded parallel)
|
||||
- [x] `OAuthUseCase` (PKCE S256 + CSRF state)
|
||||
- [x] `SessionServiceImpl`, `MemoryServiceImpl`, `SettingsServiceImpl`, `ConversationServiceImpl`
|
||||
- [x] 11 test application
|
||||
|
||||
### Fase 3a — LLM client
|
||||
- [x] `LlmClient` (fetch, retry 10x backoff+jitter, abort 401/402/403)
|
||||
- [x] `resolve_api_key`, `is_auth_error`, `backoff_for_error`
|
||||
|
||||
### Fase 3b — Tool system (`commit a291dd1`)
|
||||
- [x] `Tool` interface, `ToolCtx`/`ToolCtxBuilder`, `InfrastructureToolExecutor`
|
||||
- [x] Registry: `allTools` (37), `toolDefs`, `toolIsRisky`, `toolIsParallelSafe`
|
||||
- [x] FS: read, write, edit, delete · **Guard**: graduated checks, resolve_path sandbox
|
||||
- [x] Search: grep, glob (matcher ringan)
|
||||
- [x] Shell: bash (timeout), bash_output, bash_kill · **Filter**: checkGitDestructive, checkCredentialRead
|
||||
- [x] Git: git_operator, git_worktree, git_cred
|
||||
- [x] Memory: remember, forget, recall
|
||||
- [x] Util: cd, dir_list, dir_cache_update, pong, todowrite, todofinish
|
||||
- [x] Reasoning/Plan: sequential_think, plan_enter, plan_ready
|
||||
- [x] Web: web_search (async → SearXNG)
|
||||
- [x] Semantic: semantic_search, rebuild_index, list_symbols (index multi-bahasa)
|
||||
- [x] Best-practice: best_practice, commit_convention + BestPracticeEngine
|
||||
- [x] Agent/Workflow: spawn_agents, spawn_pipeline, parallel_delegate, workflow_run, note_finding, read_findings, hive_mind (delegasi ke placeholder 3c/3d)
|
||||
- [x] 16 test tool system
|
||||
|
||||
### Fase 3e — Persistence
|
||||
- [x] File repos: settings, app_config (+ auto-detect kredensial Claude), conversation, edits.jsonl, markdown memory, rewind blob, session, oauth, session lock
|
||||
- [x] `writeJsonAtomic`, `slugify`, `buildWorkspaceTree`, `runCommand`
|
||||
|
||||
### Fase 3c — Subagent engine
|
||||
**Sumber Rust:** `apps/infrastructure/src/subagent/{engine,division,context,provider,spawn}.rs`, `subagent/auto/*`
|
||||
|
||||
- [x] `AccessTier.toolsFor(access)` — `tools/division.ts`: Read / Write / Full yang mem-filter registry
|
||||
- [x] `run_agent` loop (`tools/engine.ts`): `MAX_ITERATIONS=25`, `TOOL_OUTPUT_MAX_CHARS=12_000`,
|
||||
`MAX_CONSECUTIVE_TOOL_ERRORS=3`, `MAX_PARALLEL_TOOLS=8`; recovery note setelah 3 error beruntun
|
||||
- [x] `SubagentContext` (`subagent/context.ts`) — directive, toolCtx, access, baseURL, apiKey, model
|
||||
- [x] `spawn_subagent` — `spawn_tools.ts` — runAgent berbasis HTTP provider + config_resolver
|
||||
- [x] `resolve_subagent_provider` (`subagent/provider.ts`) — pilih provider/model subagent dari settings + app_config
|
||||
- [x] `spawn_tools.ts` — isi body `spawnAgents` (paralel) & `spawnPipeline` (berurutan)
|
||||
- [x] `delegate.ts` — `runParallelDelegation` untuk `parallel_delegate`
|
||||
- [x] `http_provider.ts` — shared HTTP provider service builder
|
||||
- [ ] Auto-reviewer (`subagent/auto/*`) — `spawn_background_review`: git diff → LLM review → perbaiki;
|
||||
wiring `review_enabled` (yang sekarang no-op di `Review` agent)
|
||||
- [x] Placeholder di `apps/packages/infrastructure/src/subagent/*` diisi penuh
|
||||
|
||||
### Fase 3d — Workflow + hive-mind
|
||||
**Sumber Rust:** `apps/infrastructure/src/workflow/{script,mod,docs}.rs`, `workflow/engine/{execution,phases,primitives}.rs`, `workflow/hive_mind/{complexity,cycle,synthesis,live,types}.rs`
|
||||
|
||||
- [x] `parse_workflow_script(yaml)` — parser YAML workflow (`workflow/script.ts`)
|
||||
- [x] `execute_workflow(script, ctx)` — eksekusi multi-phase (`workflow/engine.ts` + `workflow/orchestrate.ts`)
|
||||
- [x] Phase primitives: run sub-agent per node (`executePrimitive`)
|
||||
- [x] `execute_cycle(cycle, ctx)` — hive-mind cycle, concurrency 8, kumpulkan NodeOutput (`workflow/cycle.ts`)
|
||||
- [x] `synthesize_consensus(node_outputs, ctx)` — konsensus via LLM + fallback concat (`workflow/synthesis.ts`)
|
||||
- [x] `complexity_heuristic` — pilih apakah cukup 1 cycle / butuh banyak (`workflow/complexity.ts`)
|
||||
- [x] `docs.ts` — tulis convergence/decision doc (`workflow/docs.ts`)
|
||||
- [x] Isi body `WorkflowRun` & `HiveMind` tool (ganti placeholder) di `tools/workflow.ts`
|
||||
- [x] `orchestrate.ts` — top-level `executeWorkflow` + `executeHiveMind` entry points
|
||||
|
||||
---
|
||||
|
||||
## ⬜ Yang belum dikerjakan
|
||||
|
||||
### Fase 3f — Auth infrastructure
|
||||
**Sumber Rust:** `apps/infrastructure/src/auth/{password,jwt,oauth_loopback,mod}.rs`
|
||||
|
||||
- [x] `password.ts` — Argon2 via `@node-rs/argon2` (hash + verify), port `PasswordService`
|
||||
- [x] `jwt.ts` — HMAC HS256 via `node:crypto` (access + refresh token, typ, exp), port `TokenService`
|
||||
- [x] `oauth_loopback.ts` — server TCP loopback lokal utk terima redirect OAuth; port `oauth_loopback`
|
||||
- [x] `mod.ts` (`index.ts`) — composisi ke `AuthService`
|
||||
- [x] Tambah dep `@node-rs/argon2` di `infrastructure/package.json`
|
||||
|
||||
### Fase 3g — IPC + bgbash
|
||||
**Sumber Rust:** `apps/infrastructure/src/ipc/{frame,protocol,server,client,conn}.rs`, `apps/infrastructure/src/bgbash/{job,control}.rs`
|
||||
|
||||
- [x] `ipc/frame.ts` — length-prefixed framing via `node:net` (kirim/terima bingkai biner)
|
||||
- [x] `ipc/conn.ts` — `Connection` framed JSON socket; `ipc/protocol.ts` — wire types
|
||||
- [x] `ipc/server.ts` + `ipc/client.ts` — Unix socket server + attach client; protocol message (NLJSON/binary)
|
||||
- [x] `bgbash/job.ts` — `BashJob` (pid, output file, log), `spawnBashJob`
|
||||
- [x] `bgbash/control.ts` — registry job global: `register`, `cancel(job_id)`, `is_running`
|
||||
- [x] Wire `bash` tool `run_in_background` → `spawnBashJob` + `register`; `bash_kill` → `cancel`
|
||||
- [x] Update `bash_output` untuk baca dari registry/output dir
|
||||
|
||||
### Fase 4 — Gateway + CLI + TUI
|
||||
**Sumber Rust:** `apps/gateway/src/{lib,main}.rs`, `apps/interfaces/tui/src/**`
|
||||
|
||||
- [x] `apps/interfaces/cli/` (paket Bun):
|
||||
- [x] Parser arg: `--api/--daemon/--attach/--ws/--grpc/--web`, dispatch mode (`args.ts`)
|
||||
- [x] Logging ke file (`index.ts`)
|
||||
- [x] `run_single_process` — komposisi root: wire semua implementasi konkret sekali (`compose.ts`) + REPL loop
|
||||
- [x] Bootstrap seed (`bootstrap.ts`) — wire-shape settings lama terbaca
|
||||
- [x] CLI: parser arg + dispatch + single-process REPL loop + bootstrap seed (Smoke: `bun run cli --help`, `bun run bootstrap` jalan; loop turn + tool read/write)
|
||||
- [x] TUI mode (`@zesdex/tui`, tanpa ncurses — render garis ANSI):
|
||||
- [x] Event loop raw-mode stdin (run.ts) + key decode (decodeKey)
|
||||
- [x] Key dispatcher `handleKey` → `Action[]` + overlay/autocomplete/editor handling (controller.ts)
|
||||
- [x] `Action` enum + `applyAction` dispatcher (action.ts) + slash-command parse/apply (command.ts)
|
||||
- [x] `InputState` (buffer/cursor/history/autocomplete) + `AppStateRest` state machine (state.ts)
|
||||
- [x] Renderer ringan (render.ts): transcript, input, overlay, status — 20 unit tests hijau
|
||||
- [x] Bootstrap seed (`bootstrap`) — `bun run bootstrap` seed defaults, wire-shape settings lama terbaca
|
||||
|
||||
### Fase 5 — Server interface: api / daemon / ws / grpc / web
|
||||
**Sumber Rust:** `apps/interfaces/{api,daemon,ws,grpc,web}/src/**`, `apps/infrastructure/src/middleware/*`
|
||||
|
||||
- [x] **API** (`apps/interfaces/api/`):
|
||||
- [x] Endpoint: `auth` (login/register/refresh, rate-limited), `sessions` CRUD, `conversations`, `chat/completions` proxy, `health`
|
||||
- [x] JWT middleware, CORS
|
||||
- [x] DTO (dto/)
|
||||
- [x] Smoke `bun run api`: health 200; register/login 200; wrong-pwd 401; protected-no-token 401; create/list/delete session; add conversation msg
|
||||
- [x] **daemon** — IPC agent-driver loop (Submit/Close) over per-session Unix socket; streaming token frames; wire CLI `--daemon`/`--attach`
|
||||
- [x] **ws** — handler `/ws`, protokol token/connected/done/error; smoke: no-token rejected 401, with-token welcome
|
||||
- [x] **grpc** — stub (health endpoint); smoke: `/grpc/health` 200
|
||||
- [x] **web** — static server + proteksi traversal; smoke: `/` serves index, traversal 404
|
||||
- [x] CLI `--api`/`--ws`/`--grpc`/`--web` dispatch → start server masing-masing (smoke: `bun run cli --api --api-port` → health 200)
|
||||
|
||||
### Fase 6 — Bersih-bersih + verifikasi akhir
|
||||
- [x] Hapus semua sisa crate Rust: `apps/{domain,application,infrastructure,gateway,bootstrap,interfaces/*}` file `.rs`, `Cargo.toml`, `Cargo.lock`, `.cargo/`, `lefthook.yml`
|
||||
- [x] Update `install.sh` → Bun
|
||||
- [x] Update CI workflow: `.github/workflows/{ci,release}.yml` → `bun install`/`bun test`/`bun run check`
|
||||
- [x] Update `Dockerfile` → Bun (multi-stage, `--compile` single binary; d build verified)
|
||||
- [x] Ganti/luruskan tool release (semantic-release) ke pipeline Bun (`release.yml` test job → bun; prepareCmd → `bun run check`)
|
||||
- [x] `flake.lock` dead (Rust/Nix era) dihapus — deploy infra 100% Bun (`deploy.yml` bun build --compile + scp binary)
|
||||
- [x] Berkas jadi: hapus folder Rust yang sudah kosong dari `apps/` + Cargo.toml/lock + .cargo + lefthook
|
||||
- [x] Verifikasi akhir: `tsc --noEmit` exit 0, `bun test` 74 hijau, `bun run cli --help` jalan
|
||||
- [x] Wire-shape lama terbaca — `settings.json` (Rust-era shape: api_keys/provider/model/review_*) dimuat true di headless + TUI; repos direct JSON parser. Verified: `--headless "1+1"` → "2", TUI header tunjuk `claude-opus-5`.
|
||||
|
||||
---
|
||||
|
||||
## Verifikasi global (tiap fase)
|
||||
- [ ] `bun run build` / `tsc --noEmit` tanpa error (strict)
|
||||
- [ ] `bun test` — port test Rust ke `*.test.ts` (SseParser, repairJson, resolveEffectiveModel, SessionId, turn_service, tool system, subagent, workflow)
|
||||
- [ ] Smoke: `bun run cli --help`; agent mode TUI dengan model stub/lokal; pastikan loop turn + tool read/write
|
||||
- [ ] Server: `bun run api` + hit health/auth; `bun run ws` + connect
|
||||
- [ ] Commit tiap fase dengan message Bahasa Indonesia + Conventional Commits; test hijau sebelum lanjut
|
||||
|
||||
## Catatan
|
||||
- Registry tool = **38** (terhitung workflow_run, note_finding, read_findings, hive_mind; ExploreCodebase tidak diport).
|
||||
- `Tool.run` boleh return `string | Promise<string>`.
|
||||
- `spawn_agents`/`spawn_pipeline`/`parallel_delegate` sudah terisi penuh via 3c.
|
||||
- Workflow/hive-mind tools sudah terisi penuh via 3d.
|
||||
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "zesdex-application"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Application layer — port traits (interfaces), use cases, DTOs.
|
||||
# Depends ONLY on domain. Application services orchestrate domain objects
|
||||
# through port traits without knowing concrete implementations.
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
futures-util.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
@@ -1,35 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
use zesdex_domain::agent::AgentTurnParams;
|
||||
|
||||
/// Interface for dispatching tool calls to their concrete implementations.
|
||||
pub trait ToolExecutor: Send + Sync {
|
||||
/// Execute a tool call asynchronously.
|
||||
fn execute(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> impl Future<Output = Result<String>> + Send;
|
||||
|
||||
/// Whether a tool is *read-only* and therefore safe to run concurrently
|
||||
/// with other read-only tools in the same assistant message.
|
||||
///
|
||||
/// Defaults to `false` (sequential) so a caller that does not know the
|
||||
/// tool surface stays conservative. Concrete executors that know their
|
||||
/// tools override this — e.g. return `true` for `read`/`grep`/`glob`.
|
||||
fn is_parallel_safe(&self, tool_name: &str) -> bool {
|
||||
let _ = tool_name;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for running agent turns asynchronously.
|
||||
pub trait AgentTurnService: Send + Sync {
|
||||
/// Run a full agent turn loop asynchronously.
|
||||
fn run_turn(&self, params: AgentTurnParams) -> impl Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
pub mod turn_service;
|
||||
|
||||
pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl};
|
||||
@@ -1,696 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
|
||||
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||
use zesdex_domain::main_agent_prompt;
|
||||
|
||||
use super::ToolExecutor;
|
||||
use crate::ports::ProviderService;
|
||||
|
||||
/// Maximum tool-call iterations per agent turn before forcing termination.
|
||||
const MAX_TURN_ITERATIONS: u32 = 50;
|
||||
|
||||
/// Maximum number of consecutive identical tool errors before the loop
|
||||
/// injects a recovery note and forces a different approach.
|
||||
const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3;
|
||||
|
||||
/// Total tool-call errors tolerated per turn before the loop is stopped.
|
||||
const MAX_TOTAL_TOOL_ERRORS: usize = 8;
|
||||
|
||||
/// Ceiling for a single tool-result message inserted into context.
|
||||
///
|
||||
/// Tool outputs can be huge (read / semantic_search). Truncating keeps the
|
||||
/// context window from exploding while preserving the important head.
|
||||
const TOOL_OUTPUT_MAX_CHARS: usize = 12_000;
|
||||
|
||||
/// Total conversation characters that trigger auto-compaction before the
|
||||
/// next LLM call.
|
||||
const AUTO_COMPACT_CHARS: usize = 60_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: push a TurnEvent onto the shared queue.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
||||
if let Ok(mut q) = queue.lock() {
|
||||
q.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: stream-event callback that forwards tokens to the turn-event queue
|
||||
// and checks the abort flag on each emission.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_stream_callback(
|
||||
abort: &Arc<AtomicBool>,
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
) -> Box<dyn FnMut(&StreamEvent) -> bool + Send> {
|
||||
let abort_clone = Arc::clone(abort);
|
||||
let events_clone = Arc::clone(turn_events);
|
||||
Box::new(move |event: &StreamEvent| -> bool {
|
||||
if abort_clone.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
match event {
|
||||
StreamEvent::Token(s) => {
|
||||
push_event(&events_clone, TurnEvent::StreamToken(s.clone()));
|
||||
}
|
||||
StreamEvent::Reasoning(s) => {
|
||||
push_event(&events_clone, TurnEvent::StreamReasoning(s.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: truncate a long tool output before it enters the conversation
|
||||
// context. Preserves the head and appends a clear truncation marker.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn truncate_tool_output(output: String) -> String {
|
||||
if output.len() <= TOOL_OUTPUT_MAX_CHARS {
|
||||
return output;
|
||||
}
|
||||
let mut result: String = output.chars().take(TOOL_OUTPUT_MAX_CHARS).collect();
|
||||
result.push_str(&format!(
|
||||
"\n...[truncated {} chars]",
|
||||
output.len() - TOOL_OUTPUT_MAX_CHARS
|
||||
));
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: adaptive generation parameters.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pick a `max_tokens` budget for the turn's next LLM call based on the
|
||||
/// length of the user's request. Short requests need far fewer tokens than
|
||||
/// the current hardcoded 4096 — big savings on small tasks.
|
||||
fn adaptive_max_tokens(request_len: usize) -> u32 {
|
||||
if request_len <= 80 {
|
||||
800
|
||||
} else if request_len <= 400 {
|
||||
1600
|
||||
} else {
|
||||
4096
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum the character length of the conversation (user + assistant +
|
||||
/// tool content) as a cheap proxy for context size.
|
||||
fn conversation_chars(messages: &[ChatMessage]) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_deref().map(str::len).unwrap_or(0))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Track repeated tool-call errors so the loop can recover instead of
|
||||
/// burning iterations retrying the same failing tool.
|
||||
#[derive(Default)]
|
||||
struct ErrorTracker {
|
||||
consecutive: usize,
|
||||
total: usize,
|
||||
last_tool: String,
|
||||
last_error: String,
|
||||
}
|
||||
|
||||
impl ErrorTracker {
|
||||
fn record(&mut self, tool_name: &str, error: &str, messages: &mut Vec<ChatMessage>) {
|
||||
if self.last_tool == tool_name {
|
||||
self.consecutive += 1;
|
||||
} else {
|
||||
self.consecutive = 1;
|
||||
}
|
||||
self.last_tool = tool_name.to_string();
|
||||
self.last_error = error.to_string();
|
||||
self.total += 1;
|
||||
|
||||
// Inject a recovery note once the same tool keeps failing.
|
||||
if self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS
|
||||
&& !messages.iter().any(|m| {
|
||||
m.content
|
||||
.as_deref()
|
||||
.is_some_and(|c| c.contains("[System note]"))
|
||||
})
|
||||
{
|
||||
messages.push(ChatMessage::system(
|
||||
zesdex_domain::agent::prompt::error_recovery_note(tool_name, error),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn should_stop(&self) -> bool {
|
||||
self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS * 2 || self.total >= MAX_TOTAL_TOOL_ERRORS
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: execute a single tool call, push events, return the result string.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn execute_tool_call<T: ToolExecutor>(
|
||||
tool_executor: &T,
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
tc: &zesdex_domain::core::ToolCall,
|
||||
) -> String {
|
||||
let name = &tc.function.name;
|
||||
let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments);
|
||||
|
||||
debug!("executing tool: {name}");
|
||||
|
||||
let output = match tool_executor.execute(name, &args).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
};
|
||||
|
||||
let is_error = output.starts_with("Error:");
|
||||
let output = truncate_tool_output(output);
|
||||
|
||||
push_event(
|
||||
turn_events,
|
||||
TurnEvent::ToolResult {
|
||||
tool_call_id: tc.id.clone(),
|
||||
tool_name: name.clone(),
|
||||
output: output.clone(),
|
||||
is_error,
|
||||
path: None,
|
||||
},
|
||||
);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: bounded-parallel execution of read-only tool calls.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum number of read-only tool calls executed concurrently in a single
|
||||
/// assistant batch. Models rarely emit more than a handful of reads per
|
||||
/// message; this cap keeps resource usage bounded while still removing the
|
||||
/// serial round-trip latency of many independent lookups.
|
||||
const MAX_PARALLEL_TOOLS: usize = 8;
|
||||
|
||||
fn tool_executor_ref<T: ToolExecutor>(tool_executor: &T) -> &T {
|
||||
tool_executor
|
||||
}
|
||||
|
||||
/// Execute a batch of *read-only* tool calls concurrently (bounded by
|
||||
/// [`MAX_PARALLEL_TOOLS`]) and return their outputs **in the original call
|
||||
/// order**.
|
||||
///
|
||||
/// Order preservation matters: OpenAI/Anthropic tool-calling contracts expect
|
||||
/// tool-result messages to appear in the same order as the `tool_calls`
|
||||
/// emitted in the assistant message. Without it, the model sees shuffled
|
||||
/// results and loses track of which result belongs to which call.
|
||||
///
|
||||
/// Each call still pushes its `TurnEvent::ToolResult` (so the TUI shows each
|
||||
/// tool as it completes) but the returned `Vec` is ordered by the input index.
|
||||
async fn execute_tool_calls_in_parallel<T: ToolExecutor>(
|
||||
tool_executor: &T,
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
tool_calls: &[zesdex_domain::core::ToolCall],
|
||||
) -> Vec<String> {
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_PARALLEL_TOOLS));
|
||||
let executor_ref = tool_executor_ref(tool_executor);
|
||||
|
||||
let futures = tool_calls.iter().map(|tc| {
|
||||
let tc = tc.clone();
|
||||
let events = turn_events.clone();
|
||||
let sem = semaphore.clone();
|
||||
async move {
|
||||
// Acquire a permit to bound concurrency across the batch.
|
||||
let _permit = sem.acquire_owned().await;
|
||||
execute_tool_call(executor_ref, &events, &tc).await
|
||||
}
|
||||
});
|
||||
|
||||
futures_util::future::join_all(futures).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: emit usage event from optional LLM response metadata.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn emit_usage(turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, usage: Option<(u64, u64)>) {
|
||||
if let Some((tokens_in, tokens_out)) = usage {
|
||||
push_event(
|
||||
turn_events,
|
||||
TurnEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Service implementation for executing an agent turn asynchronously.
|
||||
///
|
||||
/// The turn loop is adaptive and token-aware:
|
||||
/// - No mandatory explore phase — the *agent* decides when to call the
|
||||
/// `explore_codebase` tool (see the main prompt), so simple queries skip
|
||||
/// exploration entirely.
|
||||
/// - `max_tokens` / `temperature` adapt to the request length and phase.
|
||||
/// - Repeated tool errors trigger a system recovery note and eventually
|
||||
/// stop the loop instead of burning iterations.
|
||||
/// - Tool outputs are truncated before entering context.
|
||||
/// - Oversized histories are auto-compacted before the next LLM call.
|
||||
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
|
||||
provider: Arc<P>,
|
||||
tool_executor: Arc<T>,
|
||||
tool_defs: Vec<ToolDef>,
|
||||
}
|
||||
|
||||
impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
||||
pub fn new(provider: Arc<P>, tool_executor: Arc<T>, tool_defs: Vec<ToolDef>) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
tool_executor,
|
||||
tool_defs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a single LLM call with the current message list, handling
|
||||
/// streaming events and error reporting.
|
||||
async fn call_llm(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
abort: &Arc<AtomicBool>,
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
max_tokens: u32,
|
||||
temperature: f32,
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>), String> {
|
||||
let on_event = make_stream_callback(abort, turn_events);
|
||||
|
||||
self.provider
|
||||
.chat_stream(
|
||||
messages,
|
||||
Some(self.tool_defs.clone()),
|
||||
Some(max_tokens),
|
||||
Some(temperature),
|
||||
on_event,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("LLM error: {e}"))
|
||||
}
|
||||
|
||||
/// Auto-compact the history in place if it exceeds the threshold.
|
||||
///
|
||||
/// Runs at most once per turn. Skips the synthetic system prompt that
|
||||
/// this service inserts at index 0.
|
||||
async fn auto_compact_if_needed(&self, messages: &mut Vec<ChatMessage>) {
|
||||
if conversation_chars(messages) <= AUTO_COMPACT_CHARS {
|
||||
return;
|
||||
}
|
||||
// Keep the system prompt (index 0) out of compaction.
|
||||
let sys = messages[0].clone();
|
||||
let mut rest: Vec<ChatMessage> = messages.drain(1..).collect();
|
||||
let before = rest.len();
|
||||
if let Err(e) = super::compact_messages_with_ai(&mut rest, self.provider.as_ref()).await {
|
||||
warn!("auto-compact failed (non-fatal): {e}");
|
||||
}
|
||||
info!(
|
||||
"auto-compacted history: {} messages -> {}",
|
||||
before,
|
||||
rest.len()
|
||||
);
|
||||
let mut rebuilt = Vec::with_capacity(rest.len() + 1);
|
||||
rebuilt.push(sys);
|
||||
rebuilt.extend(rest);
|
||||
*messages = rebuilt;
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnServiceImpl<P, T> {
|
||||
async fn run_turn(&self, mut params: AgentTurnParams) -> anyhow::Result<()> {
|
||||
info!(
|
||||
"Starting async agent turn with {} messages (model: {})",
|
||||
params.messages.len(),
|
||||
params.model
|
||||
);
|
||||
|
||||
// Insert system prompt at position 0 once and keep it there for the
|
||||
// entire turn, avoiding per-iteration clones of the full message list.
|
||||
params
|
||||
.messages
|
||||
.insert(0, ChatMessage::system(main_agent_prompt()));
|
||||
let original_count = params.messages.len();
|
||||
|
||||
// Estimate request complexity from the last user message.
|
||||
let request_len = params
|
||||
.messages
|
||||
.last()
|
||||
.and_then(|m| m.content.as_deref())
|
||||
.map(str::len)
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut errors = ErrorTracker::default();
|
||||
// Track whether the previous call produced tool calls — used to
|
||||
// lower temperature once the agent starts producing a final answer.
|
||||
let mut saw_tool_calls = false;
|
||||
|
||||
for iteration in 0..MAX_TURN_ITERATIONS {
|
||||
// ── Check abort flag ────────────────────────────────────────
|
||||
if params.abort.load(Ordering::SeqCst) {
|
||||
params.abort.store(false, Ordering::SeqCst);
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::SystemNote {
|
||||
kind: "info".into(),
|
||||
message: "Turn aborted by user".into(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if errors.should_stop() {
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::SystemNote {
|
||||
kind: "warn".into(),
|
||||
message: "Stopping: repeated tool errors without progress".into(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
debug!("agent turn iteration {iteration}");
|
||||
|
||||
// ── Auto-compact oversized history before the LLM call ─────
|
||||
self.auto_compact_if_needed(&mut params.messages).await;
|
||||
|
||||
// ── Adaptive generation parameters ─────────────────────────
|
||||
let max_tokens = adaptive_max_tokens(request_len);
|
||||
// Lower temperature while the agent is still choosing tools to
|
||||
// keep tool selection deterministic; raise it for the final
|
||||
// free-form answer.
|
||||
let temperature = if saw_tool_calls { 0.2 } else { 0.7 };
|
||||
|
||||
// ── Stream start + call LLM ─────────────────────────────────
|
||||
push_event(¶ms.turn_events, TurnEvent::StreamStart);
|
||||
|
||||
let result = self
|
||||
.call_llm(
|
||||
¶ms.messages,
|
||||
¶ms.abort,
|
||||
¶ms.turn_events,
|
||||
max_tokens,
|
||||
temperature,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok((assistant_msg, usage)) => {
|
||||
let content = assistant_msg.content.clone().unwrap_or_default();
|
||||
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::StreamDone(assistant_msg.clone()),
|
||||
);
|
||||
|
||||
emit_usage(¶ms.turn_events, usage);
|
||||
|
||||
// ── No tool calls → assistant is done ──────────────
|
||||
if tool_calls.is_empty() {
|
||||
params.messages.push(ChatMessage::assistant(Some(content)));
|
||||
break;
|
||||
}
|
||||
|
||||
saw_tool_calls = true;
|
||||
params.messages.push(assistant_msg);
|
||||
|
||||
// ── Execute each tool call ──────────────────────────
|
||||
//
|
||||
// If the whole batch is made of *read-only* tools
|
||||
// (read/grep/glob/…), run it concurrently with bounded
|
||||
// parallelism — a big latency win for coding turns that
|
||||
// emit several independent lookups in one message. Any
|
||||
// single mutating tool forces the whole batch back to the
|
||||
// safe sequential path so writes never race.
|
||||
//
|
||||
// Results are always collected in the original call order
|
||||
// to honour the tool-calling contract.
|
||||
let parallel = tool_calls.len() > 1
|
||||
&& tool_calls
|
||||
.iter()
|
||||
.all(|tc| self.tool_executor.is_parallel_safe(&tc.function.name));
|
||||
let outputs: Vec<String> = if parallel {
|
||||
execute_tool_calls_in_parallel(
|
||||
self.tool_executor.as_ref(),
|
||||
¶ms.turn_events,
|
||||
&tool_calls,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let mut sequential = Vec::with_capacity(tool_calls.len());
|
||||
for tc in &tool_calls {
|
||||
let out = execute_tool_call(
|
||||
self.tool_executor.as_ref(),
|
||||
¶ms.turn_events,
|
||||
tc,
|
||||
)
|
||||
.await;
|
||||
sequential.push(out);
|
||||
}
|
||||
sequential
|
||||
};
|
||||
|
||||
for (tc, output) in tool_calls.iter().zip(outputs) {
|
||||
if output.starts_with("Error:") {
|
||||
errors.record(&tc.function.name, &output, &mut params.messages);
|
||||
}
|
||||
params
|
||||
.messages
|
||||
.push(ChatMessage::tool(tc.id.clone(), output));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{e}");
|
||||
push_event(¶ms.turn_events, TurnEvent::Error(e));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the synthetic sys_msg before shipping events to the TUI
|
||||
// so the transcript shows only the actual user/assistant/tool exchange.
|
||||
let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect();
|
||||
push_event(¶ms.turn_events, TurnEvent::Compacted(compacted));
|
||||
push_event(¶ms.turn_events, TurnEvent::Done);
|
||||
params.in_flight.store(false, Ordering::SeqCst);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation compaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum number of recent messages to preserve during compaction.
|
||||
const COMPACT_KEEP_TAIL: usize = 6;
|
||||
|
||||
/// Compacts conversation history using AI summarisation.
|
||||
///
|
||||
/// Flow: if the message count exceeds `KEEP_TAIL + 2`, the oldest messages
|
||||
/// are drained and summarised by the LLM. The summary is inserted as a
|
||||
/// system message at the head of the remaining history.
|
||||
pub async fn compact_messages_with_ai<P: ProviderService>(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
provider: &P,
|
||||
) -> anyhow::Result<()> {
|
||||
if messages.len() <= COMPACT_KEEP_TAIL + 2 {
|
||||
return Ok(()); // Not enough messages to compact
|
||||
}
|
||||
|
||||
let split_idx = messages.len() - COMPACT_KEEP_TAIL;
|
||||
let evicted: Vec<_> = messages.drain(..split_idx).collect();
|
||||
|
||||
let mut summary_prompt = vec![ChatMessage::system(zesdex_domain::compaction_prompt())];
|
||||
summary_prompt.extend(evicted);
|
||||
summary_prompt.push(ChatMessage::user(
|
||||
"Please summarise our previous conversation above for context continuity.".to_string(),
|
||||
));
|
||||
|
||||
match provider
|
||||
.chat(&summary_prompt, None, Some(1024), Some(0.3))
|
||||
.await
|
||||
{
|
||||
Ok((summary_msg, _)) => {
|
||||
let summary_text = summary_msg
|
||||
.content
|
||||
.unwrap_or_else(|| "Previous context summarised.".to_string());
|
||||
let summary_node = ChatMessage::system(format!(
|
||||
"[AI Summary of Previous Conversation]\n{}",
|
||||
summary_text.trim()
|
||||
));
|
||||
messages.insert(0, summary_node);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("AI summarisation failed during compact, falling back to simple notice: {e}");
|
||||
messages.insert(
|
||||
0,
|
||||
ChatMessage::system(
|
||||
"[Earlier conversation messages compacted to save context window]".to_string(),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn truncate_short_output_is_unchanged() {
|
||||
let out = "short".to_string();
|
||||
assert_eq!(truncate_tool_output(out.clone()), out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_long_output_preserves_head_and_marks_cut() {
|
||||
let long = "x".repeat(TOOL_OUTPUT_MAX_CHARS + 500);
|
||||
let truncated = truncate_tool_output(long.clone());
|
||||
assert!(truncated.len() < long.len());
|
||||
assert!(truncated.contains("...[truncated"));
|
||||
assert!(truncated.starts_with("xxx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_max_tokens_scales_with_request_len() {
|
||||
assert_eq!(adaptive_max_tokens(10), 800);
|
||||
assert_eq!(adaptive_max_tokens(200), 1600);
|
||||
assert_eq!(adaptive_max_tokens(5000), 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_tracker_injects_recovery_note_after_repeats() {
|
||||
let mut tracker = ErrorTracker::default();
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
tracker.record("read", "Error: File not found", &mut messages);
|
||||
tracker.record("read", "Error: File not found", &mut messages);
|
||||
assert!(!tracker.should_stop());
|
||||
// Third consecutive failure → recovery note injected.
|
||||
tracker.record("read", "Error: File not found", &mut messages);
|
||||
assert!(messages.iter().any(|m| m
|
||||
.content
|
||||
.as_deref()
|
||||
.is_some_and(|c| c.contains("[System note]"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_tracker_stops_after_too_many_errors() {
|
||||
let mut tracker = ErrorTracker::default();
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
for i in 0..MAX_TOTAL_TOOL_ERRORS {
|
||||
tracker.record("bash", &format!("Error: boom {i}"), &mut messages);
|
||||
}
|
||||
assert!(tracker.should_stop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_chars_sums_content_only() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("sys".to_string()),
|
||||
ChatMessage::user("hello world".to_string()),
|
||||
ChatMessage::tool("id".to_string(), "output".to_string()),
|
||||
];
|
||||
assert_eq!(conversation_chars(&messages), 3 + 11 + 6);
|
||||
}
|
||||
|
||||
/// A fake executor that reports parallel-safety for read-only tools and
|
||||
/// whose `execute` sleeps on the first call to prove the batch runs
|
||||
/// concurrently (a sequential loop would pay the sleep per call).
|
||||
struct FakeExecutor;
|
||||
|
||||
impl ToolExecutor for FakeExecutor {
|
||||
async fn execute(&self, name: &str, _args: &serde_json::Value) -> anyhow::Result<String> {
|
||||
if name == "read" {
|
||||
// 30ms sleep on every read; a parallel batch of 3 would
|
||||
// finish in ~30ms instead of ~90ms sequentially.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
}
|
||||
Ok(format!("out:{name}"))
|
||||
}
|
||||
|
||||
fn is_parallel_safe(&self, name: &str) -> bool {
|
||||
matches!(name, "read" | "grep")
|
||||
}
|
||||
}
|
||||
|
||||
fn tc(name: &str, id: usize) -> zesdex_domain::core::ToolCall {
|
||||
zesdex_domain::core::ToolCall {
|
||||
id: format!("call_{id}"),
|
||||
type_: "function".to_string(),
|
||||
function: zesdex_domain::core::ToolFunction {
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::Value::String(String::new()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_batch_runs_concurrently_and_preserves_order() {
|
||||
let executor = FakeExecutor;
|
||||
let events = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let calls = vec![tc("read", 1), tc("grep", 2), tc("read", 3)];
|
||||
|
||||
// All three are parallel-safe.
|
||||
assert!(calls
|
||||
.iter()
|
||||
.all(|c| executor.is_parallel_safe(&c.function.name)));
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let outputs = rt.block_on(execute_tool_calls_in_parallel(&executor, &events, &calls));
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
// Results are in *original* call order (read, grep, read).
|
||||
assert_eq!(
|
||||
outputs,
|
||||
vec![
|
||||
"out:read".to_string(),
|
||||
"out:grep".to_string(),
|
||||
"out:read".to_string()
|
||||
]
|
||||
);
|
||||
// Two reads sleep 30ms each; sequential would take ~60ms+ for the
|
||||
// two reads, parallel keeps the whole batch under 60ms.
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(60),
|
||||
"batch took {elapsed:?}, expected parallel execution"
|
||||
);
|
||||
assert!(elapsed >= std::time::Duration::from_millis(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutating_batch_falls_back_to_sequential_path() {
|
||||
// A batch containing a mutating tool is not eligible for the parallel
|
||||
// path, so the main loop keeps results ordered and side-effects safe.
|
||||
let executor = FakeExecutor;
|
||||
let calls = [tc("read", 1), tc("edit", 2)];
|
||||
assert!(!calls
|
||||
.iter()
|
||||
.all(|c| executor.is_parallel_safe(&c.function.name)));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,237 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,67 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! # 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 agent;
|
||||
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,
|
||||
};
|
||||
|
||||
pub use agent::{
|
||||
turn_service::{compact_messages_with_ai, AgentTurnServiceImpl},
|
||||
AgentTurnService, ToolExecutor,
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
//! 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)>;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,24 +0,0 @@
|
||||
//! 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;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! 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;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//! 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>;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[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
|
||||
@@ -1,2 +0,0 @@
|
||||
//! Bootstrap library — shared utilities for the bootstrap binary.
|
||||
//! The main entry point is in `main.rs`.
|
||||
@@ -1,44 +0,0 @@
|
||||
//! 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 +0,0 @@
|
||||
[package]
|
||||
name = "zesdex-domain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Domain layer — PURE entities, value objects, repository/service traits.
|
||||
# Zero framework dependencies. Only serde for serialization, chrono for
|
||||
# timestamps, uuid for identity.
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
libc.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -1,34 +0,0 @@
|
||||
//! Shared default constants used across the application.
|
||||
//!
|
||||
//! Centralising these values eliminates the hardcoded-string duplication
|
||||
//! that existed when every call site provided its own inline fallback.
|
||||
//! Consumers should reference these constants rather than repeating
|
||||
//! the string literals.
|
||||
|
||||
/// Default LLM provider API base URL.
|
||||
pub const DEFAULT_API_BASE: &str = "https://opencode.ai/zen/v1";
|
||||
|
||||
/// Default LLM model identifier.
|
||||
pub const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||
|
||||
/// Fallback JWT secret used only when `JWT_SECRET` env var is unset.
|
||||
/// In production this MUST be configured via environment variable.
|
||||
pub const FALLBACK_JWT_SECRET: &str = "dev-secret";
|
||||
|
||||
/// Default context window size (128k tokens).
|
||||
pub const DEFAULT_CONTEXT_WINDOW: usize = 256_000;
|
||||
|
||||
/// Maximum tool-call iterations per agent turn.
|
||||
pub const MAX_TOOL_ITERATIONS: u32 = 50;
|
||||
|
||||
/// Maximum subagent tool-call iterations.
|
||||
pub const MAX_SUBAGENT_ITERATIONS: u32 = 25;
|
||||
|
||||
/// Default LLM request max tokens.
|
||||
pub const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
/// Default temperature for the main agent.
|
||||
pub const DEFAULT_TEMPERATURE: f64 = 0.7;
|
||||
|
||||
/// Default temperature for compaction / summary calls.
|
||||
pub const DEFAULT_COMPACT_TEMPERATURE: f64 = 0.3;
|
||||
@@ -1,260 +0,0 @@
|
||||
//! Domain types for agent lifecycle: turn events, session runtime, progress
|
||||
//! reporting, prompts, and the agent-turn parameter bundle.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
||||
|
||||
pub mod defaults;
|
||||
pub mod progress;
|
||||
pub mod prompt;
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
StreamReasoning(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: AgentStatus,
|
||||
},
|
||||
TodoUpdate(String),
|
||||
PlanUpdate(String),
|
||||
/// Structured progress report from a subagent or workflow node,
|
||||
/// carrying the current tool name and optional step counters.
|
||||
AgentProgress(crate::agent::progress::AgentProgress),
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Tracks counts of learned patterns by outcome and lifecycle stage.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LessonStats {
|
||||
/// Total number of lessons tracked.
|
||||
pub total: u32,
|
||||
/// User-initiated lessons.
|
||||
pub user: u32,
|
||||
/// Feedback-driven lessons.
|
||||
pub feedback: u32,
|
||||
/// Project-scoped lessons.
|
||||
pub project: u32,
|
||||
/// Reference-scoped lessons.
|
||||
pub reference: u32,
|
||||
/// Currently active lessons.
|
||||
pub active: u32,
|
||||
/// Stale (outdated) lessons.
|
||||
pub stale: u32,
|
||||
/// Contradicted lessons.
|
||||
pub contradicted: u32,
|
||||
/// Human-authored lessons.
|
||||
pub human: u32,
|
||||
/// Verified lessons.
|
||||
pub verified: u32,
|
||||
/// Unverified lessons.
|
||||
pub unverified: u32,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Aggregated lesson statistics.
|
||||
pub lessons: LessonStats,
|
||||
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(),
|
||||
lessons: LessonStats::default(),
|
||||
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,
|
||||
}
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Owned parameters required to spawn and execute an agent turn.
|
||||
pub struct AgentTurnParams {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub session_dir: PathBuf,
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
pub in_flight: Arc<AtomicBool>,
|
||||
pub abort: Arc<AtomicBool>,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub api_base: Option<String>,
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
//! Progress reporting types for long-running agent and subagent operations.
|
||||
//!
|
||||
//! These types are emitted onto the turn-event queue to drive the TUI's
|
||||
//! spinner, progress bar, and agent-status sidebar. They are pure domain
|
||||
//! types with no I/O or framework dependency.
|
||||
|
||||
use crate::agent::AgentStatus;
|
||||
|
||||
/// Describes progress within a single subagent or workflow-node execution.
|
||||
///
|
||||
/// Emitted as a `TurnEvent::AgentProgress` so the UI can show which tool
|
||||
/// the subagent is currently invoking, or which step it has reached.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentProgress {
|
||||
/// Unique identifier for this agent (e.g. `"Node-0-1"`, `"auto-review"`).
|
||||
pub agent_id: String,
|
||||
/// Human-readable display name shown in the TUI sidebar.
|
||||
pub agent_name: String,
|
||||
/// Current lifecycle status.
|
||||
pub status: AgentStatus,
|
||||
/// Optional description of the current tool or step being executed.
|
||||
/// Set to `None` when the agent is not actively executing a tool.
|
||||
pub current_tool: Option<String>,
|
||||
/// Optional progress range: (completed_steps, total_steps).
|
||||
/// When `None`, the agent shows an indeterminate spinner.
|
||||
pub steps: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl AgentProgress {
|
||||
/// Mark this agent as running with an optional tool name.
|
||||
pub fn running(
|
||||
agent_id: impl Into<String>,
|
||||
agent_name: impl Into<String>,
|
||||
current_tool: Option<String>,
|
||||
) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Running,
|
||||
current_tool,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this agent as pending (queued but not yet started).
|
||||
pub fn pending(agent_id: impl Into<String>, agent_name: impl Into<String>) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Pending,
|
||||
current_tool: None,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this agent as completed successfully.
|
||||
pub fn completed(agent_id: impl Into<String>, agent_name: impl Into<String>) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Completed,
|
||||
current_tool: None,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this agent as failed with an error message.
|
||||
pub fn failed(
|
||||
agent_id: impl Into<String>,
|
||||
agent_name: impl Into<String>,
|
||||
error: String,
|
||||
) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Failed(error),
|
||||
current_tool: None,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//! System prompts and directive templates for agent and subagent turns.
|
||||
//!
|
||||
//! Centralising all prompt text here keeps the core turn logic free of
|
||||
//! hardcoded prose, making prompts easier to maintain, review, and localise.
|
||||
//!
|
||||
//! # Flow
|
||||
//! The application layer's `AgentTurnServiceImpl` calls `main_agent_prompt()`
|
||||
//! to construct the system message at the start of each turn. Subagent and
|
||||
//! review prompts are provided by their respective modules.
|
||||
|
||||
/// Build the main-agent system prompt.
|
||||
///
|
||||
/// The prompt establishes the agent's identity as Zesdex, an AI coding
|
||||
/// assistant, and defines the priority hierarchy that governs tool selection:
|
||||
///
|
||||
/// 1. **Workflow first** — `workflow_run` / `hive_mind` for complex tasks
|
||||
/// 2. **Planning & TODOs** — `plan_enter` / `todowrite` for structural work
|
||||
/// 3. **Reasoning** — `seq_think` for deep analysis
|
||||
/// 4. **Tool execution** — direct tools for simple actions
|
||||
pub fn main_agent_prompt() -> String {
|
||||
"\
|
||||
You are Zesdex, an AI coding assistant. You have access to various tools \
|
||||
via native function calling to help the user.
|
||||
|
||||
TOKEN BUDGET — BE EFFICIENT:
|
||||
- For simple/factual questions, answer directly. Do NOT call tools.
|
||||
- For complex or unfamiliar code tasks, call `explore_codebase` ONCE at the \
|
||||
start to locate relevant code, then work from that context.
|
||||
- Keep tool usage minimal: prefer `grep`/`glob`/`read` for targeted lookups; \
|
||||
avoid re-reading files you already have in context.
|
||||
- Keep responses concise; do not repeat tool output verbatim.
|
||||
|
||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
|
||||
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \
|
||||
you MUST prioritise using `workflow_run` (to construct and execute a \
|
||||
multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \
|
||||
autonomous agents). Workflows are your primary strategy.
|
||||
2. PLANNING & TODOs: Use `plan_enter` to establish high-level \
|
||||
architectural plans and `todowrite` to maintain granular task checklists.
|
||||
3. REASONING: Use `seq_think` for deep step-by-step analysis.
|
||||
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \
|
||||
within or guided by your workflows. If an error occurs, analyse and fix it.
|
||||
|
||||
Respond conversationally, concisely, and helpfully."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build a subagent directive prompt.
|
||||
///
|
||||
/// The directive is embedded in a system message that also communicates the
|
||||
/// current working directory and workspace root so the subagent can resolve
|
||||
/// paths correctly.
|
||||
pub fn subagent_directive(directive: &str, cwd: &str, ws_root: &str) -> String {
|
||||
format!(
|
||||
"\
|
||||
You are a focused subagent.
|
||||
|
||||
Current directory (PWD): {cwd}
|
||||
Workspace root: {ws_root}
|
||||
|
||||
Your directive:
|
||||
{directive}
|
||||
|
||||
Complete the directive autonomously using the tools available to you. \
|
||||
Return your final answer when done."
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a conversation-compaction prompt.
|
||||
///
|
||||
/// The LLM is asked to produce a concise bulleted summary of the key
|
||||
/// requests, decisions, tools executed, and files modified.
|
||||
pub fn compaction_prompt() -> String {
|
||||
"\
|
||||
You are a helpful assistant summarising conversation history. \
|
||||
Provide a concise summary of the key user requests, decisions, tools \
|
||||
executed, and modified files. Format as a clear bulleted list."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adaptive explore: directives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Directive for a single lightweight context-scout subagent.
|
||||
pub fn explore_scout_directive() -> String {
|
||||
"\
|
||||
You are a codebase context scout. \
|
||||
Given the workspace root, quickly locate the code that is most relevant \
|
||||
to the user's request: \
|
||||
1. Run semantic_search once with the user's key terms. \
|
||||
2. Read up to the 3 most relevant files (use grep for symbols if needed). \
|
||||
3. Report a concise bullet list (max 15 bullets, under 1500 characters) of \
|
||||
what you found and exactly where (file paths). \
|
||||
Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build a system note injected after repeated tool errors to steer the
|
||||
/// agent toward an alternative approach instead of retrying the same call.
|
||||
pub fn error_recovery_note(tool_name: &str, last_error: &str) -> String {
|
||||
format!(
|
||||
"\
|
||||
[System note] The tool `{tool_name}` failed repeatedly with: \"{last_error}\". \
|
||||
Try an alternative approach (verify paths, correct arguments, use a \
|
||||
different tool, or finish without this tool). Do NOT retry the same call."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn main_prompt_is_non_empty() {
|
||||
let prompt = main_agent_prompt();
|
||||
assert!(!prompt.is_empty());
|
||||
assert!(prompt.contains("Zesdex"));
|
||||
assert!(prompt.contains("WORKFLOW FIRST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_directive_includes_directive_text() {
|
||||
let prompt = subagent_directive("test directive", "/home", "/home/project");
|
||||
assert!(prompt.contains("test directive"));
|
||||
assert!(prompt.contains("/home"));
|
||||
assert!(prompt.contains("/home/project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explore_scout_directive_is_concise_and_mentions_tools() {
|
||||
let scout = explore_scout_directive();
|
||||
assert!(scout.contains("scout"));
|
||||
assert!(scout.contains("semantic_search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_recovery_note_suggests_alternative() {
|
||||
let note = error_recovery_note("read", "File not found");
|
||||
assert!(note.contains("read"));
|
||||
assert!(note.contains("alternative"));
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,36 +0,0 @@
|
||||
//! 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,53 +0,0 @@
|
||||
//! Pure OAuth entities — no HTTP or persistence logic.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`OAuthToken`] — access token with optional refresh token, epoch expiry
|
||||
//! - [`OAuthConfig`] — provider configuration (auth URL, token URL, client id,
|
||||
//! optional client secret, scopes)
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An OAuth 2.0 access token with optional refresh token and absolute
|
||||
/// expiry time (epoch seconds).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthToken {
|
||||
/// The OAuth 2.0 access token string.
|
||||
pub access_token: String,
|
||||
/// Optional refresh token for long-lived access.
|
||||
pub refresh_token: Option<String>,
|
||||
/// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH).
|
||||
pub expires_at: u64,
|
||||
/// Token type, e.g. `"Bearer"`.
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
/// Authorization endpoint URL.
|
||||
pub auth_url: String,
|
||||
/// Token exchange endpoint URL.
|
||||
pub token_url: String,
|
||||
/// OAuth client identifier.
|
||||
pub client_id: String,
|
||||
/// Optional client secret (not all flows require it).
|
||||
pub client_secret: Option<String>,
|
||||
/// Space-separated list of requested scopes.
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for OAuthConfig {
|
||||
fn default() -> Self {
|
||||
OAuthConfig {
|
||||
auth_url: String::new(),
|
||||
token_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
scopes: vec![
|
||||
"openid".to_string(),
|
||||
"profile".to_string(),
|
||||
"email".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//! 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,56 +0,0 @@
|
||||
//! Service trait definitions — use-case interfaces for session management
|
||||
//! and OAuth flows.
|
||||
//!
|
||||
//! These traits define the boundary between the application orchestration
|
||||
//! layer and the domain. Implementations live in the application layer.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionService`] — create, list, archive sessions
|
||||
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
|
||||
|
||||
use crate::auth::error::ServiceError;
|
||||
use crate::auth::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Session management use-case boundary.
|
||||
pub trait SessionService {
|
||||
/// Create a new session with a generated UUID and the given title.
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
|
||||
|
||||
/// List all available sessions.
|
||||
fn list_all(&self) -> Result<Vec<Session>, ServiceError>;
|
||||
|
||||
/// Archive a session by id (sets `archived = true`).
|
||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// OAuth flow use-case boundary.
|
||||
pub trait OAuthService {
|
||||
/// Start an OAuth authorization-code + PKCE flow for the given
|
||||
/// `redirect_uri` (the caller is responsible for actually listening on
|
||||
/// it — e.g. a bound `LoopbackServer`). Returns `(auth_url, state)`:
|
||||
/// the URL to send the user to, and the CSRF state token that must be
|
||||
/// passed back into `complete_flow` unchanged.
|
||||
fn start_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(String, String), ServiceError>;
|
||||
|
||||
/// Complete the OAuth flow: validates `state` against the value
|
||||
/// persisted during `start_flow` (bailing on mismatch — this is the
|
||||
/// CSRF check), then exchanges `code` for a token using the same
|
||||
/// `redirect_uri` passed to `start_flow`.
|
||||
fn complete_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
|
||||
/// Retrieve the currently stored OAuth token (if any).
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! 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")
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! 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,170 +0,0 @@
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! 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::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing;
|
||||
|
||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||
/// auto-removed on drop.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionLock {
|
||||
/// Path to the `.lock` file inside the session directory.
|
||||
pub(crate) path: PathBuf,
|
||||
/// Process ID that holds (or will hold) this lock.
|
||||
pub(crate) pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
/// Construct a lock handle for a session directory (does not acquire
|
||||
/// the lock yet — call `try_lock`).
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
pid: std::process::id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to acquire the session lock using an atomic file creation.
|
||||
///
|
||||
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
||||
/// succeeds, the lock is ours — write our PID and return ok. If the
|
||||
/// file already exists, read the PID inside it and check whether that
|
||||
/// PID is still alive: if the process is still running, fail to acquire;
|
||||
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
write!(file, "{}", self.pid)?;
|
||||
file.sync_all()?;
|
||||
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
|
||||
return Ok(true);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness");
|
||||
// Lock file exists — check if it's stale.
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Phase 2: lock file exists — check liveness of the owning process.
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if Self::is_alive(pid) {
|
||||
tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process");
|
||||
return Ok(false);
|
||||
}
|
||||
tracing::debug!(stale = pid, "stale lock detected, overwriting");
|
||||
}
|
||||
|
||||
// Phase 3: stale lock — overwrite it atomically (best-effort).
|
||||
// Use a temp file + rename to avoid partial writes corrupting the lock.
|
||||
let tmp = self.path.with_extension("lock.tmp");
|
||||
{
|
||||
let mut tmp_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
write!(tmp_file, "{}", self.pid)?;
|
||||
tmp_file.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, &self.path)?;
|
||||
// Sync the parent directory so the rename survives a crash.
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Explicitly release the lock by removing the lock file.
|
||||
pub fn unlock(&self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive and
|
||||
/// belongs to the same binary (mitigating PID-reuse races).
|
||||
///
|
||||
/// Strategy (Unix):
|
||||
/// 1. Resolve `/proc/<pid>/exe` — if it doesn't match our own binary,
|
||||
/// the PID either belongs to another process or is reused — return false.
|
||||
/// 2. Send `kill(pid, 0)` to verify the process is still alive.
|
||||
/// 3. Re-check `/proc/<pid>/exe` to close the TOCTOU window between
|
||||
/// step 1 and step 2 (PID reuse after exe check, before kill).
|
||||
///
|
||||
/// On non-Unix platforms this always returns `true` (conservative).
|
||||
fn is_alive(pid: u32) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Resolve our own executable path once.
|
||||
let self_exe = match std::fs::read_link("/proc/self/exe") {
|
||||
Ok(exe) => exe,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let pid_signed: i32 = match pid.try_into() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
|
||||
// Phase 1: read /proc/<pid>/exe and compare with self_exe.
|
||||
let target = match std::fs::read_link(&proc_exe) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if target != self_exe {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Phase 2: verify the process is still alive.
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal it.
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Phase 3: re-check /proc/<pid>/exe to detect PID reuse between
|
||||
// Phase 1 and Phase 2.
|
||||
matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Fallback: always assume alive (conservative).
|
||||
let _ = pid;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionLock {
|
||||
/// Release the lock automatically when the guard goes out of scope,
|
||||
/// so an ungracefully-exited process doesn't leave a dangling lock.
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//! 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-5` 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-5".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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
//! 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 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.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>>,
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//! 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::conversation::Conversation;
|
||||
pub use crate::core::message::{ChatMessage, Role};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user