Compare commits

...
10 Commits
Author SHA1 Message Date
semantic-release-bot aec53651ed chore(release): 1.19.0 [skip ci]
# [1.19.0](https://github.com/asepharyana/zesdex/compare/v1.18.4...v1.19.0) (2026-08-27)

### Features

* hapus fitur LSP bawaan (language server protocol) ([93f3c2a](https://github.com/asepharyana/zesdex/commit/93f3c2a3572511b5a84f244980ad71bd1b455e70))
2026-08-27 15:49:38 +00:00
asepharyana 93f3c2a357 feat: hapus fitur LSP bawaan (language server protocol)
Hapus seluruh pipeline LSP (client, manager, provisioner, dan 7 tool
lsp_*) dari codebase:

- apps/infrastructure/src/lsp/ (client.rs, manager.rs, provisioner/*)
- apps/infrastructure/src/tools/lsp/ (connect, disconnect, diagnostics,
  hover, completion, definition, references)
- ToolCtx/ToolCtxBuilder: hapus field lsp_manager
- Daemon state: hapus lsp_manager, lsp_provision_msgs, shutdown_lsp
- Registry: hapus registrasi 7 tool lsp_*
- Settings: hapus lsp_auto_provision + lsp_languages
- Agent definitions: hapus lsp_* dari allowed tools coder/reviewer
- Cargo: hapus dependency lsp-types (workspace + infra)
- Update dokumentasi mod + arch_audit forbidden list

Verifikasi: cargo check/clippy/test semua hijau (54 test), tidak ada
referensi lsp_* tersisa di luar CHANGELOG.
2026-08-27 22:38:21 +07:00
semantic-release-bot 9ea3d361b1 chore(release): 1.18.4 [skip ci]
## [1.18.4](https://github.com/asepharyana/zesdex/compare/v1.18.3...v1.18.4) (2026-08-27)

### Performance Improvements

* **tui:** render streaming token secara inkremental + kurangi redraw sia-sia ([2717216](https://github.com/asepharyana/zesdex/commit/271721694beb62e9fa5ff932312b293aa1d56823))
2026-08-27 15:33:34 +00:00
asepharyana 271721694b perf(tui): render streaming token secara inkremental + kurangi redraw sia-sia
- fix(view): token streaming kini benar-benar tampil — sebelumnya cache
  display_lines tidak pernah di-rebuild saat pesan terakhir berubah
  (msg_count == cached_count), jadi teks AI streaming tidak pernah muncul
  sampai pesan baru/resize
- perf(view): streaming kini hanya re-render pesan TERAKHIR (splice di
  batas cached_last_start) → O(konten baru) per token, bukan O(seluruh
  history); guard cached_last_len mencegah re-render pada frame spinner
  tanpa token baru
- perf(run): skip chrono::Utc::now() + drain toasts saat tidak ada toast
- perf(misc): drain_expired_toasts tidak lagi clone seluruh daftar toast
- perf(view): render_toasts fast-path saat toasts kosong
2026-08-27 22:23:28 +07:00
semantic-release-bot 171597ca05 chore(release): 1.18.3 [skip ci]
## [1.18.3](https://github.com/asepharyana/zesdex/compare/v1.18.2...v1.18.3) (2026-08-27)

### Bug Fixes

* **api:** cegah race condition pada register users.json (TOCTOU) ([884b19c](https://github.com/asepharyana/zesdex/commit/884b19ccb5fbcaa6b29cb41dc978386cf7b1b3f9))
* **api:** perbaiki keamanan auth & WebSocket, tambah rate limiting ([6db00b2](https://github.com/asepharyana/zesdex/commit/6db00b22663839a6a975ae978058b894900c71de))
* **build:** perbaiki referensi paket zesdex-gateway dan sinkronisasi versi nix ([6d3f491](https://github.com/asepharyana/zesdex/commit/6d3f4918bfea06b106d4fe75adf58e6a29aca2a5))
* **build:** perbaiki referensi paket zesdex-gateway di Dockerfile & default.nix ([7f64423](https://github.com/asepharyana/zesdex/commit/7f644236158f66fcc106ec55e437ebf28ee1cca1))
2026-08-27 15:19:16 +00:00
asepharyana 7b0b53671f style: format seluruh workspace dengan cargo fmt
Menyeragamkan format kode sesuai rustfmt (126 file). Sebelumnya
lefthook pre-commit 'cargo fmt --check' akan gagal pada commit apa pun.
2026-08-27 22:10:28 +07:00
asepharyana 884b19ccb5 fix(api): cegah race condition pada register users.json (TOCTOU)
- Tambah users_lock (Mutex) di ApiState untuk serialisasi read-modify-write
  users.json pada endpoint register; lock hanya dipegang selama operasi
  file sinkron (tidak pernah lintas .await, menjaga future tetap Send)
- Hash password dihitung sebelum lock sehingga request concurrent tidak
  saling blokir selama hashing Argon2
2026-08-27 22:09:22 +07:00
asepharyana 6db00b2266 fix(api): perbaiki keamanan auth & WebSocket, tambah rate limiting
Security fixes hasil audit:
- fix(auth): refresh token kini memakai claim typ=refresh; access token
  tidak bisa dipakai sebagai refresh token (sebelumnya bisa — eskalasi
  masa berlaku 1 jam -> 7 hari)
- fix(api): layer JWT hanya melindungi route /sessions dan /chat;
  /auth/login, /auth/register, /auth/refresh, /health kini publik
  (sebelumnya semua route 401-lock, API tidak bisa dipakai sama sekali)
- fix(ws): endpoint /ws kini memverifikasi token ZESDEX_WS_TOKEN via
  query param jika env diset (mencegah pemakaian LLM proxy terbuka)
- feat(api): rate limiting login/register/refresh (20 request / 10 menit
  per client IP) memakai RateLimiter yang tadinya dead code
- test(jwt): tambah unit test token type access vs refresh + expired
2026-08-27 22:04:32 +07:00
asepharyana 7f64423615 fix(build): perbaiki referensi paket zesdex-gateway di Dockerfile & default.nix
- Dockerfile & default.nix: ganti zesdex-backend -> zesdex-gateway (binary zesdex)
- flake.nix & default.nix: versi 1.13.0 -> 1.18.2 mengikuti Cargo.toml
- chore(ci): drop cargo build --release dari check job, tambah cargo fmt --check
2026-08-27 21:52:25 +07:00
asepharyana 6d3f4918bf fix(build): perbaiki referensi paket zesdex-gateway dan sinkronisasi versi nix
- Dockerfile & default.nix: ganti zesdex-backend -> zesdex-gateway (binary zesdex)
- flake.nix & default.nix: versi 1.13.0 -> 1.18.2
- Cargo.lock: sinkronkan versi workspace member (1.18.0 -> 1.18.2)
- perf(search): kurangi alokasi per-query di semantic search (Vec<String> -> Vec<&str>)
- chore(ci): drop cargo build release dari check job, tambah fmt check
- chore: tambah .dockerignore, perbaiki trailing newline .gitignore
2026-08-27 21:50:35 +07:00
145 changed files with 1173 additions and 1750 deletions

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# Rust build artifacts
target/
# VCS
.git/
.gitignore
# Local/secret files
.env
.env.*
!.env.example
# Editor
.idea/
.vscode/
*.swp
# Nix
result
result-*
# Kilo metadata
.kilo/
# Docs lessons
docs/lesson/
+5 -5
View File
@@ -21,11 +21,11 @@ jobs:
with: with:
components: clippy components: clippy
- name: Build workspace - name: Format check
run: cargo build --release --workspace run: cargo fmt --all -- --check
- name: Clippy workspace
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test workspace - name: Test workspace
run: cargo test --workspace run: cargo test --workspace
- name: Clippy workspace
run: cargo clippy --workspace -- -D warnings
+24
View File
@@ -1,3 +1,27 @@
# [1.19.0](https://github.com/asepharyana/zesdex/compare/v1.18.4...v1.19.0) (2026-08-27)
### Features
* hapus fitur LSP bawaan (language server protocol) ([93f3c2a](https://github.com/asepharyana/zesdex/commit/93f3c2a3572511b5a84f244980ad71bd1b455e70))
## [1.18.4](https://github.com/asepharyana/zesdex/compare/v1.18.3...v1.18.4) (2026-08-27)
### Performance Improvements
* **tui:** render streaming token secara inkremental + kurangi redraw sia-sia ([2717216](https://github.com/asepharyana/zesdex/commit/271721694beb62e9fa5ff932312b293aa1d56823))
## [1.18.3](https://github.com/asepharyana/zesdex/compare/v1.18.2...v1.18.3) (2026-08-27)
### Bug Fixes
* **api:** cegah race condition pada register users.json (TOCTOU) ([884b19c](https://github.com/asepharyana/zesdex/commit/884b19ccb5fbcaa6b29cb41dc978386cf7b1b3f9))
* **api:** perbaiki keamanan auth & WebSocket, tambah rate limiting ([6db00b2](https://github.com/asepharyana/zesdex/commit/6db00b22663839a6a975ae978058b894900c71de))
* **build:** perbaiki referensi paket zesdex-gateway dan sinkronisasi versi nix ([6d3f491](https://github.com/asepharyana/zesdex/commit/6d3f4918bfea06b106d4fe75adf58e6a29aca2a5))
* **build:** perbaiki referensi paket zesdex-gateway di Dockerfile & default.nix ([7f64423](https://github.com/asepharyana/zesdex/commit/7f644236158f66fcc106ec55e437ebf28ee1cca1))
## [1.18.2](https://github.com/asepharyana/zesdex/compare/v1.18.1...v1.18.2) (2026-08-20) ## [1.18.2](https://github.com/asepharyana/zesdex/compare/v1.18.1...v1.18.2) (2026-08-20)
Generated
+11 -45
View File
@@ -1088,15 +1088,6 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "fluent-uri"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
dependencies = [
"bitflags 1.3.2",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -1972,19 +1963,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lsp-types"
version = "0.97.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
dependencies = [
"bitflags 1.3.2",
"fluent-uri",
"serde",
"serde_json",
"serde_repr",
]
[[package]] [[package]]
name = "mac_address" name = "mac_address"
version = "1.1.8" version = "1.1.8"
@@ -3369,17 +3347,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_repr"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -4895,7 +4862,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-api" name = "zesdex-api"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -4918,7 +4885,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-application" name = "zesdex-application"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4935,7 +4902,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-bootstrap" name = "zesdex-bootstrap"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -4952,7 +4919,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-daemon" name = "zesdex-daemon"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4976,7 +4943,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-domain" name = "zesdex-domain"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4992,7 +4959,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-gateway" name = "zesdex-gateway"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5019,7 +4986,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-grpc" name = "zesdex-grpc"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5036,7 +5003,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-infrastructure" name = "zesdex-infrastructure"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -5055,7 +5022,6 @@ dependencies = [
"infer", "infer",
"jsonwebtoken", "jsonwebtoken",
"libc", "libc",
"lsp-types",
"nucleo-matcher", "nucleo-matcher",
"percent-encoding", "percent-encoding",
"pulldown-cmark", "pulldown-cmark",
@@ -5085,7 +5051,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-tui" name = "zesdex-tui"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -5111,7 +5077,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-web" name = "zesdex-web"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5131,7 +5097,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-ws" name = "zesdex-ws"
version = "1.18.0" version = "1.18.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -2
View File
@@ -15,7 +15,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.18.2" version = "1.19.0"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
@@ -61,7 +61,6 @@ ignore = "0.4"
nucleo-matcher = "0.3" nucleo-matcher = "0.3"
futures-util = "0.3" futures-util = "0.3"
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] } rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
lsp-types = "0.97"
tiktoken-rs = "0.12" tiktoken-rs = "0.12"
similar = "3" similar = "3"
syntect = { version = "5", default-features = false, features = ["default-fancy"] } syntect = { version = "5", default-features = false, features = ["default-fancy"] }
+1 -1
View File
@@ -12,7 +12,7 @@ WORKDIR /app
COPY . . COPY . .
# Build with release profile (treats warnings as errors via lints) # Build with release profile (treats warnings as errors via lints)
RUN cargo build --release -p zesdex-backend --bin zesdex RUN cargo build --release -p zesdex-gateway --bin zesdex
# Stage 2: Minimal runtime image # Stage 2: Minimal runtime image
FROM debian:bookworm-slim FROM debian:bookworm-slim
+1 -4
View File
@@ -16,10 +16,7 @@ pub trait ToolExecutor: Send + Sync {
/// Service for running agent turns asynchronously. /// Service for running agent turns asynchronously.
pub trait AgentTurnService: Send + Sync { pub trait AgentTurnService: Send + Sync {
/// Run a full agent turn loop asynchronously. /// Run a full agent turn loop asynchronously.
fn run_turn( fn run_turn(&self, params: AgentTurnParams) -> impl Future<Output = Result<()>> + Send;
&self,
params: AgentTurnParams,
) -> impl Future<Output = Result<()>> + Send;
} }
pub mod explore; pub mod explore;
+18 -23
View File
@@ -7,8 +7,8 @@ use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef}; use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
use zesdex_domain::main_agent_prompt; use zesdex_domain::main_agent_prompt;
use crate::ports::ProviderService;
use super::{ExploreService, ToolExecutor}; use super::{ExploreService, ToolExecutor};
use crate::ports::ProviderService;
/// Maximum tool-call iterations per agent turn before forcing termination. /// Maximum tool-call iterations per agent turn before forcing termination.
const MAX_TURN_ITERATIONS: u32 = 50; const MAX_TURN_ITERATIONS: u32 = 50;
@@ -124,11 +124,7 @@ pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
} }
impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> { impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
pub fn new( pub fn new(provider: Arc<P>, tool_executor: Arc<T>, tool_defs: Vec<ToolDef>) -> Self {
provider: Arc<P>,
tool_executor: Arc<T>,
tool_defs: Vec<ToolDef>,
) -> Self {
Self { Self {
provider, provider,
tool_executor, tool_executor,
@@ -203,7 +199,10 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
}, },
); );
match explorer.explore(&user_query, &workspace_root, &params.turn_events).await { match explorer
.explore(&user_query, &workspace_root, &params.turn_events)
.await
{
Ok(output) => { Ok(output) => {
// Insert each context message as a system message. // Insert each context message as a system message.
// They go at index 0 and are removed after the turn // They go at index 0 and are removed after the turn
@@ -236,7 +235,9 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
// entire turn, avoiding per-iteration clones of the full message list. // entire turn, avoiding per-iteration clones of the full message list.
// It is removed before emitting the Compacted event so persistence // It is removed before emitting the Compacted event so persistence
// does not store the prompt redundantly. // does not store the prompt redundantly.
params.messages.insert(0, ChatMessage::system(main_agent_prompt())); params
.messages
.insert(0, ChatMessage::system(main_agent_prompt()));
let original_count = params.messages.len(); let original_count = params.messages.len();
for iteration in 0..MAX_TURN_ITERATIONS { for iteration in 0..MAX_TURN_ITERATIONS {
@@ -287,7 +288,8 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
// ── Execute each tool call ────────────────────────── // ── Execute each tool call ──────────────────────────
for tc in &tool_calls { for tc in &tool_calls {
let output = let output =
execute_tool_call(self.tool_executor.as_ref(), &params.turn_events, tc).await; execute_tool_call(self.tool_executor.as_ref(), &params.turn_events, tc)
.await;
params params
.messages .messages
.push(ChatMessage::tool(tc.id.clone(), output)); .push(ChatMessage::tool(tc.id.clone(), output));
@@ -295,10 +297,7 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
} }
Err(e) => { Err(e) => {
warn!("{e}"); warn!("{e}");
push_event( push_event(&params.turn_events, TurnEvent::Error(e));
&params.turn_events,
TurnEvent::Error(e),
);
break; break;
} }
} }
@@ -307,10 +306,7 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
// Remove the synthetic sys_msg before shipping events to the TUI // Remove the synthetic sys_msg before shipping events to the TUI
// so the transcript shows only the actual user/assistant/tool exchange. // so the transcript shows only the actual user/assistant/tool exchange.
let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect(); let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect();
push_event( push_event(&params.turn_events, TurnEvent::Compacted(compacted));
&params.turn_events,
TurnEvent::Compacted(compacted),
);
push_event(&params.turn_events, TurnEvent::Done); push_event(&params.turn_events, TurnEvent::Done);
params.in_flight.store(false, Ordering::SeqCst); params.in_flight.store(false, Ordering::SeqCst);
@@ -341,15 +337,16 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
let split_idx = messages.len() - COMPACT_KEEP_TAIL; let split_idx = messages.len() - COMPACT_KEEP_TAIL;
let evicted: Vec<_> = messages.drain(..split_idx).collect(); let evicted: Vec<_> = messages.drain(..split_idx).collect();
let mut summary_prompt = vec![ let mut summary_prompt = vec![ChatMessage::system(zesdex_domain::compaction_prompt())];
ChatMessage::system(zesdex_domain::compaction_prompt()),
];
summary_prompt.extend(evicted); summary_prompt.extend(evicted);
summary_prompt.push(ChatMessage::user( summary_prompt.push(ChatMessage::user(
"Please summarise our previous conversation above for context continuity.".to_string(), "Please summarise our previous conversation above for context continuity.".to_string(),
)); ));
match provider.chat(&summary_prompt, None, Some(1024), Some(0.3)).await { match provider
.chat(&summary_prompt, None, Some(1024), Some(0.3))
.await
{
Ok((summary_msg, _)) => { Ok((summary_msg, _)) => {
let summary_text = summary_msg let summary_text = summary_msg
.content .content
@@ -373,5 +370,3 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
} }
} }
} }
+6 -19
View File
@@ -45,11 +45,7 @@ use sha2::{Digest, Sha256};
/// and clear them after a successful (or failed) flow completion. /// and clear them after a successful (or failed) flow completion.
pub trait OAuthFlowStore: Send + Sync { pub trait OAuthFlowStore: Send + Sync {
/// Persist the PKCE code verifier and CSRF state token. /// Persist the PKCE code verifier and CSRF state token.
fn save_flow_state( fn save_flow_state(&self, verifier: &str, state: &str) -> Result<(), ServiceError>;
&self,
verifier: &str,
state: &str,
) -> Result<(), ServiceError>;
/// Load the stored PKCE code verifier. /// Load the stored PKCE code verifier.
fn load_verifier(&self) -> Result<String, ServiceError>; fn load_verifier(&self) -> Result<String, ServiceError>;
@@ -141,12 +137,7 @@ pub struct OAuthUseCase<R, S, E> {
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> { impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
/// Create a new OAuth use-case. /// Create a new OAuth use-case.
pub fn new( pub fn new(token_repo: R, flow_store: S, token_exchanger: E, token_path: PathBuf) -> Self {
token_repo: R,
flow_store: S,
token_exchanger: E,
token_path: PathBuf,
) -> Self {
OAuthUseCase { OAuthUseCase {
token_repo, token_repo,
flow_store, flow_store,
@@ -156,8 +147,8 @@ impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S
} }
} }
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> zesdex_domain::auth::OAuthService
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E> for OAuthUseCase<R, S, E>
{ {
fn start_flow( fn start_flow(
&self, &self,
@@ -183,12 +174,8 @@ impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
"starting OAuth flow", "starting OAuth flow",
); );
let mut url = url::Url::parse(&config.auth_url) let mut url = url::Url::parse(&config.auth_url).map_err(|e| {
.map_err(|e| { ServiceError::InvalidConfig(format!("invalid auth_url '{}': {e}", config.auth_url))
ServiceError::InvalidConfig(format!(
"invalid auth_url '{}': {e}",
config.auth_url
))
})?; })?;
url.query_pairs_mut() url.query_pairs_mut()
+6 -11
View File
@@ -46,12 +46,11 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
} }
} }
impl<R: SessionRepository, L: SessionLockRepository> impl<R: SessionRepository, L: SessionLockRepository> zesdex_domain::auth::SessionService
zesdex_domain::auth::SessionService for SessionServiceImpl<R, L> for SessionServiceImpl<R, L>
{ {
fn create_session(&self, title: &str) -> Result<Session, ServiceError> { fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
let id = SessionId::new(&Uuid::new_v4().to_string()) let id = SessionId::new(&Uuid::new_v4().to_string()).map_err(ServiceError::Other)?;
.map_err(ServiceError::Other)?;
let title_owned = if title.is_empty() { let title_owned = if title.is_empty() {
"New Session".to_string() "New Session".to_string()
} else { } else {
@@ -59,8 +58,7 @@ impl<R: SessionRepository, L: SessionLockRepository>
}; };
let session = Session::new(id.into_string(), title_owned); let session = Session::new(id.into_string(), title_owned);
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session"); tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
self.session_repo self.session_repo.save_session(&self.base_dir, &session)?;
.save_session(&self.base_dir, &session)?;
Ok(session) Ok(session)
} }
@@ -73,17 +71,14 @@ impl<R: SessionRepository, L: SessionLockRepository>
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> { fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
tracing::debug!(session_id = %id, "archiving session"); tracing::debug!(session_id = %id, "archiving session");
let mut session = self let mut session = self.session_repo.load_session(&self.base_dir, &id)?;
.session_repo
.load_session(&self.base_dir, &id)?;
session.archived = true; session.archived = true;
let millis = std::time::SystemTime::now() let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_millis(); .as_millis();
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX); session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
self.session_repo self.session_repo.save_session(&self.base_dir, &session)?;
.save_session(&self.base_dir, &session)?;
Ok(()) Ok(())
} }
} }
@@ -58,11 +58,7 @@ impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
Ok(()) Ok(())
} }
fn add_message( fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError> {
&self,
conv: &mut Conversation,
msg: ChatMessage,
) -> Result<(), ServiceError> {
tracing::debug!("adding message to session {}", conv.session_id); tracing::debug!("adding message to session {}", conv.session_id);
conv.push(msg); conv.push(msg);
let dir = self.session_dir(&conv.session_id); let dir = self.session_dir(&conv.session_id);
+5 -14
View File
@@ -14,8 +14,7 @@ use std::path::PathBuf;
use tracing; use tracing;
use zesdex_domain::cms::{ use zesdex_domain::cms::{
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings, AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings, SettingsRepository,
SettingsRepository,
}; };
/// Service implementation for settings and app-config operations. /// Service implementation for settings and app-config operations.
@@ -30,11 +29,7 @@ pub struct SettingsServiceImpl<S, C> {
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> { impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
/// Create a new service with the given repositories and base directory. /// Create a new service with the given repositories and base directory.
pub fn new( pub fn new(settings_repo: S, app_config_repo: C, base_dir: impl Into<PathBuf>) -> Self {
settings_repo: S,
app_config_repo: C,
base_dir: impl Into<PathBuf>,
) -> Self {
tracing::debug!("creating SettingsServiceImpl"); tracing::debug!("creating SettingsServiceImpl");
Self { Self {
settings_repo, settings_repo,
@@ -44,8 +39,8 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
} }
} }
impl<S: SettingsRepository, C: AppConfigRepository> impl<S: SettingsRepository, C: AppConfigRepository> zesdex_domain::cms::SettingsService
zesdex_domain::cms::SettingsService for SettingsServiceImpl<S, C> for SettingsServiceImpl<S, C>
{ {
fn load_settings(&self) -> Result<Settings, ServiceError> { fn load_settings(&self) -> Result<Settings, ServiceError> {
tracing::debug!("loading settings"); tracing::debug!("loading settings");
@@ -60,11 +55,7 @@ impl<S: SettingsRepository, C: AppConfigRepository>
Ok(()) Ok(())
} }
fn update_provider( fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<(), ServiceError> {
&self,
name: &str,
config: &ProviderConfig,
) -> Result<(), ServiceError> {
tracing::debug!("updating provider '{name}'"); tracing::debug!("updating provider '{name}'");
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?; let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
app_config app_config
+3 -4
View File
@@ -30,10 +30,10 @@
//! the use-case logic independent of any specific persistence or infrastructure //! the use-case logic independent of any specific persistence or infrastructure
//! technology. //! technology.
pub mod agent;
pub mod auth; pub mod auth;
pub mod cms; pub mod cms;
pub mod ports; pub mod ports;
pub mod agent;
// Re-export port traits for ergonomic access. // Re-export port traits for ergonomic access.
pub use ports::*; pub use ports::*;
@@ -46,12 +46,11 @@ pub use auth::{
// Re-export CMS use-cases. // Re-export CMS use-cases.
pub use cms::{ pub use cms::{
conversation_service::ConversationServiceImpl, conversation_service::ConversationServiceImpl, memory_service::MemoryServiceImpl,
memory_service::MemoryServiceImpl,
settings_service::SettingsServiceImpl, settings_service::SettingsServiceImpl,
}; };
pub use agent::{ pub use agent::{
turn_service::{compact_messages_with_ai, AgentTurnServiceImpl},
AgentTurnService, ExploreOutput, ExploreService, ToolExecutor, AgentTurnService, ExploreOutput, ExploreService, ToolExecutor,
turn_service::{AgentTurnServiceImpl, compact_messages_with_ai},
}; };
+2 -5
View File
@@ -22,11 +22,8 @@ pub trait AuthService: Send + Sync {
/// Authenticate a user by verifying a password against a stored hash. /// Authenticate a user by verifying a password against a stored hash.
/// ///
/// Returns `true` if the password matches, `false` otherwise. /// Returns `true` if the password matches, `false` otherwise.
fn authenticate( fn authenticate(&self, password: &str, hash: &str)
&self, -> impl Future<Output = Result<bool>> + Send;
password: &str,
hash: &str,
) -> impl Future<Output = Result<bool>> + Send;
/// Issue a new access + refresh token pair for the given subject. /// Issue a new access + refresh token pair for the given subject.
/// ///
+1 -1
View File
@@ -7,8 +7,8 @@ use std::path::PathBuf;
use crate::core::{ChatMessage, ToolCallResult, UsageStats}; use crate::core::{ChatMessage, ToolCallResult, UsageStats};
pub mod defaults; pub mod defaults;
pub mod prompt;
pub mod progress; pub mod progress;
pub mod prompt;
/// Which kind of caller (main agent vs. subagent vs. reviewer) is /// Which kind of caller (main agent vs. subagent vs. reviewer) is
/// invoking a tool, used to scope permissions and tag log/output paths. /// invoking a tool, used to scope permissions and tag log/output paths.
-10
View File
@@ -46,10 +46,6 @@ pub struct SettingsPatch {
pub review_enabled: Option<bool>, pub review_enabled: Option<bool>,
/// Override the session-archive-enabled flag. /// Override the session-archive-enabled flag.
pub session_archive_enabled: Option<bool>, pub session_archive_enabled: Option<bool>,
/// Override the LSP auto-provision flag.
pub lsp_auto_provision: Option<bool>,
/// Override the list of LSP-managed languages.
pub lsp_languages: Option<Vec<String>>,
/// Override the hive-mind node timeout in milliseconds. /// Override the hive-mind node timeout in milliseconds.
pub hive_mind_node_timeout_ms: Option<u64>, pub hive_mind_node_timeout_ms: Option<u64>,
} }
@@ -111,12 +107,6 @@ impl SettingsPatch {
if let Some(val) = self.session_archive_enabled { if let Some(val) = self.session_archive_enabled {
settings.flags.session_archive_enabled = val; settings.flags.session_archive_enabled = val;
} }
if let Some(val) = self.lsp_auto_provision {
settings.flags.lsp_auto_provision = val;
}
if let Some(ref val) = self.lsp_languages {
settings.lsp_languages = val.clone();
}
if let Some(val) = self.hive_mind_node_timeout_ms { if let Some(val) = self.hive_mind_node_timeout_ms {
settings.hive_mind_node_timeout_ms = val; settings.hive_mind_node_timeout_ms = val;
} }
+1 -1
View File
@@ -11,5 +11,5 @@
//! - `ChatMessage` — a single message with role, content, and tool metadata //! - `ChatMessage` — a single message with role, content, and tool metadata
//! - `Role` — message role enum (User, Assistant, System, Tool) //! - `Role` — message role enum (User, Assistant, System, Tool)
pub use crate::core::message::{ChatMessage, Role};
pub use crate::core::conversation::Conversation; pub use crate::core::conversation::Conversation;
pub use crate::core::message::{ChatMessage, Role};
+1 -1
View File
@@ -32,6 +32,7 @@ pub mod settings;
pub use app_config::AppConfig; pub use app_config::AppConfig;
pub use app_config::ModelRole; pub use app_config::ModelRole;
pub use app_config::ProviderConfig; pub use app_config::ProviderConfig;
pub use commands::{NewMemory, SettingsPatch};
pub use conversation::Conversation; pub use conversation::Conversation;
pub use edit_log::EditLog; pub use edit_log::EditLog;
pub use edit_log::EditLogEntry; pub use edit_log::EditLogEntry;
@@ -46,7 +47,6 @@ pub use repository::SettingsRepository;
pub use service::ConversationService; pub use service::ConversationService;
pub use service::MemoryService; pub use service::MemoryService;
pub use service::SettingsService; pub use service::SettingsService;
pub use commands::{NewMemory, SettingsPatch};
pub use settings::InternetMode; pub use settings::InternetMode;
pub use settings::Settings; pub use settings::Settings;
pub use settings::SettingsFlags; pub use settings::SettingsFlags;
+1 -5
View File
@@ -56,11 +56,7 @@ pub trait ConversationRepository {
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>; fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
/// Persist a `Conversation` to the given session directory. /// Persist a `Conversation` to the given session directory.
fn save( fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError>;
&self,
session_dir: &Path,
conversation: &Conversation,
) -> Result<(), RepositoryError>;
} }
/// Persistence contract for `Memory` (long-term agent memory entries). /// Persistence contract for `Memory` (long-term agent memory entries).
+1 -5
View File
@@ -44,11 +44,7 @@ pub trait ConversationService {
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>; fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
/// Append a single `ChatMessage` to the conversation and persist. /// Append a single `ChatMessage` to the conversation and persist.
fn add_message( fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError>;
&self,
conv: &mut Conversation,
msg: ChatMessage,
) -> Result<(), ServiceError>;
} }
/// Use-cases for long-term memory management. /// Use-cases for long-term memory management.
-6
View File
@@ -18,7 +18,6 @@
//! - `workflow_max_concurrency` — max parallel hive-mind nodes //! - `workflow_max_concurrency` — max parallel hive-mind nodes
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration //! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
//! - `flags` — grouped boolean feature toggles //! - `flags` — grouped boolean feature toggles
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
use std::collections::HashMap; use std::collections::HashMap;
@@ -49,12 +48,10 @@ pub enum InternetMode {
/// ## Fields /// ## Fields
/// - `review_enabled` — enable automatic inline review after edits /// - `review_enabled` — enable automatic inline review after edits
/// - `session_archive_enabled` — enable periodic session archiving /// - `session_archive_enabled` — enable periodic session archiving
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsFlags { pub struct SettingsFlags {
pub review_enabled: bool, pub review_enabled: bool,
pub session_archive_enabled: bool, pub session_archive_enabled: bool,
pub lsp_auto_provision: bool,
} }
impl Default for SettingsFlags { impl Default for SettingsFlags {
@@ -63,7 +60,6 @@ impl Default for SettingsFlags {
Self { Self {
review_enabled: true, review_enabled: true,
session_archive_enabled: true, session_archive_enabled: true,
lsp_auto_provision: true,
} }
} }
} }
@@ -93,7 +89,6 @@ pub struct Settings {
pub workflow_max_concurrency: usize, pub workflow_max_concurrency: usize,
#[serde(flatten)] #[serde(flatten)]
pub flags: SettingsFlags, pub flags: SettingsFlags,
pub lsp_languages: Vec<String>,
#[serde(default = "default_hive_mind_node_timeout_ms")] #[serde(default = "default_hive_mind_node_timeout_ms")]
pub hive_mind_node_timeout_ms: u64, pub hive_mind_node_timeout_ms: u64,
} }
@@ -113,7 +108,6 @@ impl Default for Settings {
verify_timeout_ms: 30_000, verify_timeout_ms: 30_000,
workflow_max_concurrency: 5, workflow_max_concurrency: 5,
flags: SettingsFlags::default(), flags: SettingsFlags::default(),
lsp_languages: Vec::new(),
hive_mind_node_timeout_ms: 600_000, hive_mind_node_timeout_ms: 600_000,
} }
} }
+6 -7
View File
@@ -335,16 +335,16 @@ impl SseParser {
{ {
const MAX_TOOL_CALLS: usize = 64; const MAX_TOOL_CALLS: usize = 64;
for tc in tool_calls { for tc in tool_calls {
let raw_index = let raw_index = tc
tc.get("index").and_then(Value::as_u64).unwrap_or_else( .get("index")
|| { .and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!( tracing::warn!(
"[stream] tool call delta missing index, \ "[stream] tool call delta missing index, \
defaulting to 0" defaulting to 0"
); );
0 0
}, });
);
// Clamp index to prevent out-of-bounds / memory exhaustion // Clamp index to prevent out-of-bounds / memory exhaustion
let index = usize::try_from(raw_index) let index = usize::try_from(raw_index)
.unwrap_or(0) .unwrap_or(0)
@@ -395,8 +395,7 @@ impl SseParser {
if let Some(content) = delta.get("text").and_then(|c| c.as_str()) { if let Some(content) = delta.get("text").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string())); d_events.push(StreamEvent::Token(content.to_string()));
} }
if let Some(reasoning) = if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str())
delta.get("reasoning_content").and_then(|r| r.as_str())
{ {
d_events.push(StreamEvent::Reasoning(reasoning.to_string())); d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
} }
+5 -3
View File
@@ -40,9 +40,11 @@ impl Store {
/// ///
/// Why: paths are computed, not created — call `ensure_dirs` before use. /// Why: paths are computed, not created — call `ensure_dirs` before use.
pub fn new() -> Self { pub fn new() -> Self {
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok() let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok().or_else(|| {
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share"))) std::env::var("HOME")
{ .ok()
.map(|h| format!("{h}/.local/share"))
}) {
PathBuf::from(data_dir).join("zesdex") PathBuf::from(data_dir).join("zesdex")
} else { } else {
PathBuf::from(".local/share/zesdex") PathBuf::from(".local/share/zesdex")
+14 -15
View File
@@ -24,33 +24,32 @@
//! no framework imports, no side effects. All persistence is expressed //! no framework imports, no side effects. All persistence is expressed
//! through repository traits that infrastructure adapters implement. //! through repository traits that infrastructure adapters implement.
pub mod agent;
pub mod auth; pub mod auth;
pub mod cms; pub mod cms;
pub mod core; pub mod core;
pub mod error; pub mod error;
pub mod agent;
pub mod workflow;
pub mod subagent; pub mod subagent;
pub mod workflow;
// Re-export all public items from each module for ergonomic imports. // Re-export all public items from each module for ergonomic imports.
// Consumers can do `use zesdex_domain::*` for common types. // Consumers can do `use zesdex_domain::*` for common types.
pub use auth::{ pub use auth::{
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService, IamSession, NewSession, OAuthConfig, OAuthRepository, OAuthService, OAuthToken,
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session, RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session, SessionId,
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService, SessionLock, SessionLockRepository, SessionRepository, SessionService,
}; };
pub use cms::{ pub use cms::{
AppConfig, AppConfigRepository, Conversation as CmsConversation, AppConfig, AppConfigRepository, Conversation as CmsConversation, ConversationRepository,
ConversationRepository, ConversationService, EditLog, EditLogEntry, ConversationService, EditLog, EditLogEntry, EditLogRepository, InternetMode, Memory,
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService, MemoryRepository, MemoryService, ModelRole, NewMemory, ProviderConfig,
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError, RepositoryError as CmsRepositoryError, ServiceError as CmsServiceError, Settings,
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch, SettingsFlags, SettingsPatch, SettingsRepository, SettingsService,
SettingsRepository, SettingsService,
}; };
pub use core::{ pub use core::{
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role, ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role, SseParser, Store,
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall, StreamEvent, StreamOptions, TokenUsage, ToolCall, ToolCallResult, ToolDef, ToolFunction,
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats, ToolFunctionDef, UsageStats,
}; };
pub use error::DomainError; pub use error::DomainError;
@@ -60,5 +59,5 @@ pub use agent::*;
pub use agent::defaults::*; pub use agent::defaults::*;
pub use agent::progress::AgentProgress; pub use agent::progress::AgentProgress;
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive}; pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive};
pub use workflow::*;
pub use subagent::*; pub use subagent::*;
pub use workflow::*;
+1 -1
View File
@@ -10,6 +10,6 @@ pub enum AccessTier {
Read, Read,
/// Read + Write: above plus write, edit, delete, git, memory. /// Read + Write: above plus write, edit, delete, git, memory.
Write, Write,
/// Full: above plus bash, shell, LSP, workflow, plan tools. /// Full: above plus bash, shell, workflow, plan tools.
Full, Full,
} }
+3 -6
View File
@@ -4,7 +4,6 @@
//! configuration files plus a seed session for development/testing. //! configuration files plus a seed session for development/testing.
//! Invoked as `cargo run --bin seed`. //! Invoked as `cargo run --bin seed`.
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let store = zesdex_domain::core::Store::new(); let store = zesdex_domain::core::Store::new();
store.ensure_dirs()?; store.ensure_dirs()?;
@@ -46,13 +45,11 @@ fn main() -> anyhow::Result<()> {
// Create a seed session // Create a seed session
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
let session = zesdex_domain::auth::Session::new( let session = zesdex_domain::auth::Session::new(session_id.clone(), "Seed Session".to_string());
session_id.clone(),
"Seed Session".to_string(),
);
// Persist via the session repository // Persist via the session repository
use zesdex_domain::SessionRepository; use zesdex_domain::SessionRepository;
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new(); let repo =
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
repo.save_session(&store.base_dir, &session)?; repo.save_session(&store.base_dir, &session)?;
tracing::info!("Seed session created: id={session_id}"); tracing::info!("Seed session created: id={session_id}");
+4 -1
View File
@@ -16,7 +16,10 @@ struct ClaudeSettings {
} }
fn main() { fn main() {
let path = dirs::home_dir().unwrap().join(".claude").join("settings.json"); let path = dirs::home_dir()
.unwrap()
.join(".claude")
.join("settings.json");
println!("Path: {:?}", path); println!("Path: {:?}", path);
match std::fs::read_to_string(&path) { match std::fs::read_to_string(&path) {
Ok(content) => { Ok(content) => {
+5 -1
View File
@@ -12,7 +12,11 @@ use clap::Parser;
/// Zesdex — autonomous AI coding agent. /// Zesdex — autonomous AI coding agent.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "zesdex", version, about = "Autonomous AI coding agent with TUI")] #[command(
name = "zesdex",
version,
about = "Autonomous AI coding agent with TUI"
)]
struct Cli { struct Cli {
/// Run as background daemon with IPC socket /// Run as background daemon with IPC socket
#[arg(long)] #[arg(long)]
-1
View File
@@ -32,7 +32,6 @@ ignore.workspace = true
nucleo-matcher.workspace = true nucleo-matcher.workspace = true
futures-util.workspace = true futures-util.workspace = true
rmcp.workspace = true rmcp.workspace = true
lsp-types.workspace = true
tiktoken-rs.workspace = true tiktoken-rs.workspace = true
similar.workspace = true similar.workspace = true
syntect.workspace = true syntect.workspace = true
+71 -1
View File
@@ -8,12 +8,27 @@ pub struct JwtClaims {
pub sub: String, pub sub: String,
pub exp: u64, pub exp: u64,
pub iat: u64, pub iat: u64,
/// Token purpose: `"access"` or `"refresh"`.
///
/// Prevents an access token from being replayed as a refresh token
/// (which would otherwise extend a short-lived credential into the
/// 7-day refresh window).
#[serde(rename = "typ")]
pub token_type: TokenType,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>, pub session_id: Option<String>,
} }
/// JWT token purpose.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TokenType {
Access,
Refresh,
}
impl JwtClaims { impl JwtClaims {
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self { pub fn new(sub: String, exp: u64, token_type: TokenType, session_id: Option<String>) -> Self {
let iat = std::time::SystemTime::now() let iat = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -22,6 +37,7 @@ impl JwtClaims {
sub, sub,
exp, exp,
iat, iat,
token_type,
session_id, session_id,
} }
} }
@@ -48,3 +64,57 @@ pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?; let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
Ok(token_data.claims) Ok(token_data.claims)
} }
#[cfg(test)]
mod tests {
use super::*;
fn claims(exp_secs_from_now: u64, token_type: TokenType) -> JwtClaims {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
JwtClaims::new(
"user-1".to_string(),
now + exp_secs_from_now,
token_type,
None,
)
}
#[test]
fn access_and_refresh_tokens_roundtrip() {
let secret = "test-secret";
let access = create_token(secret, claims(3600, TokenType::Access)).unwrap();
let refresh = create_token(secret, claims(604800, TokenType::Refresh)).unwrap();
let acc = verify_token(secret, &access).unwrap();
assert_eq!(acc.token_type, TokenType::Access);
let refr = verify_token(secret, &refresh).unwrap();
assert_eq!(refr.token_type, TokenType::Refresh);
}
#[test]
fn token_type_is_distinct() {
let secret = "test-secret";
let access = create_token(secret, claims(3600, TokenType::Access)).unwrap();
let claims = verify_token(secret, &access).unwrap();
assert_ne!(claims.token_type, TokenType::Refresh);
}
#[test]
fn expired_token_is_rejected() {
let secret = "test-secret";
// exp well in the past (beyond the library's default 60s leeway) →
// verification must fail.
let past = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(120);
let expired = JwtClaims::new("user-1".to_string(), past, TokenType::Access, None);
let token = create_token(secret, expired).unwrap();
assert!(verify_token(secret, &token).is_err());
}
}
+6 -10
View File
@@ -21,20 +21,13 @@ impl LoopbackServer {
format!("http://127.0.0.1:{}/callback", self.port) format!("http://127.0.0.1:{}/callback", self.port)
} }
pub fn wait_for_code( pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
&self,
timeout_ms: u64,
expected_state: &str,
) -> std::io::Result<String> {
let (mut stream, _) = self.listener.accept()?; let (mut stream, _) = self.listener.accept()?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?; stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
Self::read_callback(&mut stream, expected_state) Self::read_callback(&mut stream, expected_state)
} }
fn read_callback( fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
stream: &mut TcpStream,
expected_state: &str,
) -> std::io::Result<String> {
let mut buf = [0u8; 4096]; let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?; let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]); let request = String::from_utf8_lossy(&buf[..n]);
@@ -68,7 +61,10 @@ impl LoopbackServer {
)); ));
} }
code.ok_or_else(|| { code.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback") std::io::Error::new(
std::io::ErrorKind::InvalidData,
"code not found in callback",
)
}) })
} }
@@ -59,17 +59,25 @@ pub struct AuditReport {
impl AuditReport { impl AuditReport {
/// True if any ERROR-level violations exist. /// True if any ERROR-level violations exist.
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.violations.iter().any(|v| v.severity == Severity::Error) self.violations
.iter()
.any(|v| v.severity == Severity::Error)
} }
/// Number of errors. /// Number of errors.
pub fn error_count(&self) -> usize { pub fn error_count(&self) -> usize {
self.violations.iter().filter(|v| v.severity == Severity::Error).count() self.violations
.iter()
.filter(|v| v.severity == Severity::Error)
.count()
} }
/// Number of warnings. /// Number of warnings.
pub fn warning_count(&self) -> usize { pub fn warning_count(&self) -> usize {
self.violations.iter().filter(|v| v.severity == Severity::Warning).count() self.violations
.iter()
.filter(|v| v.severity == Severity::Warning)
.count()
} }
} }
@@ -117,7 +125,6 @@ fn forbidden_imports(layer: &str) -> &'static [&'static str] {
"argon2", "argon2",
"jsonwebtoken", "jsonwebtoken",
"rmcp", "rmcp",
"lsp_types",
"tiktoken_rs", "tiktoken_rs",
"syntect", "syntect",
"pulldown_cmark", "pulldown_cmark",
@@ -152,11 +159,7 @@ fn forbidden_imports(layer: &str) -> &'static [&'static str] {
} }
/// Scan a single Rust source file for forbidden imports. /// Scan a single Rust source file for forbidden imports.
fn scan_file( fn scan_file(file_path: &Path, layer: &'static str, root: &Path) -> Vec<Violation> {
file_path: &Path,
layer: &'static str,
root: &Path,
) -> Vec<Violation> {
let mut violations = Vec::new(); let mut violations = Vec::new();
let content = match std::fs::read_to_string(file_path) { let content = match std::fs::read_to_string(file_path) {
Ok(c) => c, Ok(c) => c,
@@ -185,10 +188,12 @@ fn scan_file(
// `use crate::` in domain could reference domain-only items — skip. // `use crate::` in domain could reference domain-only items — skip.
continue; continue;
} }
if trimmed.starts_with(&pattern) || trimmed.starts_with(&format!("use {forbidden}::")) { if trimmed.starts_with(&pattern)
|| trimmed.starts_with(&format!("use {forbidden}::"))
{
// Skip test code — test modules commonly import outer layers. // Skip test code — test modules commonly import outer layers.
let is_test = content[..content.len().saturating_sub(1)] let is_test =
.contains("#[cfg(test)]"); content[..content.len().saturating_sub(1)].contains("#[cfg(test)]");
if is_test { if is_test {
continue; continue;
} }
@@ -243,10 +248,7 @@ pub fn audit_layering(root: &Path) -> Result<AuditReport> {
} }
// Determine which crate this file belongs to by walking up. // Determine which crate this file belongs to by walking up.
let layer = path let layer = path.ancestors().skip(1).find_map(|p| classify_layer(p));
.ancestors()
.skip(1)
.find_map(|p| classify_layer(p));
if let Some(layer) = layer { if let Some(layer) = layer {
files_scanned += 1; files_scanned += 1;
@@ -371,7 +373,10 @@ mod tests {
fn function_metrics_short_function_ok() { fn function_metrics_short_function_ok() {
let content = "fn ok() {\n let x = 1;\n}\n"; let content = "fn ok() {\n let x = 1;\n}\n";
let violations = check_function_metrics(content); let violations = check_function_metrics(content);
let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect(); let long: Vec<_> = violations
.iter()
.filter(|v| v.message.contains("Function too long"))
.collect();
assert!(long.is_empty(), "short function should not trigger"); assert!(long.is_empty(), "short function should not trigger");
} }
@@ -383,7 +388,10 @@ mod tests {
} }
lines.push_str("}\n"); lines.push_str("}\n");
let violations = check_function_metrics(&lines); let violations = check_function_metrics(&lines);
let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect(); let long: Vec<_> = violations
.iter()
.filter(|v| v.message.contains("Function too long"))
.collect();
assert!(!long.is_empty(), "long function should trigger warning"); assert!(!long.is_empty(), "long function should trigger warning");
} }
} }
@@ -36,7 +36,9 @@ pub struct CodeQualityReport {
impl CodeQualityReport { impl CodeQualityReport {
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.findings.iter().any(|f| f.severity == super::arch_audit::Severity::Error) self.findings
.iter()
.any(|f| f.severity == super::arch_audit::Severity::Error)
} }
pub fn count_by_rule(&self) -> Vec<(&'static str, usize)> { pub fn count_by_rule(&self) -> Vec<(&'static str, usize)> {
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
@@ -147,7 +149,8 @@ pub fn scan_quality_file(file_path: &Path, root: &Path) -> Vec<Finding> {
// ── Rule: Missing doc comments on pub items ──────────────────── // ── Rule: Missing doc comments on pub items ────────────────────
if (trimmed.starts_with("pub ") || trimmed.starts_with("pub(")) if (trimmed.starts_with("pub ") || trimmed.starts_with("pub("))
&& !prev_line_doc && !prev_line_empty && !prev_line_doc
&& !prev_line_empty
{ {
// Check it's a struct/enum/fn/trait/type/const/mod // Check it's a struct/enum/fn/trait/type/const/mod
let is_item = trimmed.starts_with("pub fn ") let is_item = trimmed.starts_with("pub fn ")
@@ -246,7 +249,10 @@ mod tests {
std::fs::write(&file, "fn x() { let y = foo.unwrap(); }\n").unwrap(); std::fs::write(&file, "fn x() { let y = foo.unwrap(); }\n").unwrap();
let findings = scan_quality_file(&file, &dir); let findings = scan_quality_file(&file, &dir);
let unwrap_findings: Vec<_> = findings.iter().filter(|f| f.rule == "unwrap-in-production").collect(); let unwrap_findings: Vec<_> = findings
.iter()
.filter(|f| f.rule == "unwrap-in-production")
.collect();
assert!(!unwrap_findings.is_empty(), "should detect unwrap"); assert!(!unwrap_findings.is_empty(), "should detect unwrap");
} }
@@ -262,8 +268,14 @@ mod tests {
.unwrap(); .unwrap();
let findings = scan_quality_file(&file, &dir); let findings = scan_quality_file(&file, &dir);
let unwrap_findings: Vec<_> = findings.iter().filter(|f| f.rule == "unwrap-in-production").collect(); let unwrap_findings: Vec<_> = findings
assert!(unwrap_findings.is_empty(), "should skip unwrap in test blocks"); .iter()
.filter(|f| f.rule == "unwrap-in-production")
.collect();
assert!(
unwrap_findings.is_empty(),
"should skip unwrap in test blocks"
);
} }
#[test] #[test]
@@ -274,7 +286,13 @@ mod tests {
std::fs::write(&file, "#[allow(clippy::too_many_arguments)]\nfn x() {}\n").unwrap(); std::fs::write(&file, "#[allow(clippy::too_many_arguments)]\nfn x() {}\n").unwrap();
let findings = scan_quality_file(&file, &dir); let findings = scan_quality_file(&file, &dir);
let bypass_findings: Vec<_> = findings.iter().filter(|f| f.rule == "compiler-bypass").collect(); let bypass_findings: Vec<_> = findings
assert!(!bypass_findings.is_empty(), "should detect allow attributes"); .iter()
.filter(|f| f.rule == "compiler-bypass")
.collect();
assert!(
!bypass_findings.is_empty(),
"should detect allow attributes"
);
} }
} }
+23 -16
View File
@@ -50,9 +50,8 @@ pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
} }
// Parse: `type(scope): description` or `type!: description` or `type: description` // Parse: `type(scope): description` or `type!: description` or `type: description`
let re = Regex::new( let re =
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$", Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$")
)
.expect("valid regex for commit parsing"); .expect("valid regex for commit parsing");
match re.captures(subject) { match re.captures(subject) {
@@ -94,21 +93,19 @@ pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
} else { } else {
let first_char = desc.chars().next().unwrap_or(' '); let first_char = desc.chars().next().unwrap_or(' ');
if first_char.is_uppercase() { if first_char.is_uppercase() {
errors.push(format!( errors.push(format!("Description must start with lowercase: '{desc}'"));
"Description must start with lowercase: '{desc}'"
));
} }
if desc.ends_with('.') { if desc.ends_with('.') {
errors.push(format!( errors.push(format!("Description must not end with a period: '{desc}'"));
"Description must not end with a period: '{desc}'"
));
} }
} }
// Type-specific rules. // Type-specific rules.
match type_ { match type_ {
"chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build" "chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build"
| "revert" if scope.is_some() => { | "revert"
if scope.is_some() =>
{
errors.push(format!( errors.push(format!(
"'{type_}' commits should not use a scope. \ "'{type_}' commits should not use a scope. \
Only 'feat' and 'fix' require scopes." Only 'feat' and 'fix' require scopes."
@@ -140,9 +137,8 @@ pub fn parse_commit_message(msg: &str) -> Option<CommitInfo> {
let msg = msg.trim(); let msg = msg.trim();
let subject = msg.lines().next()?; let subject = msg.lines().next()?;
let re = Regex::new( let re =
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$", Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$")
)
.expect("valid regex"); .expect("valid regex");
let caps = re.captures(subject)?; let caps = re.captures(subject)?;
@@ -155,10 +151,18 @@ pub fn parse_commit_message(msg: &str) -> Option<CommitInfo> {
}; };
Some(CommitInfo { Some(CommitInfo {
type_: caps.name("type").map(|m| m.as_str()).unwrap_or("").to_string(), type_: caps
.name("type")
.map(|m| m.as_str())
.unwrap_or("")
.to_string(),
scope: caps.name("scope").map(|m| m.as_str().to_string()), scope: caps.name("scope").map(|m| m.as_str().to_string()),
breaking: caps.name("breaking").is_some(), breaking: caps.name("breaking").is_some(),
description: caps.name("desc").map(|m| m.as_str()).unwrap_or("").to_string(), description: caps
.name("desc")
.map(|m| m.as_str())
.unwrap_or("")
.to_string(),
body, body,
}) })
} }
@@ -232,7 +236,10 @@ mod tests {
#[test] #[test]
fn parses_valid_commit() { fn parses_valid_commit() {
let parsed = parse_commit_message("feat(agent): add parallel execution\n\nWith cycle orchestration.").unwrap(); let parsed = parse_commit_message(
"feat(agent): add parallel execution\n\nWith cycle orchestration.",
)
.unwrap();
assert_eq!(parsed.type_, "feat"); assert_eq!(parsed.type_, "feat");
assert_eq!(parsed.scope, Some("agent".to_string())); assert_eq!(parsed.scope, Some("agent".to_string()));
assert!(!parsed.breaking); assert!(!parsed.breaking);
@@ -51,7 +51,6 @@ const EXPLORE_DIRECTIVES: [&str; 3] = [
4. Identify main entry points (main.rs, main.py, index.ts, etc.).\n\ 4. Identify main entry points (main.rs, main.py, index.ts, etc.).\n\
5. Count files by extension type.\n\ 5. Count files by extension type.\n\
Use the ls_dir, read, grep, and glob tools. Be concise.", Use the ls_dir, read, grep, and glob tools. Be concise.",
// Agent 1: Symbol Index // Agent 1: Symbol Index
"You are a symbol index explorer.\n\ "You are a symbol index explorer.\n\
1. Call the 'rebuild_index' tool to rebuild the symbol index.\n\ 1. Call the 'rebuild_index' tool to rebuild the symbol index.\n\
@@ -59,7 +58,6 @@ const EXPLORE_DIRECTIVES: [&str; 3] = [
3. Identify public APIs, entry points, and key types.\n\ 3. Identify public APIs, entry points, and key types.\n\
4. Group symbols by language and kind.\n\ 4. Group symbols by language and kind.\n\
Be concise. Report what symbols exist and where they live.", Be concise. Report what symbols exist and where they live.",
// Agent 2: Semantic Context // Agent 2: Semantic Context
"You are a semantic context explorer.\n\ "You are a semantic context explorer.\n\
1. Call the 'rebuild_index' tool to ensure the index is fresh.\n\ 1. Call the 'rebuild_index' tool to ensure the index is fresh.\n\
@@ -231,8 +229,8 @@ async fn run_explore_phase(
let tc = tool_ctx.clone(); let tc = tool_ctx.clone();
let handle = thread::spawn(move || { let handle = thread::spawn(move || {
let rt = tokio::runtime::Runtime::new() let rt =
.context("create explore subagent tokio runtime")?; tokio::runtime::Runtime::new().context("create explore subagent tokio runtime")?;
rt.block_on(run_agent(ctx, &directive, AccessTier::Read, tc)) rt.block_on(run_agent(ctx, &directive, AccessTier::Read, tc))
}); });
@@ -252,12 +250,22 @@ async fn run_explore_phase(
} }
Ok(Err(e)) => { Ok(Err(e)) => {
warn!(agent = i, error = %e, "explore subagent failed"); warn!(agent = i, error = %e, "explore subagent failed");
emit_failed(&turn_events_clone, EXPLORE_IDS[i], EXPLORE_LABELS[i], &e.to_string()); emit_failed(
&turn_events_clone,
EXPLORE_IDS[i],
EXPLORE_LABELS[i],
&e.to_string(),
);
(i, format!("Error: {e}"), false) (i, format!("Error: {e}"), false)
} }
Err(e) => { Err(e) => {
warn!(agent = i, error = ?e, "explore subagent panicked"); warn!(agent = i, error = ?e, "explore subagent panicked");
emit_failed(&turn_events_clone, EXPLORE_IDS[i], EXPLORE_LABELS[i], "thread panicked"); emit_failed(
&turn_events_clone,
EXPLORE_IDS[i],
EXPLORE_LABELS[i],
"thread panicked",
);
(i, format!("Thread panic: {e:?}"), false) (i, format!("Thread panic: {e:?}"), false)
} }
}; };
@@ -281,9 +289,7 @@ fn build_explore_context(results: &[(usize, String, bool)]) -> String {
let success_count = results.iter().filter(|r| r.2).count(); let success_count = results.iter().filter(|r| r.2).count();
let total = results.len(); let total = results.len();
let mut msg = format!( let mut msg = format!("[Explore Phase — {success_count}/{total} agents succeeded]\n\n");
"[Explore Phase — {success_count}/{total} agents succeeded]\n\n"
);
for (i, output, success) in results { for (i, output, success) in results {
let label = EXPLORE_LABELS.get(*i).unwrap_or(&"❓ Unknown"); let label = EXPLORE_LABELS.get(*i).unwrap_or(&"❓ Unknown");
+1 -4
View File
@@ -107,10 +107,7 @@ impl BestPracticeEngine {
let layering = self.audit_layering(workspace_root)?; let layering = self.audit_layering(workspace_root)?;
let quality = self.scan_quality(workspace_root)?; let quality = self.scan_quality(workspace_root)?;
Ok(CombinedAuditReport { Ok(CombinedAuditReport { layering, quality })
layering,
quality,
})
} }
} }
+3 -1
View File
@@ -89,6 +89,8 @@ impl BashJob {
let Ok(mut guard) = self.process.lock() else { let Ok(mut guard) = self.process.lock() else {
return false; return false;
}; };
guard.as_mut().is_some_and(|c| matches!(c.try_wait(), Ok(None))) guard
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(None)))
} }
} }
+8 -10
View File
@@ -7,24 +7,22 @@
pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> { pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> {
match tool_name { match tool_name {
"bash" => { "bash" => {
let cmd = args let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
// Detect git push with --force // Detect git push with --force
if cmd.contains("git push") && cmd.contains("--force") { if cmd.contains("git push") && cmd.contains("--force") {
return Some("Force-pushing to git is destructive and may lose history".to_string()); return Some(
"Force-pushing to git is destructive and may lose history".to_string(),
);
} }
// Detect rm -rf / // Detect rm -rf /
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") { if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
return Some("Recursive deletion of the root filesystem is never allowed".to_string()); return Some(
"Recursive deletion of the root filesystem is never allowed".to_string(),
);
} }
} }
"delete" => { "delete" => {
let path = args let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
if path == "/" || path.starts_with("/etc") { if path == "/" || path.starts_with("/etc") {
return Some(format!("Deleting '{}' is too dangerous", path)); return Some(format!("Deleting '{}' is too dangerous", path));
} }
+1 -4
View File
@@ -76,10 +76,7 @@ pub struct StatePayload {
pub enum DaemonFrame { pub enum DaemonFrame {
StateUpdate(Box<StatePayload>), StateUpdate(Box<StatePayload>),
StreamToken(String), StreamToken(String),
SystemNote { SystemNote { kind: String, message: String },
kind: String,
message: String,
},
ClipboardCopy(String), ClipboardCopy(String),
Closed, Closed,
} }
+1 -4
View File
@@ -15,7 +15,6 @@
//! ├── auth/ — JWT, Argon2, OAuth loopback //! ├── auth/ — JWT, Argon2, OAuth loopback
//! ├── llm/ — LLM provider HTTP client //! ├── llm/ — LLM provider HTTP client
//! ├── ipc/ — Unix-socket IPC protocol //! ├── ipc/ — Unix-socket IPC protocol
//! ├── lsp/ — Native LSP client + provisioner
//! ├── mcp/ — Model Context Protocol bridge //! ├── mcp/ — Model Context Protocol bridge
//! ├── bgbash/ — Background bash job management //! ├── bgbash/ — Background bash job management
//! ├── tools/ — All 37 agent-invocable tools //! ├── tools/ — All 37 agent-invocable tools
@@ -32,7 +31,6 @@ pub mod bgbash;
pub mod guard; pub mod guard;
pub mod ipc; pub mod ipc;
pub mod llm; pub mod llm;
pub mod lsp;
pub mod mcp; pub mod mcp;
pub mod middleware; pub mod middleware;
pub mod persistence; pub mod persistence;
@@ -67,8 +65,7 @@ use std::sync::Arc;
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder}; pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
// Re-export commonly needed types at the crate root // Re-export commonly needed types at the crate root
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult}; pub use zesdex_domain::core::{ChatMessage, Role, Store, ToolCallResult, UsageStats};
/// A shared, async-writable cache of directory entries, used to avoid /// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame. /// re-reading a directory every render frame.
+14 -21
View File
@@ -4,10 +4,10 @@
use rand_core::RngCore; use rand_core::RngCore;
use std::time::Duration; use std::time::Duration;
use zesdex_application::ports::ProviderService;
use zesdex_domain::core::{ use zesdex_domain::core::{
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef, ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
}; };
use zesdex_application::ports::ProviderService;
use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL}; use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL};
const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE; const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE;
@@ -82,10 +82,7 @@ impl LlmClient {
retrying without connect timeout", retrying without connect timeout",
e, e,
); );
match reqwest::Client::builder() match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() {
.timeout(REQUEST_TIMEOUT)
.build()
{
Ok(c) => c, Ok(c) => c,
Err(e2) => { Err(e2) => {
tracing::warn!("also failed: {e2}. using default client"); tracing::warn!("also failed: {e2}. using default client");
@@ -115,8 +112,7 @@ impl LlmClient {
.post(url) .post(url)
.header("Content-Type", "application/json"); .header("Content-Type", "application/json");
if !self.api_key.is_empty() { if !self.api_key.is_empty() {
http_req = http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
http_req.header("Authorization", format!("Bearer {}", self.api_key));
} }
let mut resp = http_req.json(req).send().await.map_err(|e| { let mut resp = http_req.json(req).send().await.map_err(|e| {
@@ -237,8 +233,7 @@ impl LlmClient {
n n
} }
}; };
let text = let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
byte_buf.drain(..valid_len); byte_buf.drain(..valid_len);
for event in parser.feed(&text) { for event in parser.feed(&text) {
@@ -306,8 +301,7 @@ impl ProviderService for LlmClient {
.header("Content-Type", "application/json"); .header("Content-Type", "application/json");
if !self.api_key.is_empty() { if !self.api_key.is_empty() {
http_req = http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
http_req.header("Authorization", format!("Bearer {}", self.api_key));
} }
let result = async { let result = async {
@@ -335,9 +329,9 @@ impl ProviderService for LlmClient {
} }
let data: ChatResponse = resp.json().await?; let data: ChatResponse = resp.json().await?;
let usage = data.usage.map(|u| { let usage = data
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens)) .usage
}); .map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)));
let message = data let message = data
.choices .choices
.into_iter() .into_iter()
@@ -345,7 +339,8 @@ impl ProviderService for LlmClient {
.and_then(|c| c.message) .and_then(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?; .ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
Ok((message, usage)) Ok((message, usage))
}.await; }
.await;
match result { match result {
Ok((msg, usage)) => return Ok((msg, usage)), Ok((msg, usage)) => return Ok((msg, usage)),
@@ -393,7 +388,9 @@ impl ProviderService for LlmClient {
let mut captured_content = false; let mut captured_content = false;
let mut wrapped = |event: &StreamEvent| -> bool { let mut wrapped = |event: &StreamEvent| -> bool {
match event { match event {
StreamEvent::Token(_) | StreamEvent::Reasoning(_) | StreamEvent::ToolCallDelta { .. } => { StreamEvent::Token(_)
| StreamEvent::Reasoning(_)
| StreamEvent::ToolCallDelta { .. } => {
captured_content = true; captured_content = true;
} }
_ => {} _ => {}
@@ -425,11 +422,7 @@ pub fn resolve_api_key(
) -> String { ) -> String {
let provider = &settings.provider; let provider = &settings.provider;
let mut api_key = settings let mut api_key = settings.api_keys.get(provider).cloned().unwrap_or_default();
.api_keys
.get(provider)
.cloned()
.unwrap_or_default();
if api_key.is_empty() { if api_key.is_empty() {
if let Some(provider_cfg) = app_config.providers.get(provider) { if let Some(provider_cfg) = app_config.providers.get(provider) {
-124
View File
@@ -1,124 +0,0 @@
//! LSP client — sends JSON-RPC requests to language servers.
use anyhow::Result;
use serde_json::Value;
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::Mutex;
use tracing::{debug, info};
/// Mutable inner state of an LSP client, protected by a mutex so that
/// `send_request` and `shutdown` can be called via `&self` (required by
/// [`LspManager`](super::manager::LspManager)).
struct LspClientInner {
process: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
request_id: u64,
}
/// A minimal but functional LSP client.
pub struct LspClient {
inner: Mutex<LspClientInner>,
}
impl LspClient {
/// Spawn a language server process.
pub fn start(command: &str, args: &[String]) -> Result<Self> {
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("no stdin on LSP process"))?;
let stdout = BufReader::new(child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout on LSP process"))?);
info!("LSP client spawned: {command}");
Ok(LspClient {
inner: Mutex::new(LspClientInner {
process: child,
stdin,
stdout,
request_id: 0,
}),
})
}
/// Send a JSON-RPC request and read the response.
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
let mut inner = match self.inner.lock() {
Ok(g) => g,
Err(poisoned) => {
tracing::error!("LSP client mutex poisoned, recovering");
poisoned.into_inner()
}
};
inner.request_id += 1;
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": inner.request_id,
"method": method,
"params": params.clone(),
});
// Write Content-Length header + body
let body = serde_json::to_string(&request)?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
inner.stdin.write_all(header.as_bytes())?;
inner.stdin.write_all(body.as_bytes())?;
inner.stdin.flush()?;
debug!("LSP request: {method} (id={})", inner.request_id);
// Read Content-Length header
let mut content_length = 0usize;
loop {
let mut line = String::new();
inner.stdout.read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() {
break; // end of headers
}
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
content_length = len_str.parse::<usize>()?;
}
}
// Read the JSON body
let mut buf = vec![0u8; content_length];
inner.stdout.read_exact(&mut buf)?;
let response: Value = serde_json::from_slice(&buf)?;
debug!("LSP response for {method}: response received");
Ok(response)
}
/// Gracefully shut down the server.
pub fn shutdown(&self) -> Result<()> {
let null = Value::Null;
if let Err(e) = self.send_request("shutdown", &null) {
tracing::warn!("LSP shutdown error: {e}");
}
if let Err(e) = self.send_request("exit", &null) {
tracing::warn!("LSP exit error: {e}");
}
if let Ok(mut inner) = self.inner.lock() {
let _ = inner.process.wait();
}
info!("LSP client shut down");
Ok(())
}
}
impl Drop for LspClient {
fn drop(&mut self) {
if let Ok(mut inner) = self.inner.lock() {
if let Err(e) = inner.process.kill() {
tracing::warn!("LSP process kill error: {e}");
}
let _ = inner.process.wait();
}
}
}
-56
View File
@@ -1,56 +0,0 @@
//! Manages multiple LSP server processes, keyed by language ID.
//!
//! Each language (e.g. "rust", "python") maps to one `LspClient`.
//! The manager provides a unified `request` method that dispatches
//! to the correct client by language.
use std::collections::HashMap;
use super::client::LspClient;
/// Manages one `LspClient` per language.
pub struct LspManager {
clients: HashMap<String, LspClient>,
}
impl LspManager {
pub fn new() -> Self {
LspManager {
clients: HashMap::new(),
}
}
}
impl Default for LspManager {
fn default() -> Self {
Self::new()
}
}
impl LspManager {
pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> {
let client = LspClient::start(command, args)?;
self.clients.insert(language.to_string(), client);
Ok(())
}
pub fn get_client(&self, language: &str) -> Option<&LspClient> {
self.clients.get(language)
}
pub fn shutdown_all(&mut self) {
for client in self.clients.values() {
let _ = client.shutdown();
}
self.clients.clear();
}
pub fn languages(&self) -> Vec<String> {
self.clients.keys().cloned().collect()
}
pub fn is_empty(&self) -> bool {
self.clients.is_empty()
}
}
-6
View File
@@ -1,6 +0,0 @@
//! Native LSP client integration — manage language server processes and
//! dispatch requests for completion, hover, diagnostics, etc.
pub mod client;
pub mod manager;
pub mod provisioner;
@@ -1,16 +0,0 @@
//! Configuration for LSP language server provisioning.
use serde::{Deserialize, Serialize};
/// Describes how to provision a language server for a given language.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspProvisionerConfig {
/// Language identifier, e.g. "rust", "python".
pub language: String,
/// The command to start the language server.
pub command: String,
/// Arguments for the command.
pub args: Vec<String>,
/// How to install the language server (if not found).
pub install_hint: Option<String>,
}
@@ -1,48 +0,0 @@
//! Discovers installed language servers on the system PATH.
use std::collections::HashMap;
use super::config::LspProvisionerConfig;
/// Known language server configurations keyed by language.
fn known_configs() -> HashMap<&'static str, (&'static str, Vec<&'static str>)> {
let mut m = HashMap::new();
m.insert("rust", ("rust-analyzer", vec![]));
m.insert("python", ("pyright-langserver", vec!["--stdio"]));
m.insert("typescript", ("typescript-language-server", vec!["--stdio"]));
m.insert("javascript", ("typescript-language-server", vec!["--stdio"]));
m.insert("go", ("gopls", vec![]));
m
}
/// Check if a command is available on PATH.
fn command_exists(cmd: &str) -> bool {
std::env::var_os("PATH")
.and_then(|path| {
std::env::split_paths(&path).find_map(|dir| {
let full_path = dir.join(cmd);
if full_path.is_file() {
Some(())
} else {
None
}
})
})
.is_some()
}
/// Discover which language servers are already on PATH.
pub fn discover_installed() -> Vec<LspProvisionerConfig> {
let mut configs = Vec::new();
for (lang, (cmd, args)) in known_configs() {
if command_exists(cmd) {
configs.push(LspProvisionerConfig {
language: lang.to_string(),
command: cmd.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
install_hint: None,
});
}
}
configs
}
@@ -1,32 +0,0 @@
//! Installs language servers (non-interactive, via package managers or
//! direct download).
/// Install a language server for the given language.
///
/// Returns a success message or an error describing why installation failed.
pub fn install_language_server(language: &str) -> anyhow::Result<String> {
match language {
"rust" => {
// rust-analyzer is typically installed via rustup
let output = std::process::Command::new("rustup")
.args(["component", "add", "rust-analyzer"])
.output()?;
if output.status.success() {
Ok("rust-analyzer installed via rustup".to_string())
} else {
anyhow::bail!("failed to install rust-analyzer: {}", String::from_utf8_lossy(&output.stderr))
}
}
"python" => {
let output = std::process::Command::new("npm")
.args(["install", "-g", "pyright"])
.output()?;
if output.status.success() {
Ok("pyright installed via npm".to_string())
} else {
anyhow::bail!("failed to install pyright: {}", String::from_utf8_lossy(&output.stderr))
}
}
lang => anyhow::bail!("no install method known for language '{lang}'"),
}
}
@@ -1,46 +0,0 @@
//! High-level manager that discovers, installs (if needed), and starts
//! LSP servers.
use crate::lsp::manager::LspManager;
use super::discovery::discover_installed;
use super::install::install_language_server;
/// Auto-provision language servers for the given list of languages.
///
/// Flow: discover already-installed servers → for each requested language
/// not yet available, attempt auto-install → start each server.
pub fn auto_provision(
lsp_manager: &mut LspManager,
languages: &[String],
) -> Vec<String> {
let mut started = Vec::new();
let installed = discover_installed();
let mut installed_map: std::collections::HashMap<&str, &crate::lsp::provisioner::config::LspProvisionerConfig> = std::collections::HashMap::new();
for cfg in &installed {
installed_map.insert(cfg.language.as_str(), cfg);
}
for lang in languages {
if let Some(cfg) = installed_map.get(lang.as_str()) {
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
started.push(lang.clone());
}
} else {
// Not installed — try auto-install
if install_language_server(lang).is_ok() {
// Re-discover after install
let refreshed = discover_installed();
for cfg in refreshed {
if cfg.language == *lang {
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
started.push(lang.clone());
}
break;
}
}
}
}
}
started
}
@@ -1,7 +0,0 @@
//! LSP language server provisioner — discovers, installs, and manages
//! language server executables.
pub mod config;
pub mod discovery;
pub mod install;
pub mod manager;
-1
View File
@@ -31,7 +31,6 @@ impl Default for McpManager {
} }
impl McpManager { impl McpManager {
/// Register an MCP server by name and transport string. /// Register an MCP server by name and transport string.
/// ///
/// Returns an error if a server with the same name is already registered. /// Returns an error if a server with the same name is already registered.
Loaded 50 of 145 files, more files were not shown because too many files have changed in this diff. Show more