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
147 changed files with 1555 additions and 2132 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/
+6 -6
View File
@@ -21,11 +21,11 @@ jobs:
with:
components: clippy
- name: Build workspace
run: cargo build --release --workspace
- name: Test workspace
run: cargo test --workspace
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy workspace
run: cargo clippy --workspace -- -D warnings
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test workspace
run: cargo test --workspace
+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)
Generated
+11 -45
View File
@@ -1088,15 +1088,6 @@ dependencies = [
"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]]
name = "fnv"
version = "1.0.7"
@@ -1972,19 +1963,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "mac_address"
version = "1.1.8"
@@ -3369,17 +3347,6 @@ dependencies = [
"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]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -4895,7 +4862,7 @@ dependencies = [
[[package]]
name = "zesdex-api"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"argon2",
@@ -4918,7 +4885,7 @@ dependencies = [
[[package]]
name = "zesdex-application"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"base64",
@@ -4935,7 +4902,7 @@ dependencies = [
[[package]]
name = "zesdex-bootstrap"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"chrono",
@@ -4952,7 +4919,7 @@ dependencies = [
[[package]]
name = "zesdex-daemon"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"base64",
@@ -4976,7 +4943,7 @@ dependencies = [
[[package]]
name = "zesdex-domain"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"base64",
@@ -4992,7 +4959,7 @@ dependencies = [
[[package]]
name = "zesdex-gateway"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"axum",
@@ -5019,7 +4986,7 @@ dependencies = [
[[package]]
name = "zesdex-grpc"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"axum",
@@ -5036,7 +5003,7 @@ dependencies = [
[[package]]
name = "zesdex-infrastructure"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"argon2",
@@ -5055,7 +5022,6 @@ dependencies = [
"infer",
"jsonwebtoken",
"libc",
"lsp-types",
"nucleo-matcher",
"percent-encoding",
"pulldown-cmark",
@@ -5085,7 +5051,7 @@ dependencies = [
[[package]]
name = "zesdex-tui"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"base64",
@@ -5111,7 +5077,7 @@ dependencies = [
[[package]]
name = "zesdex-web"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"axum",
@@ -5131,7 +5097,7 @@ dependencies = [
[[package]]
name = "zesdex-ws"
version = "1.18.0"
version = "1.18.3"
dependencies = [
"anyhow",
"axum",
+1 -2
View File
@@ -15,7 +15,7 @@ members = [
]
[workspace.package]
version = "1.18.2"
version = "1.19.0"
edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"]
@@ -61,7 +61,6 @@ 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"] }
lsp-types = "0.97"
tiktoken-rs = "0.12"
similar = "3"
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
+1 -1
View File
@@ -12,7 +12,7 @@ WORKDIR /app
COPY . .
# 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
FROM debian:bookworm-slim
+1 -4
View File
@@ -16,10 +16,7 @@ pub trait ToolExecutor: Send + Sync {
/// 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;
fn run_turn(&self, params: AgentTurnParams) -> impl Future<Output = Result<()>> + Send;
}
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::main_agent_prompt;
use crate::ports::ProviderService;
use super::{ExploreService, ToolExecutor};
use crate::ports::ProviderService;
/// Maximum tool-call iterations per agent turn before forcing termination.
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> {
pub fn new(
provider: Arc<P>,
tool_executor: Arc<T>,
tool_defs: Vec<ToolDef>,
) -> Self {
pub fn new(provider: Arc<P>, tool_executor: Arc<T>, tool_defs: Vec<ToolDef>) -> Self {
Self {
provider,
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) => {
// Insert each context message as a system message.
// 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.
// It is removed before emitting the Compacted event so persistence
// 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();
for iteration in 0..MAX_TURN_ITERATIONS {
@@ -287,7 +288,8 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
// ── Execute each tool call ──────────────────────────
for tc in &tool_calls {
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
.messages
.push(ChatMessage::tool(tc.id.clone(), output));
@@ -295,10 +297,7 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
}
Err(e) => {
warn!("{e}");
push_event(
&params.turn_events,
TurnEvent::Error(e),
);
push_event(&params.turn_events, TurnEvent::Error(e));
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
// so the transcript shows only the actual user/assistant/tool exchange.
let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect();
push_event(
&params.turn_events,
TurnEvent::Compacted(compacted),
);
push_event(&params.turn_events, TurnEvent::Compacted(compacted));
push_event(&params.turn_events, TurnEvent::Done);
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 evicted: Vec<_> = messages.drain(..split_idx).collect();
let mut summary_prompt = vec![
ChatMessage::system(zesdex_domain::compaction_prompt()),
];
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 {
match provider
.chat(&summary_prompt, None, Some(1024), Some(0.3))
.await
{
Ok((summary_msg, _)) => {
let summary_text = summary_msg
.content
@@ -373,5 +370,3 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
}
}
}
+7 -20
View File
@@ -45,11 +45,7 @@ use sha2::{Digest, Sha256};
/// 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>;
fn save_flow_state(&self, verifier: &str, state: &str) -> Result<(), ServiceError>;
/// Load the stored PKCE code verifier.
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> {
/// Create a new OAuth use-case.
pub fn new(
token_repo: R,
flow_store: S,
token_exchanger: E,
token_path: PathBuf,
) -> Self {
pub fn new(token_repo: R, flow_store: S, token_exchanger: E, token_path: PathBuf) -> Self {
OAuthUseCase {
token_repo,
flow_store,
@@ -156,8 +147,8 @@ impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S
}
}
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E>
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> zesdex_domain::auth::OAuthService
for OAuthUseCase<R, S, E>
{
fn start_flow(
&self,
@@ -183,13 +174,9 @@ impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
"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
))
})?;
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")
+6 -11
View File
@@ -46,12 +46,11 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
}
}
impl<R: SessionRepository, L: SessionLockRepository>
zesdex_domain::auth::SessionService for SessionServiceImpl<R, L>
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 id = SessionId::new(&Uuid::new_v4().to_string()).map_err(ServiceError::Other)?;
let title_owned = if title.is_empty() {
"New Session".to_string()
} else {
@@ -59,8 +58,7 @@ impl<R: SessionRepository, L: SessionLockRepository>
};
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)?;
self.session_repo.save_session(&self.base_dir, &session)?;
Ok(session)
}
@@ -73,17 +71,14 @@ impl<R: SessionRepository, L: SessionLockRepository>
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)?;
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)?;
self.session_repo.save_session(&self.base_dir, &session)?;
Ok(())
}
}
@@ -58,11 +58,7 @@ impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
Ok(())
}
fn add_message(
&self,
conv: &mut Conversation,
msg: ChatMessage,
) -> Result<(), ServiceError> {
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);
+5 -14
View File
@@ -14,8 +14,7 @@ use std::path::PathBuf;
use tracing;
use zesdex_domain::cms::{
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings,
SettingsRepository,
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings, SettingsRepository,
};
/// 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> {
/// 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 {
pub fn new(settings_repo: S, app_config_repo: C, base_dir: impl Into<PathBuf>) -> Self {
tracing::debug!("creating SettingsServiceImpl");
Self {
settings_repo,
@@ -44,8 +39,8 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
}
}
impl<S: SettingsRepository, C: AppConfigRepository>
zesdex_domain::cms::SettingsService for SettingsServiceImpl<S, C>
impl<S: SettingsRepository, C: AppConfigRepository> zesdex_domain::cms::SettingsService
for SettingsServiceImpl<S, C>
{
fn load_settings(&self) -> Result<Settings, ServiceError> {
tracing::debug!("loading settings");
@@ -60,11 +55,7 @@ impl<S: SettingsRepository, C: AppConfigRepository>
Ok(())
}
fn update_provider(
&self,
name: &str,
config: &ProviderConfig,
) -> Result<(), ServiceError> {
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
+3 -4
View File
@@ -30,10 +30,10 @@
//! the use-case logic independent of any specific persistence or infrastructure
//! technology.
pub mod agent;
pub mod auth;
pub mod cms;
pub mod ports;
pub mod agent;
// Re-export port traits for ergonomic access.
pub use ports::*;
@@ -46,12 +46,11 @@ pub use auth::{
// Re-export CMS use-cases.
pub use cms::{
conversation_service::ConversationServiceImpl,
memory_service::MemoryServiceImpl,
conversation_service::ConversationServiceImpl, memory_service::MemoryServiceImpl,
settings_service::SettingsServiceImpl,
};
pub use agent::{
turn_service::{compact_messages_with_ai, AgentTurnServiceImpl},
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.
///
/// Returns `true` if the password matches, `false` otherwise.
fn authenticate(
&self,
password: &str,
hash: &str,
) -> impl Future<Output = Result<bool>> + Send;
fn authenticate(&self, password: &str, hash: &str)
-> impl Future<Output = Result<bool>> + Send;
/// 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};
pub mod defaults;
pub mod prompt;
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.
-10
View File
@@ -46,10 +46,6 @@ pub struct SettingsPatch {
pub review_enabled: Option<bool>,
/// Override the session-archive-enabled flag.
pub session_archive_enabled: Option<bool>,
/// Override the LSP auto-provision flag.
pub lsp_auto_provision: Option<bool>,
/// Override the list of LSP-managed languages.
pub lsp_languages: Option<Vec<String>>,
/// Override the hive-mind node timeout in milliseconds.
pub hive_mind_node_timeout_ms: Option<u64>,
}
@@ -111,12 +107,6 @@ impl SettingsPatch {
if let Some(val) = self.session_archive_enabled {
settings.flags.session_archive_enabled = val;
}
if let Some(val) = self.lsp_auto_provision {
settings.flags.lsp_auto_provision = val;
}
if let Some(ref val) = self.lsp_languages {
settings.lsp_languages = val.clone();
}
if let Some(val) = self.hive_mind_node_timeout_ms {
settings.hive_mind_node_timeout_ms = val;
}
+1 -1
View File
@@ -11,5 +11,5 @@
//! - `ChatMessage` — a single message with role, content, and tool metadata
//! - `Role` — message role enum (User, Assistant, System, Tool)
pub use crate::core::message::{ChatMessage, Role};
pub use crate::core::conversation::Conversation;
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::ModelRole;
pub use app_config::ProviderConfig;
pub use commands::{NewMemory, SettingsPatch};
pub use conversation::Conversation;
pub use edit_log::EditLog;
pub use edit_log::EditLogEntry;
@@ -46,7 +47,6 @@ pub use repository::SettingsRepository;
pub use service::ConversationService;
pub use service::MemoryService;
pub use service::SettingsService;
pub use commands::{NewMemory, SettingsPatch};
pub use settings::InternetMode;
pub use settings::Settings;
pub use settings::SettingsFlags;
+1 -5
View File
@@ -56,11 +56,7 @@ pub trait ConversationRepository {
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
/// Persist a `Conversation` to the given session directory.
fn save(
&self,
session_dir: &Path,
conversation: &Conversation,
) -> Result<(), RepositoryError>;
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError>;
}
/// 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>;
/// Append a single `ChatMessage` to the conversation and persist.
fn add_message(
&self,
conv: &mut Conversation,
msg: ChatMessage,
) -> Result<(), ServiceError>;
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError>;
}
/// Use-cases for long-term memory management.
-6
View File
@@ -18,7 +18,6 @@
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
//! - `flags` — grouped boolean feature toggles
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
use std::collections::HashMap;
@@ -49,12 +48,10 @@ pub enum InternetMode {
/// ## Fields
/// - `review_enabled` — enable automatic inline review after edits
/// - `session_archive_enabled` — enable periodic session archiving
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsFlags {
pub review_enabled: bool,
pub session_archive_enabled: bool,
pub lsp_auto_provision: bool,
}
impl Default for SettingsFlags {
@@ -63,7 +60,6 @@ impl Default for SettingsFlags {
Self {
review_enabled: true,
session_archive_enabled: true,
lsp_auto_provision: true,
}
}
}
@@ -93,7 +89,6 @@ pub struct Settings {
pub workflow_max_concurrency: usize,
#[serde(flatten)]
pub flags: SettingsFlags,
pub lsp_languages: Vec<String>,
#[serde(default = "default_hive_mind_node_timeout_ms")]
pub hive_mind_node_timeout_ms: u64,
}
@@ -113,7 +108,6 @@ impl Default for Settings {
verify_timeout_ms: 30_000,
workflow_max_concurrency: 5,
flags: SettingsFlags::default(),
lsp_languages: Vec::new(),
hive_mind_node_timeout_ms: 600_000,
}
}
+10 -11
View File
@@ -335,16 +335,16 @@ impl SseParser {
{
const MAX_TOOL_CALLS: usize = 64;
for tc in tool_calls {
let raw_index =
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|| {
tracing::warn!(
"[stream] tool call delta missing index, \
let raw_index = tc
.get("index")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!(
"[stream] tool call delta missing index, \
defaulting to 0"
);
0
},
);
);
0
});
// Clamp index to prevent out-of-bounds / memory exhaustion
let index = usize::try_from(raw_index)
.unwrap_or(0)
@@ -395,8 +395,7 @@ impl SseParser {
if let Some(content) = delta.get("text").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
if let Some(reasoning) =
delta.get("reasoning_content").and_then(|r| r.as_str())
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str())
{
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.
pub fn new() -> Self {
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
{
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok().or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| format!("{h}/.local/share"))
}) {
PathBuf::from(data_dir).join("zesdex")
} else {
PathBuf::from(".local/share/zesdex")
+14 -15
View File
@@ -24,33 +24,32 @@
//! no framework imports, no side effects. All persistence is expressed
//! through repository traits that infrastructure adapters implement.
pub mod agent;
pub mod auth;
pub mod cms;
pub mod core;
pub mod error;
pub mod agent;
pub mod workflow;
pub mod subagent;
pub mod workflow;
// Re-export all public items from each module for ergonomic imports.
// Consumers can do `use zesdex_domain::*` for common types.
pub use auth::{
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService,
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session,
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService,
IamSession, NewSession, OAuthConfig, OAuthRepository, OAuthService, OAuthToken,
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session, SessionId,
SessionLock, SessionLockRepository, SessionRepository, SessionService,
};
pub use cms::{
AppConfig, AppConfigRepository, Conversation as CmsConversation,
ConversationRepository, ConversationService, EditLog, EditLogEntry,
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService,
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError,
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch,
SettingsRepository, SettingsService,
AppConfig, AppConfigRepository, Conversation as CmsConversation, ConversationRepository,
ConversationService, EditLog, EditLogEntry, EditLogRepository, InternetMode, Memory,
MemoryRepository, MemoryService, ModelRole, NewMemory, ProviderConfig,
RepositoryError as CmsRepositoryError, ServiceError as CmsServiceError, Settings,
SettingsFlags, SettingsPatch, SettingsRepository, SettingsService,
};
pub use core::{
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role,
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall,
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role, SseParser, Store,
StreamEvent, StreamOptions, TokenUsage, ToolCall, ToolCallResult, ToolDef, ToolFunction,
ToolFunctionDef, UsageStats,
};
pub use error::DomainError;
@@ -60,5 +59,5 @@ pub use agent::*;
pub use agent::defaults::*;
pub use agent::progress::AgentProgress;
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive};
pub use workflow::*;
pub use subagent::*;
pub use workflow::*;
+1 -1
View File
@@ -10,6 +10,6 @@ pub enum AccessTier {
Read,
/// Read + Write: above plus write, edit, delete, git, memory.
Write,
/// Full: above plus bash, shell, LSP, workflow, plan tools.
/// Full: above plus bash, shell, workflow, plan tools.
Full,
}
+3 -6
View File
@@ -4,7 +4,6 @@
//! configuration files plus a seed session for development/testing.
//! Invoked as `cargo run --bin seed`.
fn main() -> anyhow::Result<()> {
let store = zesdex_domain::core::Store::new();
store.ensure_dirs()?;
@@ -46,13 +45,11 @@ fn main() -> anyhow::Result<()> {
// Create a seed session
let session_id = uuid::Uuid::new_v4().to_string();
let session = zesdex_domain::auth::Session::new(
session_id.clone(),
"Seed Session".to_string(),
);
let session = zesdex_domain::auth::Session::new(session_id.clone(), "Seed Session".to_string());
// Persist via the session repository
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)?;
tracing::info!("Seed session created: id={session_id}");
+2 -2
View File
@@ -5,12 +5,12 @@ fn main() {
let base_dir = dirs::home_dir().unwrap().join(".local/share/zesdex");
let repo = JsonAppConfigRepository::new();
let config = repo.load(&base_dir).unwrap();
println!("Providers:");
for (k, v) in &config.providers {
println!(" - {} (default model: {:?})", k, v.default_model);
}
println!("Default provider: {}", config.default_provider);
println!("Default model: {}", config.default_model);
println!("Model roles:");
+4 -1
View File
@@ -16,7 +16,10 @@ struct ClaudeSettings {
}
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);
match std::fs::read_to_string(&path) {
Ok(content) => {
+5 -1
View File
@@ -12,7 +12,11 @@ use clap::Parser;
/// Zesdex — autonomous AI coding agent.
#[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 {
/// Run as background daemon with IPC socket
#[arg(long)]
-1
View File
@@ -32,7 +32,6 @@ ignore.workspace = true
nucleo-matcher.workspace = true
futures-util.workspace = true
rmcp.workspace = true
lsp-types.workspace = true
tiktoken-rs.workspace = true
similar.workspace = true
syntect.workspace = true
+71 -1
View File
@@ -8,12 +8,27 @@ pub struct JwtClaims {
pub sub: String,
pub exp: 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")]
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 {
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()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -22,6 +37,7 @@ impl JwtClaims {
sub,
exp,
iat,
token_type,
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)?;
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)
}
pub fn wait_for_code(
&self,
timeout_ms: u64,
expected_state: &str,
) -> std::io::Result<String> {
pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
let (mut stream, _) = self.listener.accept()?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
Self::read_callback(&mut stream, expected_state)
}
fn read_callback(
stream: &mut TcpStream,
expected_state: &str,
) -> std::io::Result<String> {
fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]);
@@ -68,7 +61,10 @@ impl LoopbackServer {
));
}
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 {
/// True if any ERROR-level violations exist.
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.
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.
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",
"jsonwebtoken",
"rmcp",
"lsp_types",
"tiktoken_rs",
"syntect",
"pulldown_cmark",
@@ -152,11 +159,7 @@ fn forbidden_imports(layer: &str) -> &'static [&'static str] {
}
/// Scan a single Rust source file for forbidden imports.
fn scan_file(
file_path: &Path,
layer: &'static str,
root: &Path,
) -> Vec<Violation> {
fn scan_file(file_path: &Path, layer: &'static str, root: &Path) -> Vec<Violation> {
let mut violations = Vec::new();
let content = match std::fs::read_to_string(file_path) {
Ok(c) => c,
@@ -185,10 +188,12 @@ fn scan_file(
// `use crate::` in domain could reference domain-only items — skip.
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.
let is_test = content[..content.len().saturating_sub(1)]
.contains("#[cfg(test)]");
let is_test =
content[..content.len().saturating_sub(1)].contains("#[cfg(test)]");
if is_test {
continue;
}
@@ -243,10 +248,7 @@ pub fn audit_layering(root: &Path) -> Result<AuditReport> {
}
// Determine which crate this file belongs to by walking up.
let layer = path
.ancestors()
.skip(1)
.find_map(|p| classify_layer(p));
let layer = path.ancestors().skip(1).find_map(|p| classify_layer(p));
if let Some(layer) = layer {
files_scanned += 1;
@@ -371,7 +373,10 @@ mod tests {
fn function_metrics_short_function_ok() {
let content = "fn ok() {\n let x = 1;\n}\n";
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");
}
@@ -383,7 +388,10 @@ mod tests {
}
lines.push_str("}\n");
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");
}
}
@@ -36,7 +36,9 @@ pub struct CodeQualityReport {
impl CodeQualityReport {
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)> {
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 ────────────────────
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
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();
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");
}
@@ -262,8 +268,14 @@ mod tests {
.unwrap();
let findings = scan_quality_file(&file, &dir);
let unwrap_findings: Vec<_> = findings.iter().filter(|f| f.rule == "unwrap-in-production").collect();
assert!(unwrap_findings.is_empty(), "should skip unwrap in test blocks");
let unwrap_findings: Vec<_> = findings
.iter()
.filter(|f| f.rule == "unwrap-in-production")
.collect();
assert!(
unwrap_findings.is_empty(),
"should skip unwrap in test blocks"
);
}
#[test]
@@ -274,7 +286,13 @@ mod tests {
std::fs::write(&file, "#[allow(clippy::too_many_arguments)]\nfn x() {}\n").unwrap();
let findings = scan_quality_file(&file, &dir);
let bypass_findings: Vec<_> = findings.iter().filter(|f| f.rule == "compiler-bypass").collect();
assert!(!bypass_findings.is_empty(), "should detect allow attributes");
let bypass_findings: Vec<_> = findings
.iter()
.filter(|f| f.rule == "compiler-bypass")
.collect();
assert!(
!bypass_findings.is_empty(),
"should detect allow attributes"
);
}
}
+25 -18
View File
@@ -50,10 +50,9 @@ pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
}
// Parse: `type(scope): description` or `type!: description` or `type: description`
let re = Regex::new(
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
)
.expect("valid regex for commit parsing");
let re =
Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$")
.expect("valid regex for commit parsing");
match re.captures(subject) {
None => {
@@ -94,21 +93,19 @@ pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
} else {
let first_char = desc.chars().next().unwrap_or(' ');
if first_char.is_uppercase() {
errors.push(format!(
"Description must start with lowercase: '{desc}'"
));
errors.push(format!("Description must start with lowercase: '{desc}'"));
}
if desc.ends_with('.') {
errors.push(format!(
"Description must not end with a period: '{desc}'"
));
errors.push(format!("Description must not end with a period: '{desc}'"));
}
}
// Type-specific rules.
match type_ {
"chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build"
| "revert" if scope.is_some() => {
| "revert"
if scope.is_some() =>
{
errors.push(format!(
"'{type_}' commits should not use a scope. \
Only 'feat' and 'fix' require scopes."
@@ -140,10 +137,9 @@ pub fn parse_commit_message(msg: &str) -> Option<CommitInfo> {
let msg = msg.trim();
let subject = msg.lines().next()?;
let re = Regex::new(
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
)
.expect("valid regex");
let re =
Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$")
.expect("valid regex");
let caps = re.captures(subject)?;
@@ -155,10 +151,18 @@ pub fn parse_commit_message(msg: &str) -> Option<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()),
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,
})
}
@@ -232,7 +236,10 @@ mod tests {
#[test]
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.scope, Some("agent".to_string()));
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\
5. Count files by extension type.\n\
Use the ls_dir, read, grep, and glob tools. Be concise.",
// Agent 1: Symbol Index
"You are a symbol index explorer.\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\
4. Group symbols by language and kind.\n\
Be concise. Report what symbols exist and where they live.",
// Agent 2: Semantic Context
"You are a semantic context explorer.\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 handle = thread::spawn(move || {
let rt = tokio::runtime::Runtime::new()
.context("create explore subagent tokio runtime")?;
let rt =
tokio::runtime::Runtime::new().context("create explore subagent tokio runtime")?;
rt.block_on(run_agent(ctx, &directive, AccessTier::Read, tc))
});
@@ -252,12 +250,22 @@ async fn run_explore_phase(
}
Ok(Err(e)) => {
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)
}
Err(e) => {
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)
}
};
@@ -281,9 +289,7 @@ fn build_explore_context(results: &[(usize, String, bool)]) -> String {
let success_count = results.iter().filter(|r| r.2).count();
let total = results.len();
let mut msg = format!(
"[Explore Phase — {success_count}/{total} agents succeeded]\n\n"
);
let mut msg = format!("[Explore Phase — {success_count}/{total} agents succeeded]\n\n");
for (i, output, success) in results {
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 quality = self.scan_quality(workspace_root)?;
Ok(CombinedAuditReport {
layering,
quality,
})
Ok(CombinedAuditReport { layering, quality })
}
}
+3 -1
View File
@@ -89,6 +89,8 @@ impl BashJob {
let Ok(mut guard) = self.process.lock() else {
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> {
match tool_name {
"bash" => {
let cmd = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
// Detect git push with --force
if cmd.contains("git push") && cmd.contains("--force") {
return Some("Force-pushing to git is destructive and may lose history".to_string());
return Some(
"Force-pushing to git is destructive and may lose history".to_string(),
);
}
// Detect rm -rf /
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
return Some("Recursive deletion of the root filesystem is never allowed".to_string());
return Some(
"Recursive deletion of the root filesystem is never allowed".to_string(),
);
}
}
"delete" => {
let path = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
if path == "/" || path.starts_with("/etc") {
return Some(format!("Deleting '{}' is too dangerous", path));
}
+1 -4
View File
@@ -76,10 +76,7 @@ pub struct StatePayload {
pub enum DaemonFrame {
StateUpdate(Box<StatePayload>),
StreamToken(String),
SystemNote {
kind: String,
message: String,
},
SystemNote { kind: String, message: String },
ClipboardCopy(String),
Closed,
}
+1 -4
View File
@@ -15,7 +15,6 @@
//! ├── auth/ — JWT, Argon2, OAuth loopback
//! ├── llm/ — LLM provider HTTP client
//! ├── ipc/ — Unix-socket IPC protocol
//! ├── lsp/ — Native LSP client + provisioner
//! ├── mcp/ — Model Context Protocol bridge
//! ├── bgbash/ — Background bash job management
//! ├── tools/ — All 37 agent-invocable tools
@@ -32,7 +31,6 @@ pub mod bgbash;
pub mod guard;
pub mod ipc;
pub mod llm;
pub mod lsp;
pub mod mcp;
pub mod middleware;
pub mod persistence;
@@ -67,8 +65,7 @@ use std::sync::Arc;
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
// Re-export commonly needed types at the crate root
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult};
pub use zesdex_domain::core::{ChatMessage, Role, Store, ToolCallResult, UsageStats};
/// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame.
+16 -23
View File
@@ -4,10 +4,10 @@
use rand_core::RngCore;
use std::time::Duration;
use zesdex_application::ports::ProviderService;
use zesdex_domain::core::{
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
};
use zesdex_application::ports::ProviderService;
use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL};
const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE;
@@ -82,10 +82,7 @@ impl LlmClient {
retrying without connect timeout",
e,
);
match reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
{
match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() {
Ok(c) => c,
Err(e2) => {
tracing::warn!("also failed: {e2}. using default client");
@@ -115,8 +112,7 @@ impl LlmClient {
.post(url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req =
http_req.header("Authorization", format!("Bearer {}", self.api_key));
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let mut resp = http_req.json(req).send().await.map_err(|e| {
@@ -182,7 +178,7 @@ impl LlmClient {
}
let tc = &mut self.tool_calls[index];
if let Some(ref id_val) = id {
tc.id = id_val.clone();
}
@@ -237,8 +233,7 @@ impl LlmClient {
n
}
};
let text =
String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
byte_buf.drain(..valid_len);
for event in parser.feed(&text) {
@@ -306,8 +301,7 @@ impl ProviderService for LlmClient {
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req =
http_req.header("Authorization", format!("Bearer {}", self.api_key));
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let result = async {
@@ -335,9 +329,9 @@ impl ProviderService for LlmClient {
}
let data: ChatResponse = resp.json().await?;
let usage = data.usage.map(|u| {
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens))
});
let usage = data
.usage
.map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)));
let message = data
.choices
.into_iter()
@@ -345,7 +339,8 @@ impl ProviderService for LlmClient {
.and_then(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
Ok((message, usage))
}.await;
}
.await;
match result {
Ok((msg, usage)) => return Ok((msg, usage)),
@@ -393,14 +388,16 @@ impl ProviderService for LlmClient {
let mut captured_content = false;
let mut wrapped = |event: &StreamEvent| -> bool {
match event {
StreamEvent::Token(_) | StreamEvent::Reasoning(_) | StreamEvent::ToolCallDelta { .. } => {
StreamEvent::Token(_)
| StreamEvent::Reasoning(_)
| StreamEvent::ToolCallDelta { .. } => {
captured_content = true;
}
_ => {}
}
on_event(event)
};
match self.try_stream_once(&req, &url, &mut wrapped).await {
Ok(result) => return Ok(result),
Err(e) => {
@@ -425,11 +422,7 @@ pub fn resolve_api_key(
) -> String {
let provider = &settings.provider;
let mut api_key = settings
.api_keys
.get(provider)
.cloned()
.unwrap_or_default();
let mut api_key = settings.api_keys.get(provider).cloned().unwrap_or_default();
if api_key.is_empty() {
if let Some(provider_cfg) = app_config.providers.get(provider) {
-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;
Loaded 50 of 147 files, more files were not shown because too many files have changed in this diff. Show more