chore: hapus semua source Rust + Cargo.toml/lock + .cargo (247 file .rs, migrasi ke TS/Bun)

This commit is contained in:
asepharyana
2026-09-02 22:51:10 +07:00
parent 319198d2f0
commit c165dff114
250 changed files with 0 additions and 33715 deletions
-120
View File
@@ -1,120 +0,0 @@
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
use serde::{Deserialize, Serialize};
/// Standard JWT claims with optional session binding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
pub sub: String,
pub exp: u64,
pub iat: u64,
/// 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, token_type: TokenType, session_id: Option<String>) -> Self {
let iat = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
sub,
exp,
iat,
token_type,
session_id,
}
}
}
/// Sign a set of claims into a JWT string using HS256.
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
let token = jsonwebtoken::encode(&header, &claims, &key)?;
Ok(token)
}
/// Verify a JWT string and return its claims.
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.validate_exp = true;
validation.required_spec_claims = ["sub", "exp", "iat"]
.iter()
.map(|&s| s.to_string())
.collect();
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
Ok(token_data.claims)
}
#[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
View File
@@ -1,6 +0,0 @@
//! Auth service implementations: JWT signing/verification, Argon2 password
//! hashing, and OAuth loopback server.
pub mod jwt;
pub mod oauth_loopback;
pub mod password;
@@ -1,121 +0,0 @@
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
/// `?code=...` redirect and serves back a static confirmation page.
pub struct LoopbackServer {
listener: TcpListener,
port: u16,
}
impl LoopbackServer {
pub fn bind() -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
Ok(LoopbackServer { listener, port })
}
pub fn redirect_uri(&self) -> String {
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> {
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> {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]);
let code = Self::extract_code(&request);
let state = Self::extract_state(&request);
let state_ok = state.as_deref() == Some(expected_state);
let response = match (code.as_ref(), state_ok) {
(Some(_), true) => {
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
Authorization complete. You may close this tab."
}
(Some(_), false) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
State mismatch — possible CSRF attack."
}
(None, _) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
Missing authorization code."
}
};
if let Err(e) = stream.write_all(response.as_bytes()) {
tracing::warn!("OAuth loopback write error: {e}");
}
if let Err(e) = stream.flush() {
tracing::warn!("OAuth loopback flush error: {e}");
}
if !state_ok {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"state mismatch",
));
}
code.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"code not found in callback",
)
})
}
fn extract_code(request: &str) -> Option<String> {
let line = request.lines().next()?;
let path = line.split(' ').nth(1)?;
let query = path.split('?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "code" {
return parts.next().map(urlencoding);
}
}
None
}
fn extract_state(request: &str) -> Option<String> {
let line = request.lines().next()?;
let path = line.split(' ').nth(1)?;
let query = path.split('?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "state" {
return parts.next().map(urlencoding);
}
}
None
}
}
/// Percent-decode a string (e.g. `%20` -> space).
fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '%' {
match (
chars.next().and_then(|c| c.to_digit(16)),
chars.next().and_then(|c| c.to_digit(16)),
) {
(Some(hi), Some(lo)) => {
let byte: u8 = (hi as u8) * 16 + lo as u8;
result.push(char::from(byte));
}
_ => {
result.push('%');
}
}
} else {
result.push(c);
}
}
result
}
-39
View File
@@ -1,39 +0,0 @@
//! Argon2 password hashing and verification utilities.
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rand_core::OsRng;
/// Hash a plaintext password using Argon2id with a random salt.
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
let password = password.to_string();
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
Ok(hash.to_string())
})
.await
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
}
/// Verify a plaintext password against a previously-hashed PHC string.
pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
let password = password.to_string();
let hash = hash.to_string();
tokio::task::spawn_blocking(move || {
let parsed_hash = PasswordHash::new(&hash)
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
let argon2 = Argon2::default();
let valid = argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
Ok(valid)
})
.await
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
}
@@ -1,397 +0,0 @@
//! Architecture layering audit for clean-architecture compliance.
//!
//! Scans Rust source files in the workspace and reports violations of the
//! dependency rule: domain must not import from outer layers, application
//! must not import from infrastructure or interfaces, etc.
//!
//! # Flow
//!
//! `audit_layering(workspace_dir)` → walk `apps/` → classify crate by path →
//! scan `use` statements → match against forbidden crates → collect violations.
use anyhow::Result;
use ignore::Walk;
use std::path::Path;
use tracing::instrument;
/// A single layering violation found during the audit.
#[derive(Debug, Clone)]
pub struct Violation {
/// Severity level.
pub severity: Severity,
/// Which crate layer caused the violation.
pub layer: &'static str,
/// File path relative to workspace root.
pub file: String,
/// Line number (1-indexed).
pub line: usize,
/// Human-readable description.
pub message: String,
}
/// How severe a violation is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Error => write!(f, "ERROR"),
Severity::Warning => write!(f, "WARN"),
Severity::Info => write!(f, "INFO"),
}
}
}
/// Result of an architecture audit.
#[derive(Debug, Clone)]
pub struct AuditReport {
/// All violations found, grouped by severity.
pub violations: Vec<Violation>,
/// Number of source files scanned.
pub files_scanned: usize,
}
impl AuditReport {
/// True if any ERROR-level violations exist.
pub fn has_errors(&self) -> bool {
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()
}
/// Number of warnings.
pub fn warning_count(&self) -> usize {
self.violations
.iter()
.filter(|v| v.severity == Severity::Warning)
.count()
}
}
/// Classify a path into a clean-architecture layer name.
fn classify_layer(crate_path: &Path) -> Option<&'static str> {
let path_str = crate_path.to_string_lossy();
if path_str.contains("/domain") || path_str.ends_with("/domain") {
Some("domain")
} else if path_str.contains("/application") || path_str.ends_with("/application") {
Some("application")
} else if path_str.contains("/infrastructure") || path_str.ends_with("/infrastructure") {
Some("infrastructure")
} else if path_str.contains("/interfaces/") {
Some("interfaces")
} else if path_str.contains("/gateway") {
Some("gateway")
} else {
None
}
}
/// Forbidden import patterns per layer.
///
/// Returns a list of crate prefixes that the given layer must NOT import.
fn forbidden_imports(layer: &str) -> &'static [&'static str] {
match layer {
"domain" => &[
"zesdex_application",
"zesdex_infrastructure",
"zesdex_tui",
"zesdex_api",
"zesdex_daemon",
"zesdex_ws",
"zesdex_grpc",
"zesdex_web",
"zesdex_gateway",
"tokio",
"axum",
"reqwest",
"rusqlite",
"ratatui",
"crossterm",
"tower",
"tower_http",
"argon2",
"jsonwebtoken",
"rmcp",
"tiktoken_rs",
"syntect",
"pulldown_cmark",
"serde_yaml_ng",
"ignore",
"dom_smoothie",
"fast_html2md",
"scraper",
"clap",
],
"application" => &[
"zesdex_infrastructure",
"zesdex_tui",
"zesdex_api",
"zesdex_daemon",
"zesdex_ws",
"zesdex_grpc",
"zesdex_web",
"zesdex_gateway",
],
"infrastructure" => &[
"zesdex_tui",
"zesdex_api",
"zesdex_daemon",
"zesdex_ws",
"zesdex_grpc",
"zesdex_web",
"zesdex_gateway",
],
_ => &[],
}
}
/// Scan a single Rust source file for forbidden imports.
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,
Err(_) => return violations,
};
let forbidden = forbidden_imports(layer);
if forbidden.is_empty() {
return violations;
}
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
// Match: `use zesdex_application::...` or `use zesdex_infrastructure::...`
if trimmed.starts_with("use ") {
for &forbidden in forbidden {
let pattern = format!("use {forbidden}");
if trimmed.starts_with(&pattern) || trimmed.starts_with("use crate::") {
// `use crate::` in domain could reference domain-only items — skip.
continue;
}
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)]");
if is_test {
continue;
}
violations.push(Violation {
severity: Severity::Error,
layer,
file: relative.clone(),
line: line_num + 1,
message: format!(
"Layer '{layer}' must not depend on '{forbidden}': {trimmed}"
),
});
}
}
}
}
violations
}
/// Run a full architecture layering audit on the workspace at `root`.
///
/// Walks all `.rs` files under `root/apps/`, classifies each by its parent
/// crate, and checks `use` statements against the dependency rule.
#[instrument(skip(root))]
pub fn audit_layering(root: &Path) -> Result<AuditReport> {
let apps_dir = root.join("apps");
if !apps_dir.is_dir() {
return Ok(AuditReport {
violations: vec![Violation {
severity: Severity::Warning,
layer: "workspace",
file: "apps/".to_string(),
line: 0,
message: format!("apps/ directory not found at {}", apps_dir.display()),
}],
files_scanned: 0,
});
}
let mut violations = Vec::new();
let mut files_scanned = 0;
for entry in Walk::new(&apps_dir).flatten() {
if entry.file_type().is_none_or(|ft| !ft.is_file()) {
continue;
}
let path = entry.path();
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
// Determine which crate this file belongs to by walking up.
let layer = path.ancestors().skip(1).find_map(|p| classify_layer(p));
if let Some(layer) = layer {
files_scanned += 1;
violations.extend(scan_file(path, layer, root));
}
}
Ok(AuditReport {
violations,
files_scanned,
})
}
/// Count lines of code and nesting depth in a Rust source file.
pub fn check_function_metrics(content: &str) -> Vec<Violation> {
let mut violations = Vec::new();
let mut in_function = false;
let mut fn_start = 0;
let mut fn_name = String::new();
let mut brace_depth = 0;
let mut max_nesting = 0;
let mut current_nesting: i32 = 0;
for (i, line) in content.lines().enumerate() {
let line_num = i + 1;
// Track function entry.
if line.trim().starts_with("fn ") && line.trim().ends_with('{') {
in_function = true;
fn_start = line_num;
fn_name = line.trim().to_string();
brace_depth = 1;
max_nesting = 0;
current_nesting = 0;
continue;
}
if in_function {
for ch in line.chars() {
match ch {
'{' => {
brace_depth += 1;
current_nesting += 1;
max_nesting = max_nesting.max(current_nesting);
}
'}' => {
brace_depth -= 1;
current_nesting = (current_nesting.saturating_sub(1)).max(0);
if brace_depth == 0 {
// End of function — check metrics.
let fn_lines = line_num - fn_start;
if fn_lines > 40 {
violations.push(Violation {
severity: Severity::Warning,
layer: "code",
file: String::new(),
line: fn_start,
message: format!(
"Function too long: {} lines (max 40): {}",
fn_lines, fn_name
),
});
}
if max_nesting >= 4 {
violations.push(Violation {
severity: Severity::Warning,
layer: "code",
file: String::new(),
line: fn_start,
message: format!(
"Deep nesting (level {}) in: {}",
max_nesting, fn_name
),
});
}
in_function = false;
break;
}
}
_ => {}
}
}
}
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domain_layer_forbidden_imports() {
let forbidden = forbidden_imports("domain");
assert!(forbidden.contains(&"tokio"));
assert!(forbidden.contains(&"zesdex_application"));
assert!(forbidden.contains(&"axum"));
}
#[test]
fn application_layer_forbidden_imports() {
let forbidden = forbidden_imports("application");
assert!(forbidden.contains(&"zesdex_infrastructure"));
assert!(!forbidden.contains(&"tokio")); // tokio is allowed in application
}
#[test]
fn classify_layer_works() {
let p = Path::new("/root/apps/domain/src/lib.rs");
assert_eq!(classify_layer(p), Some("domain"));
let p = Path::new("/root/apps/application/src/lib.rs");
assert_eq!(classify_layer(p), Some("application"));
let p = Path::new("/root/apps/gateway/src/main.rs");
assert_eq!(classify_layer(p), Some("gateway"));
}
#[test]
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();
assert!(long.is_empty(), "short function should not trigger");
}
#[test]
fn function_metrics_reports_long_function() {
let mut lines = String::from("fn long() {\n");
for _ in 0..45 {
lines.push_str(" let _ = 1;\n");
}
lines.push_str("}\n");
let violations = check_function_metrics(&lines);
let long: Vec<_> = violations
.iter()
.filter(|v| v.message.contains("Function too long"))
.collect();
assert!(!long.is_empty(), "long function should trigger warning");
}
}
@@ -1,298 +0,0 @@
//! Code-quality analysis for clean-code compliance.
//!
//! Scans Rust source files for common clean-code violations:
//! - Missing doc comments on `pub` items
//! - `.unwrap()` / `.expect()` in production (non-test) code
//! - `#[allow(...)]` / `#[expect(...)]` compiler bypasses
//! - Magic number literals (integer/float constants)
//! - Commented-out code blocks
//!
//! # Flow
//!
//! `scan_file(path, root)` → read source → classify lines into scopes →
//! match each rule → return `Vec<Finding>`.
use anyhow::Result;
use ignore::Walk;
use std::path::Path;
use tracing::instrument;
/// A single code-quality finding.
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: super::arch_audit::Severity,
pub rule: &'static str,
pub file: String,
pub line: usize,
pub message: String,
}
/// Result of a code-quality scan.
#[derive(Debug, Clone)]
pub struct CodeQualityReport {
pub findings: Vec<Finding>,
pub files_scanned: usize,
}
impl CodeQualityReport {
pub fn has_errors(&self) -> bool {
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();
for f in &self.findings {
*counts.entry(f.rule).or_default() += 1;
}
let mut sorted: Vec<_> = counts.into_iter().collect();
sorted.sort_by_key(|(_, c)| *c);
sorted.reverse();
sorted
}
}
/// Check if a line is inside a `#[cfg(test)]` block.
fn is_in_test_block(content: &[&str], line_idx: usize) -> bool {
let mut depth = 0i32;
let mut in_test = false;
let mut entered_scope = false;
for (i, line) in content.iter().enumerate() {
if i > line_idx {
break;
}
if line.contains("#[cfg(test)]") {
in_test = true;
}
depth += line.matches('{').count() as i32;
if in_test && depth > 0 {
entered_scope = true;
}
depth -= line.matches('}').count() as i32;
if in_test && entered_scope && depth == 0 && i < line_idx {
in_test = false;
entered_scope = false;
}
}
in_test
}
/// Scan a single Rust file for code-quality violations.
pub fn scan_quality_file(file_path: &Path, root: &Path) -> Vec<Finding> {
let mut findings = Vec::new();
let content = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(_) => return findings,
};
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let lines: Vec<&str> = content.lines().collect();
// Track pub items for doc-comment checking.
let mut prev_line_doc = false;
let mut prev_line_empty = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
let line_num = i + 1;
let in_test = is_in_test_block(&lines, i);
// ── Rule: Compiler bypass ──────────────────────────────────────
if trimmed.starts_with("#[allow(") || trimmed.starts_with("#[expect(") {
// Skip if this is in the workspace lints config (Cargo.toml)
if trimmed.contains("clippy::") || trimmed.contains("dead_code") {
findings.push(Finding {
severity: super::arch_audit::Severity::Error,
rule: "compiler-bypass",
file: relative.clone(),
line: line_num,
message: format!("Compiler bypass attribute found: {trimmed}"),
});
}
}
// ── Rule: unwrap / expect in production code ───────────────────
if !in_test {
if let Some(col) = trimmed.find(".unwrap(") {
// Allow unwrap in test code and in `#[]` attributes
if !trimmed.starts_with("//") {
let snippet = &trimmed[col..(col + 20).min(trimmed.len())];
findings.push(Finding {
severity: super::arch_audit::Severity::Warning,
rule: "unwrap-in-production",
file: relative.clone(),
line: line_num,
message: format!(
"Use `?` or proper error handling instead of `.unwrap()`: {snippet}..."
),
});
}
}
if let Some(col) = trimmed.find(".expect(") {
if !trimmed.starts_with("//") {
let snippet = &trimmed[col..(col + 20).min(trimmed.len())];
findings.push(Finding {
severity: super::arch_audit::Severity::Info,
rule: "expect-in-production",
file: relative.clone(),
line: line_num,
message: format!(".expect() with message: {snippet}..."),
});
}
}
}
// ── Rule: Missing doc comments on pub items ────────────────────
if (trimmed.starts_with("pub ") || trimmed.starts_with("pub("))
&& !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 ")
|| trimmed.starts_with("pub struct ")
|| trimmed.starts_with("pub enum ")
|| trimmed.starts_with("pub trait ")
|| trimmed.starts_with("pub type ")
|| trimmed.starts_with("pub const ")
|| trimmed.starts_with("pub mod ")
|| trimmed.starts_with("pub(crate) fn ")
|| trimmed.starts_with("pub(crate) struct ")
|| trimmed.starts_with("pub(crate) enum ")
|| trimmed.starts_with("pub(crate) trait ");
if is_item {
findings.push(Finding {
severity: super::arch_audit::Severity::Info,
rule: "missing-doc",
file: relative.clone(),
line: line_num,
message: format!("Missing doc comment on pub item: {trimmed}"),
});
}
}
// ── Rule: Commented-out code ───────────────────────────────────
if trimmed.starts_with("// ") && !in_test {
let stripped = trimmed.trim_start_matches("// ");
if stripped.starts_with("fn ")
|| stripped.starts_with("let ")
|| stripped.starts_with("if ")
|| stripped.starts_with("for ")
|| stripped.starts_with("while ")
|| stripped.starts_with("match ")
|| stripped.starts_with("pub ")
|| stripped.starts_with("impl ")
{
findings.push(Finding {
severity: super::arch_audit::Severity::Info,
rule: "commented-code",
file: relative.clone(),
line: line_num,
message: format!("Commented-out code detected: {trimmed}"),
});
}
}
// Track doc-comment state
prev_line_doc = trimmed.starts_with("///") || trimmed.starts_with("//!");
prev_line_empty = trimmed.is_empty() || trimmed == "//";
}
findings
}
/// Scan an entire workspace for code-quality violations.
#[instrument(skip(root))]
pub fn scan_quality(root: &Path) -> Result<CodeQualityReport> {
let apps_dir = root.join("apps");
if !apps_dir.is_dir() {
return Ok(CodeQualityReport {
findings: vec![],
files_scanned: 0,
});
}
let mut findings = Vec::new();
let mut files_scanned = 0;
for entry in Walk::new(&apps_dir).flatten() {
if entry.file_type().is_none_or(|ft| !ft.is_file()) {
continue;
}
let path = entry.path();
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
files_scanned += 1;
findings.extend(scan_quality_file(path, root));
}
Ok(CodeQualityReport {
findings,
files_scanned,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_unwrap_in_production_code() {
let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("test.rs");
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();
assert!(!unwrap_findings.is_empty(), "should detect unwrap");
}
#[test]
fn skips_unwrap_in_test_block() {
let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("test.rs");
std::fs::write(
&file,
"#[cfg(test)]\nmod tests {\n fn x() { let y = foo.unwrap(); }\n}\n",
)
.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"
);
}
#[test]
fn detects_allow_attributes() {
let dir = std::env::temp_dir().join(format!("qtest_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("test.rs");
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"
);
}
}
@@ -1,261 +0,0 @@
//! Commit message validation following Conventional Commits (Bahasa Indonesia).
//!
//! # Flow
//!
//! `validate_commit_message(msg)` → parse subject → check type, scope,
//! description format → return list of errors (empty = valid).
//!
//! # Format
//!
//! ```text
//! feat(scope): description
//! fix(scope): description
//! chore: description
//! docs: description
//! refactor: description
//! test: description
//! style: description
//! perf: description
//! ci: description
//! ```
use regex::Regex;
/// Valid commit types.
const VALID_TYPES: &[&str] = &[
"feat", "fix", "chore", "docs", "refactor", "test", "style", "perf", "ci", "build", "revert",
];
/// Validate a commit message against the Conventional Commits spec.
///
/// Returns `Ok(())` if valid, or `Err(errors)` with human-readable messages.
pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let msg = msg.trim();
if msg.is_empty() {
errors.push("Commit message must not be empty.".to_string());
return Err(errors);
}
// Split subject and body.
let subject = msg.lines().next().unwrap_or(msg);
// Check subject length.
if subject.len() > 72 {
errors.push(format!(
"Subject line is {} characters (max 72).",
subject.len()
));
}
// 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");
match re.captures(subject) {
None => {
errors.push(format!(
"Subject does not match Conventional Commits format.\n\
Expected: `type(scope): description`\n\
Got: {subject}\n\
Valid types: {}",
VALID_TYPES.join(", ")
));
}
Some(caps) => {
let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
let scope = caps.name("scope").map(|m| m.as_str());
let desc = caps.name("desc").map(|m| m.as_str()).unwrap_or("");
// Validate type.
if !VALID_TYPES.contains(&type_) {
errors.push(format!(
"Invalid commit type '{type_}'. Valid types: {}",
VALID_TYPES.join(", ")
));
}
// Scope is lowercase, no spaces.
if let Some(s) = scope {
if s.contains(' ') {
errors.push(format!("Scope must not contain spaces: '{s}'"));
}
if s.chars().any(|c| c.is_uppercase()) {
errors.push(format!("Scope must be lowercase: '{s}'"));
}
}
// Description rules.
if desc.is_empty() {
errors.push("Description must not be empty.".to_string());
} else {
let first_char = desc.chars().next().unwrap_or(' ');
if first_char.is_uppercase() {
errors.push(format!("Description must start with lowercase: '{desc}'"));
}
if desc.ends_with('.') {
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() =>
{
errors.push(format!(
"'{type_}' commits should not use a scope. \
Only 'feat' and 'fix' require scopes."
));
}
_ => {}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
/// Convert a commit message to a structured representation.
pub struct CommitInfo {
pub type_: String,
pub scope: Option<String>,
pub breaking: bool,
pub description: String,
pub body: Option<String>,
}
/// Parse a commit message into its structured components.
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 caps = re.captures(subject)?;
let body_lines: Vec<&str> = msg.lines().skip(1).collect();
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
Some(CommitInfo {
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(),
body,
})
}
/// Suggest a commit message template for a given change type.
pub fn suggest_template(type_: &str, scope: Option<&str>) -> String {
match type_ {
"feat" | "fix" if scope.is_some() => {
format!("{}({}): <imperative description>", type_, scope.unwrap())
}
"feat" | "fix" => {
format!("{}(<scope>): <imperative description>", type_)
}
_ => {
format!("{}: <imperative description>", type_)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_feat_commit() {
assert!(validate_commit_message("feat(tool): add batch file delete").is_ok());
}
#[test]
fn valid_fix_commit() {
assert!(validate_commit_message("fix(ipc): reconnect loop on socket timeout").is_ok());
}
#[test]
fn valid_chore_commit() {
assert!(validate_commit_message("chore: bump reqwest to 0.13").is_ok());
}
#[test]
fn valid_docs_commit() {
assert!(validate_commit_message("docs: add architecture diagram to README").is_ok());
}
#[test]
fn rejects_empty_message() {
assert!(validate_commit_message("").is_err());
}
#[test]
fn rejects_missing_colon() {
assert!(validate_commit_message("feat:missing space").is_err());
}
#[test]
fn rejects_uppercase_description() {
let result = validate_commit_message("feat(tool): Add new feature");
assert!(result.is_err(), "should reject uppercase start");
}
#[test]
fn rejects_trailing_period() {
let result = validate_commit_message("feat(tool): add new feature.");
assert!(result.is_err(), "should reject trailing period");
}
#[test]
fn rejects_invalid_type() {
let result = validate_commit_message("wat(tool): something broke");
assert!(result.is_err(), "should reject invalid type");
}
#[test]
fn parses_valid_commit() {
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);
assert_eq!(parsed.description, "add parallel execution");
assert!(parsed.body.unwrap().contains("cycle orchestration"));
}
#[test]
fn parses_breaking_change() {
let parsed = parse_commit_message("feat(api)!: change response format").unwrap();
assert!(parsed.breaking);
}
#[test]
fn suggests_template() {
let tpl = suggest_template("feat", Some("tool"));
assert_eq!(tpl, "feat(tool): <imperative description>");
}
}
@@ -1,141 +0,0 @@
//! `explore_codebase` tool — lazy, agent-initiated codebase exploration.
//!
//! The main agent decides (via the system prompt) when it needs codebase
//! context. Unlike the old mandatory explore phase (which ran 3 subagents on
//! every turn regardless of the question), this tool is invoked only when the
//! agent judges it necessary — saving tokens on trivial queries while keeping
//! context available for complex tasks.
//!
//! # Flow
//!
//! `ExploreCodebase::run` →
//! 1. Parse the user's goal / target from args.
//! 2. Resolve subagent provider credentials from settings.
//! 3. Spawn a single "context scout" subagent (read-only, semantic_search +
//! read of up to 3 relevant files).
//! 4. Join the result and return a concise bullet summary as a tool message.
use anyhow::Result;
use serde_json::{json, Value};
use tracing::{info, warn};
use zesdex_domain::agent::prompt::explore_scout_directive;
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
use zesdex_domain::core::Store;
use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository};
use crate::subagent::context::SubagentContext;
use crate::subagent::division::AccessTier;
use crate::subagent::engine::run_agent;
use crate::tools::{Tool, ToolCtx};
/// Maximum characters of the scout's final output to keep in context.
/// The scout is directed to stay under 1500 chars, but this ceiling protects
/// against rogue output.
const EXPLORE_OUTPUT_MAX_CHARS: usize = 4000;
/// `explore_codebase` tool — ask a read-only context-scout subagent to
/// locate relevant code for the current task.
pub struct ExploreCodebase;
impl Tool for ExploreCodebase {
fn name(&self) -> &'static str {
"explore_codebase"
}
fn description(&self) -> &'static str {
"Explore the codebase to locate code relevant to a task. Use this \
once at the start of complex or unfamiliar tasks (implementing a \
feature, fixing a bug, refactoring, navigating a large repo). \
Do NOT use for simple factual questions about the current \
conversation."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": "The task or question to explore for"
}
},
"required": ["goal"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let goal = args
.get("goal")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if goal.is_empty() {
return Err(anyhow::anyhow!("missing non-empty 'goal'"));
}
info!("explore_codebase: {goal}");
let store = Store::new();
let settings = JsonSettingsRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let app_config = JsonAppConfigRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let (provider, model) =
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
let base_url = app_config
.providers
.get(&provider)
.map(|p| p.api_base.clone())
.unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
let workspace_root = ctx
.workspaces
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
// One lightweight scout — no parallel agents, no index rebuild.
let directive = format!(
"{}\n\nUser's task: {goal}\nWorkspace root: {workspace_root}",
explore_scout_directive()
);
let subagent_ctx = SubagentContext::new(
directive.clone(),
ctx.clone(),
"read".to_string(),
base_url,
api_key,
model,
);
let rt = crate::runtime::runtime();
let result = rt.block_on(run_agent(
subagent_ctx,
&directive,
AccessTier::Read,
ctx.clone(),
))?;
let mut out = format!("[Codebase scout report]\n{goal}\n\n----------\n{}", result);
if out.len() > EXPLORE_OUTPUT_MAX_CHARS {
warn!(
"explore_codebase output truncated: {} chars -> {}",
out.len(),
EXPLORE_OUTPUT_MAX_CHARS
);
out.truncate(EXPLORE_OUTPUT_MAX_CHARS);
out.push_str("\n...[truncated]");
}
Ok(out)
}
}
@@ -1,186 +0,0 @@
//! # Built-in Kana Engineering Best Practices Engine
//!
//! This module provides compile-time embedded skills, architecture audit,
//! code-quality scanning, and commit-message validation as **built-in**
//! features of the zesdex binary — not external configuration files.
//!
//! ## Modules
//!
//! | Module | Purpose |
//! |--------|---------|
//! | [`skills`] | Compile-time embedded skill markdown files via `include_dir!` |
//! | [`arch_audit`] | Clean-architecture layering violation scanner |
//! | [`code_quality`] | Clean-code rule checker (unwrap, missing docs, etc.) |
//! | [`commit`] | Conventional Commits message validator |
//!
//! ## Usage
//!
//! The [`BestPracticeEngine`] struct provides a unified API that tools call.
//! New tool implementations in `crate::tools::best_practice` delegate to this
//! engine.
pub mod arch_audit;
pub mod code_quality;
pub mod commit;
pub mod explore;
pub mod skills;
use anyhow::Result;
use std::path::Path;
/// Unified engine for all best-practice operations.
///
/// Wraps the individual audit, quality, and commit modules behind a single
/// struct that tools and subagents can call without knowing the module layout.
#[derive(Default)]
pub struct BestPracticeEngine {
/// Compile-time embedded skills, lazy-loaded.
embedded_skills: std::sync::OnceLock<skills::EmbeddedSkills>,
}
impl BestPracticeEngine {
/// Create a new engine (skills index is built lazily on first access).
pub fn new() -> Self {
BestPracticeEngine {
embedded_skills: std::sync::OnceLock::new(),
}
}
// ── Skills ─────────────────────────────────────────────────────────
/// Access the embedded skills index.
pub fn skills(&self) -> &skills::EmbeddedSkills {
self.embedded_skills
.get_or_init(skills::EmbeddedSkills::load)
}
/// List all available embedded-skills names.
pub fn list_skills(&self) -> Vec<&'static str> {
self.skills().list()
}
/// Get the full content of a skill by name.
pub fn get_skill(&self, name: &str) -> Option<&'static str> {
self.skills().get(name)
}
/// Get skill summaries (name + description).
pub fn skill_summaries(&self) -> Vec<(&'static str, &'static str)> {
self.skills().summaries()
}
// ── Architecture Audit ─────────────────────────────────────────────
/// Run a full architecture layering audit on a workspace directory.
pub fn audit_layering(&self, workspace_root: &Path) -> Result<arch_audit::AuditReport> {
arch_audit::audit_layering(workspace_root)
}
// ── Code Quality ───────────────────────────────────────────────────
/// Run a full code-quality scan on a workspace directory.
pub fn scan_quality(&self, workspace_root: &Path) -> Result<code_quality::CodeQualityReport> {
code_quality::scan_quality(workspace_root)
}
// ── Commit Convention ──────────────────────────────────────────────
/// Validate a commit message against Conventional Commits.
pub fn validate_commit(&self, message: &str) -> Result<(), Vec<String>> {
commit::validate_commit_message(message)
}
/// Parse a commit message into its structured fields.
pub fn parse_commit(&self, message: &str) -> Option<commit::CommitInfo> {
commit::parse_commit_message(message)
}
/// Get a commit template suggestion.
pub fn suggest_commit_template(&self, type_: &str, scope: Option<&str>) -> String {
commit::suggest_template(type_, scope)
}
// ── Combined Audit ─────────────────────────────────────────────────
/// Run all audits (layering + code quality) and return combined results.
pub fn audit_all(&self, workspace_root: &Path) -> Result<CombinedAuditReport> {
let layering = self.audit_layering(workspace_root)?;
let quality = self.scan_quality(workspace_root)?;
Ok(CombinedAuditReport { layering, quality })
}
}
/// Combined report from all audits.
#[derive(Debug, Clone)]
pub struct CombinedAuditReport {
pub layering: arch_audit::AuditReport,
pub quality: code_quality::CodeQualityReport,
}
impl CombinedAuditReport {
/// Format the full report as a human-readable string.
pub fn format(&self) -> String {
let mut out = String::new();
// ── Layering ──
out.push_str(&format!(
"=== Architecture Layering Audit ===\n\
Files scanned: {}\n",
self.layering.files_scanned
));
if self.layering.violations.is_empty() {
out.push_str(" ✅ No layering violations found.\n");
} else {
for v in &self.layering.violations {
out.push_str(&format!(
" [{}] {}:{} — {}\n",
v.severity, v.file, v.line, v.message
));
}
}
// ── Code Quality ──
out.push_str(&format!(
"\n=== Code Quality Scan ===\n\
Files scanned: {}\n",
self.quality.files_scanned
));
if self.quality.findings.is_empty() {
out.push_str(" ✅ No code-quality issues found.\n");
} else {
let by_rule = self.quality.count_by_rule();
out.push_str(" By rule:\n");
for (rule, count) in &by_rule {
out.push_str(&format!(" {rule}: {count}\n"));
}
out.push_str(" Top findings:\n");
for f in self.quality.findings.iter().take(20) {
out.push_str(&format!(
" [{}] {}:{} — {}: {}\n",
f.severity, f.file, f.line, f.rule, f.message
));
}
if self.quality.findings.len() > 20 {
out.push_str(&format!(
" ... and {} more findings.\n",
self.quality.findings.len() - 20
));
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_lists_skills() {
let engine = BestPracticeEngine::new();
let names = engine.list_skills();
assert!(names.contains(&"clean-code"));
}
}
@@ -1,152 +0,0 @@
//! Embedding Kana Engineering best-practice skills as compiled-in static assets.
//!
//! Skill markdown files from `apps/infrastructure/skills/` are embedded at
//! compile time via `include_dir!` and served through [`EmbeddedSkills`].
//!
//! # Flow
//!
//! `EmbeddedSkills::load()` reads the compile-time directory tree → indexes
//! every `SKILL.md` by its parent directory name → exposes lookup / listing.
use include_dir::{include_dir, Dir, DirEntry};
use std::collections::HashMap;
/// The compile-time-embedded skills directory tree.
static SKILLS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills");
/// Index of embedded best-practice skills, built once at startup.
///
/// Each skill is identified by its directory name (e.g. `"clean-code"`) and
/// its content is the full text of `SKILL.md` within that directory.
pub struct EmbeddedSkills {
skills: HashMap<&'static str, &'static str>,
}
impl EmbeddedSkills {
/// Walk the embedded directory tree and build the skill index.
///
/// Flow: iterate `Dir::entries()` → find every `SKILL.md` file →
/// store `(parent_dir_name, file_contents)` in the map.
pub fn load() -> Self {
let mut skills: HashMap<&str, &str> = HashMap::new();
fn walk<'a>(dir: &Dir<'a>, skills: &mut HashMap<&'a str, &'a str>) {
for entry in dir.entries() {
match entry {
DirEntry::Dir(sub) => walk(sub, skills),
DirEntry::File(file) => {
if file.path().file_name().is_some_and(|n| n == "SKILL.md") {
if let Some(parent) = file
.path()
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
{
if let Some(content) = file.contents_utf8() {
skills.entry(parent).or_insert(content);
}
}
}
}
}
}
}
walk(&SKILLS_DIR, &mut skills);
EmbeddedSkills { skills }
}
/// Return the full `SKILL.md` content for a named skill, if present.
pub fn get(&self, name: &str) -> Option<&'static str> {
self.skills.get(name).copied()
}
/// List all available skill names.
pub fn list(&self) -> Vec<&'static str> {
let mut names: Vec<&str> = self.skills.keys().copied().collect();
names.sort();
names
}
/// Return an iterator of `(name, trimmed_description)` pairs.
///
/// Description is extracted from the YAML frontmatter `description:` field.
pub fn summaries(&self) -> Vec<(&'static str, &'static str)> {
let mut out: Vec<(&str, &str)> = Vec::new();
for name in self.list() {
let content = self.skills.get(name).copied().unwrap_or("");
let desc = Self::extract_description(content).unwrap_or("");
out.push((name, desc));
}
out
}
/// Extract the `description:` field from YAML frontmatter.
fn extract_description(content: &str) -> Option<&str> {
let lines: Vec<&str> = content.lines().collect();
if lines.first()? != &"---" {
return None;
}
for line in &lines[1..] {
if let Some(rest) = line.strip_prefix("description: ") {
return Some(rest.trim());
}
if line == &"---" {
break;
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_skills_loads_known_skills() {
let skills = EmbeddedSkills::load();
let names = skills.list();
assert!(
names.contains(&"clean-code"),
"expected clean-code skill, got {names:?}"
);
assert!(
names.contains(&"commit-convention"),
"expected commit-convention skill, got {names:?}"
);
assert!(
names.contains(&"kana-rust-backend-best-practice"),
"expected kana-rust-backend-best-practice skill, got {names:?}"
);
}
#[test]
fn each_skill_has_content() {
let skills = EmbeddedSkills::load();
for name in skills.list() {
let content = skills.get(name);
assert!(content.is_some(), "skill '{name}' has no content");
assert!(
content.unwrap().len() > 50,
"skill '{name}' content suspiciously short"
);
}
}
#[test]
fn unknown_skill_returns_none() {
let skills = EmbeddedSkills::load();
assert!(skills.get("nonexistent-skill").is_none());
}
#[test]
fn summaries_include_all_skills() {
let skills = EmbeddedSkills::load();
let summaries = skills.summaries();
assert_eq!(summaries.len(), skills.list().len());
for (name, desc) in &summaries {
assert!(!desc.is_empty(), "skill '{name}' has empty description");
}
}
}
-78
View File
@@ -1,78 +0,0 @@
//! Background bash control — list, cancel, and inspect background processes.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use tracing::error;
use super::job::BashJob;
/// Global accessor for the shared BashControl singleton.
///
/// Used by the Bash tool (to register jobs) and BashKill (to look them up).
pub fn bash_control() -> &'static BashControl {
static BASH_CONTROL: OnceLock<BashControl> = OnceLock::new();
BASH_CONTROL.get_or_init(BashControl::new)
}
/// Central registry of all running background bash jobs.
pub struct BashControl {
jobs: Mutex<HashMap<String, Arc<BashJob>>>,
}
impl BashControl {
pub fn new() -> Self {
BashControl {
jobs: Mutex::new(HashMap::new()),
}
}
}
impl Default for BashControl {
fn default() -> Self {
Self::new()
}
}
impl BashControl {
/// Register a new background job.
pub fn register(&self, job: Arc<BashJob>) {
if let Ok(mut guard) = self.jobs.lock() {
guard.insert(job.id.clone(), job);
}
}
/// Cancel a job by ID.
pub fn cancel(&self, id: &str) -> bool {
if let Ok(mut guard) = self.jobs.lock() {
if let Some(job) = guard.remove(id) {
job.cancel();
return true;
}
}
false
}
/// List all active jobs.
pub fn list(&self) -> Vec<(String, String, bool)> {
let mut guard = match self.jobs.lock() {
Ok(g) => g,
Err(poisoned) => {
error!("bgbash jobs mutex poisoned, recovering");
poisoned.into_inner()
}
};
guard.retain(|_, j| j.is_running());
guard
.iter()
.map(|(id, job)| (id.clone(), job.command.clone(), job.is_running()))
.collect()
}
/// Clean up completed jobs.
pub fn prune(&self) {
if let Ok(mut guard) = self.jobs.lock() {
guard.retain(|_, j| j.is_running());
}
}
}
-96
View File
@@ -1,96 +0,0 @@
//! Background bash job — spawns a `bash -c` subprocess and tracks its life.
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing::error;
/// A handle to a spawned background bash job.
pub struct BashJob {
pub id: String,
pub command: String,
pub process: Mutex<Option<Child>>,
pub cancelled: AtomicBool,
}
/// Spawn a background bash job and return a handle.
///
/// The job runs until completion or until `cancel()` is called.
pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
let child = Command::new("bash")
.arg("-c")
.arg(&cmd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok();
let job = Arc::new(BashJob {
id: uuid::Uuid::new_v4().to_string(),
command: cmd,
process: Mutex::new(child),
cancelled: AtomicBool::new(false),
});
// Spawn a monitor thread using try_wait() polling so the lock is never
// held across a blocking wait, allowing cancel() to acquire the lock.
let job_clone = Arc::clone(&job);
std::thread::spawn(move || {
loop {
let mut exited = false;
{
let mut guard = match job_clone.process.lock() {
Ok(g) => g,
Err(poisoned) => {
error!("bgbash job mutex poisoned, recovering");
poisoned.into_inner()
}
};
if let Some(ref mut child) = *guard {
match child.try_wait() {
Ok(Some(_)) => exited = true,
Ok(None) => {} // still running
Err(e) => {
error!("bgbash wait error: {e}");
exited = true;
}
}
} else {
exited = true; // no child process
}
} // lock is dropped here — cancel() can now acquire it
if exited || job_clone.cancelled.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
});
job
}
impl BashJob {
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
if let Ok(mut guard) = self.process.lock() {
if let Some(ref mut child) = *guard {
let _ = child.kill();
let _ = child.wait();
}
}
}
pub fn is_running(&self) -> bool {
if self.cancelled.load(Ordering::SeqCst) {
return false;
}
let Ok(mut guard) = self.process.lock() else {
return false;
};
guard
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(None)))
}
}
-5
View File
@@ -1,5 +0,0 @@
//! Background bash job management — spawn, track, and query long-running
//! shell processes.
pub mod control;
pub mod job;
-3
View File
@@ -1,3 +0,0 @@
//! Tool gate — per-tool access control and permissions.
pub mod patterns;
-35
View File
@@ -1,35 +0,0 @@
//! Tool usage patterns — detect dangerous or suspicious tool invocations.
/// Check whether a tool invocation matches a known dangerous pattern.
///
/// Returns a description of the risk if the pattern matches, or `None`
/// if the invocation appears safe.
pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> {
match tool_name {
"bash" => {
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
// Detect git push with --force
if cmd.contains("git push") && cmd.contains("--force") {
return Some(
"Force-pushing to git is destructive and may lose history".to_string(),
);
}
// Detect rm -rf /
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
return Some(
"Recursive deletion of the root filesystem is never allowed".to_string(),
);
}
}
"delete" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
if path == "/" || path.starts_with("/etc") {
return Some(format!("Deleting '{}' is too dangerous", path));
}
}
_ => {
tracing::debug!("no guard pattern registered for tool: {tool_name}");
}
}
None
}
-36
View File
@@ -1,36 +0,0 @@
//! IPC client — connects to the daemon's Unix socket and sends/receives
//! framed JSON messages.
use std::os::unix::net::UnixStream;
use std::sync::Mutex;
/// A thread-safe IPC client connected to a Zesdex daemon over a Unix socket.
pub struct IpcClient {
conn: Mutex<crate::ipc::conn::Connection>,
}
impl IpcClient {
pub fn connect_unix(path: &str) -> anyhow::Result<Self> {
let stream = UnixStream::connect(path)?;
let conn = crate::ipc::conn::Connection::new(stream)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn send<T: serde::Serialize>(&self, msg: &T) -> anyhow::Result<()> {
let mut guard = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("IpcClient mutex poisoned: {e}"))?;
guard.send(msg)
}
pub fn receive<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<Option<T>> {
let mut guard = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("IpcClient mutex poisoned: {e}"))?;
guard.receive()
}
}
-41
View File
@@ -1,41 +0,0 @@
//! Connection wrapper around a Unix socket stream,
//! pairing a buffered reader with a raw writer.
use anyhow::Context;
use std::io::BufReader;
use std::os::unix::net::UnixStream;
/// A framed JSON connection over a Unix socket.
pub struct Connection {
reader: BufReader<UnixStream>,
writer: UnixStream,
}
impl Connection {
pub fn new(stream: UnixStream) -> anyhow::Result<Self> {
let reader = BufReader::new(
stream
.try_clone()
.context("failed to clone Unix stream for IPC reader")?,
);
let writer = stream;
Ok(Self { reader, writer })
}
pub fn send<T: serde::Serialize>(&mut self, msg: &T) -> anyhow::Result<()> {
let json = serde_json::to_vec(msg)?;
crate::ipc::frame::write_frame(&mut self.writer, &json)?;
Ok(())
}
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> anyhow::Result<Option<T>> {
let raw = crate::ipc::frame::read_frame(&mut self.reader)?;
match raw {
None => Ok(None),
Some(bytes) => {
let msg: T = serde_json::from_slice(&bytes)?;
Ok(Some(msg))
}
}
}
}
-51
View File
@@ -1,51 +0,0 @@
//! Length-prefixed framing for Unix-socket IPC.
//!
//! Every message on the wire is encoded as:
//! ```text
//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
//! ```
use anyhow::Context;
use std::io::{Read, Write};
const MAX_PAYLOAD: u32 = 64 * 1024 * 1024;
/// Read one length-prefixed frame from `reader`.
pub fn read_frame(reader: &mut impl Read) -> anyhow::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
match reader.read_exact(&mut len_buf) {
Ok(()) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(None);
}
Err(e) => return Err(e).context("failed to read frame length prefix"),
}
let payload_len = u32::from_be_bytes(len_buf) as usize;
if payload_len > MAX_PAYLOAD as usize {
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
}
let mut payload = vec![0u8; payload_len];
reader.read_exact(&mut payload)?;
Ok(Some(payload))
}
/// Write one length-prefixed frame to `writer`.
pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> anyhow::Result<()> {
let payload_len: u32 = data.len().try_into()?;
if payload_len > MAX_PAYLOAD {
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
}
let len_bytes = payload_len.to_be_bytes();
writer.write_all(&len_bytes)?;
writer.write_all(data)?;
writer.flush()?;
Ok(())
}
-7
View File
@@ -1,7 +0,0 @@
//! Unix-socket IPC layer for daemon/client communication.
pub mod client;
pub mod conn;
pub mod frame;
pub mod protocol;
pub mod server;
-82
View File
@@ -1,82 +0,0 @@
//! Wire types for the Zesdex IPC protocol.
use serde::{Deserialize, Serialize};
/// A resolved key press sent from the daemon to the client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum KeyAction {
Char(char),
Enter,
Escape,
Backspace,
Delete,
Tab,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Function(u8),
}
/// A message sent from the TUI client to the daemon over the IPC socket.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientRequest {
Tick,
KeyPress {
key: KeyAction,
ctrl: bool,
alt: bool,
shift: bool,
},
Submit(String),
Paste(String),
Resize(u16, u16),
Close,
ScrollUp,
ScrollDown,
}
/// A single chat message within a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEntry {
pub role: String,
pub content: String,
pub timestamp: i64,
}
/// A transient toast notification sent to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastEntry {
pub kind: String,
pub message: String,
pub created_at: i64,
pub lifetime_ms: u64,
}
/// Full UI state snapshot pushed from the daemon to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatePayload {
pub session_id: String,
pub messages: Vec<MessageEntry>,
pub edit_count: u32,
pub message_count: usize,
pub overlay: Option<String>,
pub toasts: Vec<ToastEntry>,
pub dirty: bool,
pub input_buffer: String,
pub input_cursor: usize,
}
/// A frame sent from the daemon to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DaemonFrame {
StateUpdate(Box<StatePayload>),
StreamToken(String),
SystemNote { kind: String, message: String },
ClipboardCopy(String),
Closed,
}
-26
View File
@@ -1,26 +0,0 @@
//! IPC server — binds a Unix socket and accepts incoming client connections.
use std::os::unix::net::UnixListener;
use std::path::Path;
/// A Unix-socket IPC server.
pub struct IpcServer {
listener: UnixListener,
}
impl IpcServer {
pub fn bind_unix(path: &str) -> anyhow::Result<Self> {
let p = Path::new(path);
if p.exists() {
std::fs::remove_file(p)?;
}
let listener = UnixListener::bind(path)?;
Ok(Self { listener })
}
pub fn accept(&self) -> anyhow::Result<crate::ipc::conn::Connection> {
let (stream, _addr) = self.listener.accept()?;
crate::ipc::conn::Connection::new(stream)
}
}
-138
View File
@@ -1,138 +0,0 @@
//! # Zesdex Infrastructure Layer
//!
//! ALL concrete implementations of domain repository traits, application port
//! traits, and platform services. This is the outermost ring of the Clean
//! Architecture onion — it depends on `zesdex-domain` and `zesdex-application`
//! but NEVER on interface/presentation crates.
//!
//! ## Architecture
//!
//! ```text
//! src/
//! ├── lib.rs — Foundational types + re-exports
//! ├── utils.rs — CastOr, write_json_atomic, slugify
//! ├── persistence/ — Repository implementations (IAM, CMS, SQLite)
//! ├── auth/ — JWT, Argon2, OAuth loopback
//! ├── llm/ — LLM provider HTTP client
//! ├── ipc/ — Unix-socket IPC protocol
//! ├── mcp/ — Model Context Protocol bridge
//! ├── bgbash/ — Background bash job management
//! ├── tools/ — All 37 agent-invocable tools
//! ├── subagent/ — Subagent spawning & execution engine
//! ├── workflow/ — Hive-mind orchestration engine
//! ├── review/ — Post-edit auto-review subagent
//! ├── guard/ — Tool-gate access control
//! └── middleware/ — Axum HTTP middleware (auth, cors, rate-limit)
//! ```
pub mod auth;
pub mod best_practice;
pub mod bgbash;
pub mod guard;
pub mod ipc;
pub mod llm;
pub mod mcp;
pub mod middleware;
pub mod persistence;
pub mod runtime;
pub mod subagent;
pub mod tools;
pub mod utils;
pub mod workflow;
// ---------------------------------------------------------------------------
// Re-exports from domain
// ---------------------------------------------------------------------------
pub use zesdex_domain::*;
// ---------------------------------------------------------------------------
// Foundation types — these replace `crate::app::state::*` references
// from the legacy backend code.
// ---------------------------------------------------------------------------
use std::path::PathBuf;
use std::sync::Arc;
// Which kind of caller (main agent vs. subagent vs. reviewer) is
// invoking a tool, used to scope permissions and tag log/output paths.
// ---------------------------------------------------------------------------
// TurnEvent & runtime types have been moved to zesdex_domain::agent
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Tool types — needed by all tool modules
// ---------------------------------------------------------------------------
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
// Re-export commonly needed types at the crate root
pub use zesdex_domain::core::{ChatMessage, Role, Store, ToolCallResult, UsageStats};
/// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame.
#[derive(Clone)]
pub struct DirCache {
entries: Arc<tokio::sync::RwLock<Vec<PathBuf>>>,
}
impl DirCache {
/// Create an empty directory cache.
pub fn new() -> Self {
DirCache {
entries: Arc::new(tokio::sync::RwLock::new(Vec::new())),
}
}
/// Replace the cached directory entries.
pub async fn set(&self, paths: Vec<PathBuf>) {
let mut w = self.entries.write().await;
*w = paths;
}
}
impl Default for DirCache {
fn default() -> Self {
Self::new()
}
}
/// A shared, whole-workspace file-path index used for `@file` mention
/// autocomplete.
#[derive(Clone)]
pub struct MentionIndex {
entries: Arc<std::sync::RwLock<Vec<String>>>,
}
impl MentionIndex {
/// Create an empty mention index.
pub fn new() -> Self {
MentionIndex {
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
}
}
/// Replace the entire index with a new set of file paths.
pub fn set(&self, paths: Vec<String>) {
if let Ok(mut w) = self.entries.write() {
*w = paths;
}
}
/// Append a single file path to the index.
pub fn push(&self, path: String) {
if let Ok(mut w) = self.entries.write() {
w.push(path);
}
}
/// Return a copy of all indexed paths.
pub fn snapshot(&self) -> Vec<String> {
self.entries.read().map(|r| r.clone()).unwrap_or_default()
}
}
impl Default for MentionIndex {
fn default() -> Self {
Self::new()
}
}
-5
View File
@@ -1,5 +0,0 @@
//! LLM provider HTTP client for OpenAI/Anthropic-compatible chat completion APIs.
pub mod provider;
pub use provider::{resolve_api_key, LlmClient};
-439
View File
@@ -1,439 +0,0 @@
//! Async HTTP client for OpenAI/Anthropic-compatible chat completion APIs,
//! supporting both non-streaming and SSE-streaming requests with automatic retry.
use rand_core::RngCore;
use std::time::Duration;
use zesdex_application::ports::ProviderService;
use zesdex_domain::core::{
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
};
use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL};
const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE;
pub const DEFAULT_API_KEY: &str = "";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
// ---------------------------------------------------------------------------
// Retry helpers
// ---------------------------------------------------------------------------
fn backoff_seconds(attempt: u32, cap: u64) -> Duration {
let base = 2u64.pow(attempt.saturating_sub(1));
let delay = std::cmp::min(base, cap);
let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0;
Duration::from_secs_f64(delay as f64 * jitter_factor)
}
pub fn is_auth_error(err_str: &str) -> bool {
let err_lower = err_str.to_lowercase();
(err_str.contains("API error 401")
|| err_str.contains("API error 402")
|| err_str.contains("API error 403"))
|| err_lower.contains("unauthorized")
|| err_lower.contains("forbidden")
|| err_lower.contains("authentication failed")
}
fn is_rate_limit(err_str: &str) -> bool {
err_str.contains("API error 429") || err_str.to_lowercase().contains("rate limit")
}
fn backoff_for_error(attempt: u32, err_str: &str) -> Duration {
if is_rate_limit(err_str) {
backoff_seconds(attempt, 60)
} else {
backoff_seconds(attempt, 30)
}
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
/// Async HTTP client for a single LLM provider endpoint.
pub struct LlmClient {
pub client: reqwest::Client,
pub api_key: String,
pub base_url: String,
pub model: String,
}
impl LlmClient {
pub fn new(mut api_key: String, model: String, base_url: Option<String>) -> Self {
if api_key.is_empty() {
api_key = DEFAULT_API_KEY.to_string();
}
let model = if model.is_empty() {
DEFAULT_MODEL.to_string()
} else {
model
};
let client = match reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(
"failed to build reqwest client with connect timeout: {}. \
retrying without connect timeout",
e,
);
match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() {
Ok(c) => c,
Err(e2) => {
tracing::warn!("also failed: {e2}. using default client");
reqwest::Client::new()
}
}
}
};
LlmClient {
client,
api_key,
base_url: base_url
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
model,
}
}
async fn try_stream_once(
&self,
req: &ChatRequest,
url: &str,
on_event: &mut (dyn FnMut(&StreamEvent) -> bool + Send),
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
let mut http_req = self
.client
.post(url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let mut resp = http_req.json(req).send().await.map_err(|e| {
if e.is_timeout() {
anyhow::anyhow!(
"API request timed out after {REQUEST_TIMEOUT:?}. \
Check your network or try again."
)
} else if e.is_connect() {
anyhow::anyhow!(
"Could not connect to {}. \
Is the URL correct and is the service reachable?",
self.base_url
)
} else {
anyhow::anyhow!("API request failed: {e}")
}
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
}
struct StreamedTurn {
content: String,
tool_calls: Vec<zesdex_domain::core::ToolCall>,
done_received: bool,
}
impl StreamedTurn {
fn new() -> Self {
StreamedTurn {
content: String::new(),
tool_calls: Vec::new(),
done_received: false,
}
}
fn apply_event(&mut self, event: &StreamEvent) {
match event {
StreamEvent::Token(t) => self.content.push_str(t),
StreamEvent::Reasoning(_) => {}
StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta,
} => {
const MAX_TOOL_CALLS: usize = 64;
let index = usize::min(*index, MAX_TOOL_CALLS.saturating_sub(1));
while self.tool_calls.len() <= index {
self.tool_calls.push(zesdex_domain::core::ToolCall {
id: String::new(),
type_: "function".to_string(),
function: zesdex_domain::core::ToolFunction {
name: String::new(),
arguments: serde_json::Value::String(String::new()),
},
});
}
let tc = &mut self.tool_calls[index];
if let Some(ref id_val) = id {
tc.id = id_val.clone();
}
if let Some(ref name_val) = name {
tc.function.name = name_val.clone();
}
if let serde_json::Value::String(ref mut args) = tc.function.arguments {
args.push_str(arguments_delta);
}
}
_ => {
tracing::debug!("unhandled stream event in apply_event: {event:?}");
}
}
}
fn build_assistant_message(self) -> ChatMessage {
ChatMessage {
role: zesdex_domain::core::Role::Assistant,
content: if self.content.is_empty() {
None
} else {
Some(self.content)
},
tool_calls: if self.tool_calls.is_empty() {
None
} else {
Some(self.tool_calls)
},
tool_call_id: None,
name: None,
}
}
}
let mut turn = StreamedTurn::new();
let mut usage: Option<(u64, u64)> = None;
let mut parser = SseParser::new();
let mut byte_buf: Vec<u8> = Vec::new();
while let Some(chunk) = resp.chunk().await? {
byte_buf.extend_from_slice(&chunk);
let valid_len = match std::str::from_utf8(&byte_buf) {
Ok(s) => s.len(),
Err(e) => {
let n = e.valid_up_to();
if n == 0 {
byte_buf.drain(..1);
continue;
}
n
}
};
let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
byte_buf.drain(..valid_len);
for event in parser.feed(&text) {
if !on_event(&event) {
anyhow::bail!("aborted");
}
match &event {
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
..
} => {
usage = Some((*prompt_tokens, *completion_tokens));
}
StreamEvent::Error(msg) => {
anyhow::bail!("stream error: {msg}");
}
StreamEvent::Done => {
turn.apply_event(&event);
turn.done_received = true;
return Ok((turn.build_assistant_message(), usage));
}
other => {
turn.apply_event(other);
}
}
}
}
Ok((turn.build_assistant_message(), usage))
}
}
impl ProviderService for LlmClient {
async fn chat(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
max_tokens: Option<u32>,
temperature: Option<f32>,
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
let req = ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(max_tokens.unwrap_or(4096)),
temperature: Some(temperature.unwrap_or(0.7)),
tools,
stream: Some(false),
stop: None,
stream_options: None,
tool_choice: None,
top_p: None,
};
let url = format!("{}/chat/completions", self.base_url);
let max_retries = 10;
let mut attempt = 0u32;
loop {
attempt += 1;
let mut http_req = self
.client
.post(&url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let result = async {
let resp = http_req.json(&req).send().await.map_err(|e| {
if e.is_timeout() {
anyhow::anyhow!(
"API request timed out after {REQUEST_TIMEOUT:?}. \
Check your network or try again."
)
} else if e.is_connect() {
anyhow::anyhow!(
"Could not connect to {}. \
Is the URL correct and is the service reachable?",
self.base_url
)
} else {
anyhow::anyhow!("API request failed: {e}")
}
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
}
let data: ChatResponse = resp.json().await?;
let usage = data
.usage
.map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)));
let message = data
.choices
.into_iter()
.next()
.and_then(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
Ok((message, usage))
}
.await;
match result {
Ok((msg, usage)) => return Ok((msg, usage)),
Err(e) => {
let err_str = e.to_string();
if attempt >= max_retries || is_auth_error(&err_str) {
return Err(e);
}
let delay = backoff_for_error(attempt, &err_str);
tokio::time::sleep(delay).await;
}
}
}
}
async fn chat_stream(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
max_tokens: Option<u32>,
temperature: Option<f32>,
mut on_event: Box<dyn FnMut(&StreamEvent) -> bool + Send>,
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
let req = ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(max_tokens.unwrap_or(4096)),
temperature: Some(temperature.unwrap_or(0.7)),
tools,
stream: Some(true),
stop: None,
stream_options: Some(StreamOptions {
include_usage: true,
}),
tool_choice: None,
top_p: None,
};
let url = format!("{}/chat/completions", self.base_url);
let max_retries_stream = 10;
let mut attempt = 0u32;
loop {
attempt += 1;
let mut captured_content = false;
let mut wrapped = |event: &StreamEvent| -> bool {
match event {
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) => {
let err_str = e.to_string();
if is_auth_error(&err_str) || captured_content {
return Err(e);
}
if attempt >= max_retries_stream {
return Err(e);
}
let delay = backoff_for_error(attempt, &err_str);
tokio::time::sleep(delay).await;
}
}
}
}
}
pub fn resolve_api_key(
settings: &zesdex_domain::cms::Settings,
app_config: &zesdex_domain::cms::AppConfig,
) -> String {
let provider = &settings.provider;
let mut api_key = settings.api_keys.get(provider).cloned().unwrap_or_default();
if api_key.is_empty() {
if let Some(provider_cfg) = app_config.providers.get(provider) {
api_key = provider_cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
.or_else(|| provider_cfg.default_api_key.clone())
.unwrap_or_default();
}
}
api_key
}
-67
View File
@@ -1,67 +0,0 @@
//! Manages MCP server connections — start, stop, list, and dispatch
//! tool calls to remote MCP servers.
use std::collections::HashMap;
/// Metadata for a connected MCP server.
#[derive(Debug, Clone)]
pub struct McpServerHandle {
pub name: String,
pub transport: String,
}
/// Manages MCP server connections.
#[derive(Clone)]
pub struct McpManager {
servers: HashMap<String, McpServerHandle>,
}
impl McpManager {
pub fn new() -> Self {
McpManager {
servers: HashMap::new(),
}
}
}
impl Default for McpManager {
fn default() -> Self {
Self::new()
}
}
impl McpManager {
/// Register an MCP server by name and transport string.
///
/// Returns an error if a server with the same name is already registered.
pub fn register(&mut self, name: &str, transport: &str) -> anyhow::Result<()> {
if self.servers.contains_key(name) {
anyhow::bail!("MCP server '{name}' is already registered");
}
self.servers.insert(
name.to_string(),
McpServerHandle {
name: name.to_string(),
transport: transport.to_string(),
},
);
Ok(())
}
/// Remove a registered MCP server and return its handle, if it existed.
pub fn unregister(&mut self, name: &str) -> Option<McpServerHandle> {
self.servers.remove(name)
}
pub fn list(&self) -> Vec<McpServerHandle> {
self.servers.values().cloned().collect()
}
pub fn get(&self, name: &str) -> Option<&McpServerHandle> {
self.servers.get(name)
}
pub fn is_empty(&self) -> bool {
self.servers.is_empty()
}
}
-5
View File
@@ -1,5 +0,0 @@
//! Model Context Protocol (MCP) — bridge between agent tools and external MCP
//! servers using the rmcp crate.
pub mod manager;
pub mod transport;
-97
View File
@@ -1,97 +0,0 @@
//! MCP transport layer — manages child-process and HTTP-based transport
//! for connecting to MCP servers.
use std::{
io::{Read, Write},
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
};
/// A running MCP server process connected via stdio.
///
/// Holds the child process handle plus the piped stdin/stdout streams
/// so callers can send JSON-RPC messages and read responses.
pub struct McpTransport {
process: Option<Child>,
stdin: Option<ChildStdin>,
stdout: Option<ChildStdout>,
}
impl McpTransport {
/// Spawn a child process as an MCP server over stdio.
///
/// The command is passed to `sh -c` so shell syntax (pipes, redirects, etc.)
/// works naturally. Stderr is discarded to avoid corrupting a TUI that may
/// be running in the same terminal.
pub fn start_child_process(name: &str, command: &str) -> anyhow::Result<Self> {
tracing::info!("starting MCP transport '{name}': {command}");
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let stdin = child.stdin.take();
let stdout = child.stdout.take();
Ok(McpTransport {
process: Some(child),
stdin,
stdout,
})
}
/// Write raw bytes to the child's stdin.
pub fn send(&mut self, data: &[u8]) -> anyhow::Result<()> {
if let Some(ref mut stdin) = self.stdin {
stdin.write_all(data)?;
stdin.flush()?;
}
Ok(())
}
/// Read from the child's stdout into the provided buffer.
///
/// Returns `Ok(Some(n))` with the number of bytes read,
/// `Ok(None)` on EOF, or `Err` on I/O errors.
pub fn receive(&mut self, buf: &mut [u8]) -> anyhow::Result<Option<usize>> {
match self.stdout.as_mut() {
Some(stdout) => match stdout.read(buf) {
Ok(0) => Ok(None),
Ok(n) => Ok(Some(n)),
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(e.into()),
},
None => Ok(None),
}
}
/// Gracefully shut down the child by closing stdin (sending EOF) and
/// then killing the process.
pub fn kill(&mut self) {
// Close stdin first to signal EOF to the MCP server.
let _ = self.stdin.take();
if let Some(ref mut child) = self.process {
let _ = child.kill();
let _ = child.wait();
}
}
/// Check whether the child process is still running.
pub fn is_running(&mut self) -> bool {
self.process
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(None)))
}
/// Stop the child process. This is the public API alias for `kill`.
pub fn stop(&mut self) -> anyhow::Result<()> {
self.kill();
Ok(())
}
}
impl Drop for McpTransport {
fn drop(&mut self) {
self.kill();
}
}
-124
View File
@@ -1,124 +0,0 @@
//! Authentication middleware — session-lock based auth for Axum.
//!
//! Validates `X-Session-Id` header against the `SessionRepository` before
//! forwarding the request to the inner service.
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tower::{Layer, Service};
use zesdex_domain::auth::{SessionId, SessionRepository};
/// Identity extracted from a validated session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionIdentity {
pub session_id: String,
pub user_agent: String,
pub connected_at: i64,
}
impl SessionIdentity {
pub fn new(session_id: String, user_agent: String) -> Self {
let connected_at = chrono::Utc::now().timestamp();
Self {
session_id,
user_agent,
connected_at,
}
}
}
/// Tower Layer that produces SessionAuthMiddleware services.
///
/// Holds a reference to the `SessionRepository` and the base directory
/// needed to validate session IDs.
#[derive(Debug, Clone)]
pub struct SessionAuthLayer<R: SessionRepository + Send + Sync + 'static> {
base_dir: PathBuf,
repo: Arc<R>,
}
impl<R: SessionRepository + Send + Sync + 'static> SessionAuthLayer<R> {
pub fn new(base_dir: PathBuf, repo: Arc<R>) -> Self {
Self { base_dir, repo }
}
}
impl<S, R> Layer<S> for SessionAuthLayer<R>
where
R: SessionRepository + Send + Sync + 'static,
{
type Service = SessionAuthMiddleware<S, R>;
fn layer(&self, inner: S) -> Self::Service {
SessionAuthMiddleware {
inner,
base_dir: self.base_dir.clone(),
repo: self.repo.clone(),
}
}
}
/// Tower Service that validates X-Session-Id before forwarding.
#[derive(Debug, Clone)]
pub struct SessionAuthMiddleware<S, R: SessionRepository + Send + Sync + 'static> {
inner: S,
base_dir: PathBuf,
repo: Arc<R>,
}
impl<S, ReqBody, R> Service<Request<ReqBody>> for SessionAuthMiddleware<S, R>
where
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
R: SessionRepository + Send + Sync + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
let session_id = req
.headers()
.get("X-Session-Id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Validate the session against the repository.
match session_id {
Some(sid) => match SessionId::new(&sid) {
Ok(id) => match self.repo.load_session(&self.base_dir, &id) {
Ok(_session) => {
// Session is valid — forward the request.
let fut = self.inner.call(req);
return Box::pin(fut);
}
Err(_) => { /* fall through to 401 */ }
},
Err(_) => { /* fall through to 401 */ }
},
None => { /* fall through to 401 */ }
}
Box::pin(async move {
Ok((
StatusCode::UNAUTHORIZED,
"missing or invalid X-Session-Id header",
)
.into_response())
})
}
}
@@ -1,4 +0,0 @@
//! Axum middleware tower for the HTTP API layer.
pub mod auth;
pub mod rate_limit;
@@ -1,63 +0,0 @@
//! Simple in-memory rate limiter for Axum.
use std::collections::HashMap;
use std::sync::Mutex;
/// In-memory sliding-window rate limiter.
#[derive(Debug)]
pub struct RateLimiter {
windows: Mutex<HashMap<String, Vec<i64>>>,
}
impl RateLimiter {
pub fn new() -> Self {
RateLimiter {
windows: Mutex::new(HashMap::new()),
}
}
pub fn check_rate_limit(
&self,
client_id: &str,
max_requests: u32,
window_secs: u64,
) -> anyhow::Result<bool> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let cutoff = now.saturating_sub(window_secs as i64);
let mut windows = self
.windows
.lock()
.map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?;
let timestamps = windows
.entry(client_id.to_string())
.or_insert_with(Vec::new);
timestamps.retain(|&ts| ts >= cutoff);
if timestamps.len() >= max_requests as usize {
return Ok(false);
}
timestamps.push(now);
Ok(true)
}
pub fn reset(&self) -> anyhow::Result<()> {
let mut windows = self
.windows
.lock()
.map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?;
windows.clear();
Ok(())
}
}
impl Default for RateLimiter {
fn default() -> Self {
Self::new()
}
}
@@ -1,254 +0,0 @@
//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection.
use std::path::Path;
use serde::{Deserialize, Serialize};
use zesdex_domain::cms::{
AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError,
};
use crate::utils::write_json_atomic;
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonAppConfigRepository;
impl JsonAppConfigRepository {
pub fn new() -> Self {
Self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeEnv {
#[serde(alias = "ANTHROPIC_BASE_URL")]
anthropic_base_url: Option<String>,
#[serde(alias = "ANTHROPIC_API_KEY")]
anthropic_api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSettings {
env: Option<ClaudeEnv>,
#[serde(alias = "customModel", alias = "model")]
custom_model: Option<String>,
}
fn claude_settings_from_file() -> Option<ClaudeSettings> {
let path = dirs::home_dir()?.join(".claude").join("settings.json");
let content = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option<String>)> {
let settings = claude_settings_from_file();
let file_creds = settings.as_ref().and_then(|s| {
let env = s.env.as_ref()?;
Some((
env.anthropic_base_url.clone()?,
env.anthropic_api_key.clone()?,
))
});
let env_creds = || -> Option<(String, String)> {
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, key))
};
let custom_model = settings.and_then(|s| s.custom_model);
let (base_url, key) = file_creds.or_else(env_creds)?;
Some((
ProviderConfig {
api_base: base_url,
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: custom_model.clone(),
default_api_key: Some(key),
},
custom_model,
))
}
/// Apply a detected Claude provider + custom model onto an `AppConfig`.
///
/// Pure (no I/O) so it can be unit-tested. Flow:
/// 1. Always `insert`s the "claude" provider (refreshing a possibly stale
/// persisted entry with the current base URL + key from settings.json).
/// 2. Registers known Claude model roles if missing.
/// 3. Always sets `default_provider = "claude"` and
/// `default_model = custom_model.unwrap_or("claude-opus-5")` so Opus
/// is the default whenever `~/.claude/settings.json` is present.
fn apply_claude_provider(
cfg: &mut AppConfig,
claude_provider: ProviderConfig,
custom_model: Option<String>,
) {
cfg.providers.insert("claude".to_string(), claude_provider);
let claude_models: [(&str, &str); 3] = [
("claude-opus-5", "claude-opus-5"),
("claude-sonnet-5", "claude-sonnet-5"),
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
];
for (role_name, model_name) in &claude_models {
cfg.model_roles
.entry(role_name.to_string())
.or_insert(ModelRole {
provider: "claude".to_string(),
model: model_name.to_string(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
if let Some(custom) = &custom_model {
cfg.model_roles.entry(custom.clone()).or_insert(ModelRole {
provider: "claude".to_string(),
model: custom.clone(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
// Always prefer the Claude provider + Opus model when settings.json
// is present — this is the user's explicit custom endpoint choice.
cfg.default_provider = "claude".to_string();
cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-5".to_string());
}
impl AppConfigRepository for JsonAppConfigRepository {
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
let path = base_dir.join("app_config.json");
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppConfig::default(),
Err(e) => return Err(RepositoryError::Io(e)),
};
let defaults = AppConfig::default();
for (name, provider) in defaults.providers {
cfg.providers.entry(name).or_insert(provider);
}
if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() {
apply_claude_provider(&mut cfg, claude_provider, custom_model);
}
Ok(cfg)
}
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
std::fs::create_dir_all(base_dir)?;
let path = base_dir.join("app_config.json");
write_json_atomic(&path, config, None)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn claude_provider(base: &str, key: Option<&str>) -> ProviderConfig {
ProviderConfig {
api_base: base.to_string(),
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: Some("claude-opus-5".to_string()),
default_api_key: key.map(|s| s.to_string()),
}
}
#[test]
fn claude_settings_parse_env() {
let parsed: ClaudeSettings = serde_json::from_str(
r#"{"env":{"ANTHROPIC_BASE_URL":"https://9router.example/v1","ANTHROPIC_API_KEY":"sk-test"}}"#,
)
.unwrap();
let env = parsed.env.unwrap();
assert_eq!(
env.anthropic_base_url.as_deref(),
Some("https://9router.example/v1")
);
assert_eq!(env.anthropic_api_key.as_deref(), Some("sk-test"));
}
#[test]
fn apply_claude_refreshes_stale_provider_and_sets_opus_default() {
// Simulate a previously-persisted app_config.json with a STALE claude
// provider + non-opus default (e.g. user had switched provider).
let mut cfg = AppConfig {
providers: {
let mut m = HashMap::new();
m.insert(
"claude".to_string(),
claude_provider("https://old.example/v1", Some("sk-old")),
);
m
},
model_roles: HashMap::new(),
default_provider: "router".to_string(),
default_model: "other-model".to_string(),
default_context_window: 256_000,
};
// Detect returned a fresh provider from ~/.claude/settings.json.
apply_claude_provider(
&mut cfg,
claude_provider("https://9router.example/v1", Some("sk-new")),
None,
);
let claude = cfg.providers.get("claude").unwrap();
assert_eq!(claude.api_base, "https://9router.example/v1");
assert_eq!(claude.default_api_key.as_deref(), Some("sk-new"));
// Insert (not or_insert) → stale entry refreshed.
assert_eq!(cfg.default_provider, "claude");
assert_eq!(cfg.default_model, "claude-opus-5");
// Claude model roles registered.
assert!(cfg.model_roles.contains_key("claude-opus-5"));
assert!(cfg.model_roles.contains_key("claude-sonnet-5"));
assert!(cfg.model_roles.contains_key("claude-haiku-4-5"));
}
#[test]
fn apply_claude_honors_custom_model_from_settings() {
let mut cfg = AppConfig::default();
apply_claude_provider(
&mut cfg,
claude_provider("https://9router.example/v1", Some("sk-new")),
Some("claude-opus-5".to_string()),
);
assert_eq!(cfg.default_model, "claude-opus-5");
assert!(cfg.model_roles.contains_key("claude-opus-5"));
}
#[test]
fn detect_uses_env_creds_as_fallback() {
// When ~/.claude/settings.json is absent/unreadable, the env-var
// fallback should produce a "claude" provider. Set env vars, call
// detect, and assert the resulting provider uses them.
std::env::set_var("ANTHROPIC_BASE_URL", "https://env.example/v1");
std::env::set_var("ANTHROPIC_API_KEY", "sk-env");
match detect_claude_settings_provider() {
Some((provider, _custom)) => {
// If the real settings.json exists it wins (base could be the
// real 9router URL); otherwise env creds are used. Either way,
// the provider must have api_key_env pointing at ANTHROPIC_API_KEY.
assert_eq!(provider.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
}
None => {
// No file + no env (shouldn't happen since we just set env).
panic!("expected env fallback to produce a provider");
}
}
std::env::remove_var("ANTHROPIC_BASE_URL");
std::env::remove_var("ANTHROPIC_API_KEY");
}
}
@@ -1,34 +0,0 @@
//! JSON file–backed `ConversationRepository`.
//! Stores `Conversation` at `<session_dir>/conversation.json`.
use std::path::Path;
use zesdex_domain::cms::{Conversation, ConversationRepository, RepositoryError};
use crate::utils::write_json_atomic;
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonConversationRepository;
impl JsonConversationRepository {
pub fn new() -> Self {
Self
}
}
impl ConversationRepository for JsonConversationRepository {
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError> {
let path = session_dir.join("conversation.json");
let data = std::fs::read_to_string(&path)?;
let conv: Conversation = serde_json::from_str(&data)?;
Ok(conv)
}
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> {
std::fs::create_dir_all(session_dir)?;
let path = session_dir.join("conversation.json");
write_json_atomic(&path, conversation, None)?;
Ok(())
}
}
@@ -1,88 +0,0 @@
//! JSONL file–backed `EditLogRepository`.
//! Stores `EditLog` as an append-only newline-delimited JSON file.
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use zesdex_domain::cms::{EditLog, EditLogEntry, EditLogRepository, RepositoryError};
/// Maximum number of edit entries held in memory at once.
const MAX_MEMORY_ENTRIES: usize = 10_000;
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
#[derive(Debug, Clone, Default)]
pub struct JsonlEditLogRepository;
impl JsonlEditLogRepository {
pub fn new() -> Self {
Self
}
fn load_from_disk(path: &Path) -> VecDeque<EditLogEntry> {
let Ok(file) = std::fs::File::open(path) else {
return VecDeque::new();
};
let reader = BufReader::new(file);
let mut entries: VecDeque<EditLogEntry> = VecDeque::new();
for line in reader.lines() {
let Ok(line) = line else {
continue;
};
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
if entries.len() >= MAX_MEMORY_ENTRIES {
entries.pop_front();
}
entries.push_back(entry);
}
}
entries
}
}
impl EditLogRepository for JsonlEditLogRepository {
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
let path = session_dir.join("edits.jsonl");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let entries = Self::load_from_disk(&path);
if !path.exists() {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
}
Ok(EditLog { entries })
}
fn append(
&self,
session_dir: &Path,
log: &mut EditLog,
entry: EditLogEntry,
) -> Result<(), RepositoryError> {
let path = session_dir.join("edits.jsonl");
let line = serde_json::to_string(&entry)? + "\n";
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(line.as_bytes())?;
file.sync_all()?;
}
log.entries.push_back(entry);
if log.entries.len() > MAX_MEMORY_ENTRIES {
log.entries.pop_front();
}
Ok(())
}
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
log.entries.clone().into_iter().collect()
}
}
@@ -1,209 +0,0 @@
//! Markdown file–backed `MemoryRepository`.
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError};
/// File-based `MemoryRepository` that stores memories as `.md` files with
/// YAML-ish frontmatter.
#[derive(Debug, Clone, Default)]
pub struct MarkdownMemoryRepository;
impl MarkdownMemoryRepository {
pub fn new() -> Self {
Self
}
/// Escape newlines in field values so they do not break the
/// line-oriented frontmatter parser.
fn escape_newlines(s: &str) -> String {
s.replace('\n', "\\n")
}
/// Unescape `\n` back to actual newlines after frontmatter parsing.
fn unescape_newlines(s: &str) -> String {
s.replace("\\n", "\n")
}
fn build_frontmatter(memory: &Memory) -> String {
let outcome_line = memory
.outcome
.as_ref()
.map(|o| format!("outcome: {}\n", Self::escape_newlines(o)))
.unwrap_or_default();
let scope_line = memory
.scope
.as_ref()
.map(|s| format!("scope: {}\n", Self::escape_newlines(s)))
.unwrap_or_default();
let before_line = memory
.before_snippet
.as_ref()
.map(|s| format!("before: {}\n", Self::escape_newlines(s)))
.unwrap_or_default();
let after_line = memory
.after_snippet
.as_ref()
.map(|s| format!("after: {}\n", Self::escape_newlines(s)))
.unwrap_or_default();
let prov_line = if memory.provenances.is_empty() {
String::new()
} else {
format!("provenances: {}\n", memory.provenances.join(", "))
};
format!(
"name: {name}\ndescription: {desc}\nkind: {kind}\n\
created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\
{outcome}{scope}{before}{after}{prov}",
name = memory.name,
desc = memory.description,
kind = memory.kind,
created = memory.created_at,
updated = memory.updated_at,
lifecycle = memory.lifecycle,
outcome = outcome_line,
scope = scope_line,
before = before_line,
after = after_line,
prov = prov_line,
)
}
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
front
.lines()
.filter_map(|l| {
let mut it = l.splitn(2, ':');
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string()))
})
.collect()
}
fn parse(content: &str) -> std::io::Result<Memory> {
let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
if parts.len() < 2 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"missing frontmatter",
));
}
let front = Self::parse_frontmatter(parts[0]);
let body = parts.get(1).unwrap_or(&"").trim().to_string();
Ok(Memory {
name: front.get("name").cloned().unwrap_or_default(),
description: front.get("description").cloned().unwrap_or_default(),
content: body,
kind: front
.get("kind")
.cloned()
.unwrap_or_else(|| "reference".to_string()),
created_at: front
.get("created_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
updated_at: front
.get("updated_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
outcome: front
.get("outcome")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
lifecycle: front
.get("lifecycle")
.cloned()
.unwrap_or_else(|| "new".to_string()),
scope: front
.get("scope")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
before_snippet: front
.get("before")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
after_snippet: front
.get("after")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
provenances: front
.get("provenances")
.cloned()
.map(|s| s.split(", ").map(String::from).collect())
.unwrap_or_default(),
})
}
}
impl MemoryRepository for MarkdownMemoryRepository {
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let Ok(entries) = std::fs::read_dir(memory_dir) else {
return Ok(Vec::new());
};
let slugs: Vec<String> = entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name == "MEMORY.md" {
return None;
}
name.strip_suffix(".md")
.map(std::string::ToString::to_string)
})
.collect();
Ok(slugs)
}
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
let path = Memory::path(memory_dir, name);
let content = std::fs::read_to_string(&path)?;
let memory = Self::parse(&content)
.map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?;
Ok(memory)
}
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, &memory.name);
let parent = path
.parent()
.expect("memory path always has a parent directory");
std::fs::create_dir_all(parent)?;
let frontmatter = Self::build_frontmatter(memory);
let content = format!("---\n{frontmatter}---\n\n{}", memory.content);
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
if let Some(p) = path.parent() {
if let Ok(d) = std::fs::File::open(p) {
let _ = d.sync_all();
}
}
Ok(())
}
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, name);
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
}
@@ -1,16 +0,0 @@
//! File-based repository implementations for CMS domain entities.
//!
//! ## Repositories
//! - `JsonSettingsRepository` — reads/writes `settings.json`
//! - `JsonAppConfigRepository` — reads/writes `app_config.json`
//! - `JsonConversationRepository` — reads/writes `conversation.json`
//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files
//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl`
//! - `FileRewindBlobRepository` — stores blobs as files
pub mod app_config_repo;
pub mod conversation_repo;
pub mod edit_log_repo;
pub mod memory_repo;
pub mod rewind_blob_repo;
pub mod settings_repo;
@@ -1,112 +0,0 @@
//! Filesystem-backed `RewindBlobRepository`.
//! Blob bytes are stored at `<session_dir>/blobs/<hex(key)>.bin`.
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use zesdex_domain::cms::{RepositoryError, RewindBlobRepository};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobIndexEntry {
key: String,
mime_type: Option<String>,
created_at: i64,
}
/// Concrete filesystem rewind-blob repository.
#[derive(Debug, Clone, Default)]
pub struct FileRewindBlobRepository;
impl FileRewindBlobRepository {
pub fn new() -> Self {
Self
}
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
session_dir.join("blobs")
}
fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
}
fn index_path(session_dir: &Path) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join("index.jsonl")
}
}
impl RewindBlobRepository for FileRewindBlobRepository {
fn store_blob(
&self,
session_dir: &Path,
blob_key: &str,
data: &[u8],
mime_type: Option<&str>,
) -> Result<(), RepositoryError> {
let blobs_dir = Self::blobs_dir(session_dir);
std::fs::create_dir_all(&blobs_dir)?;
let path = Self::blob_file_path(session_dir, blob_key);
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, &path)?;
let entry = BlobIndexEntry {
key: blob_key.to_string(),
mime_type: mime_type.map(String::from),
created_at: chrono::Utc::now().timestamp_millis(),
};
let index_path = Self::index_path(session_dir);
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)?;
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
f.sync_all()?;
Ok(())
}
fn retrieve_blob(
&self,
session_dir: &Path,
blob_key: &str,
) -> Result<Option<Vec<u8>>, RepositoryError> {
let path = Self::blob_file_path(session_dir, blob_key);
if !path.exists() {
return Ok(None);
}
let data = std::fs::read(&path)?;
Ok(Some(data))
}
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let index_path = Self::index_path(session_dir);
let Ok(content) = std::fs::read_to_string(&index_path) else {
return Ok(Vec::new());
};
let mut first_seen_order: Vec<String> = Vec::new();
let mut latest_by_key: std::collections::HashMap<String, BlobIndexEntry> =
std::collections::HashMap::new();
for line in content.lines() {
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
continue;
};
if !latest_by_key.contains_key(&entry.key) {
first_seen_order.push(entry.key.clone());
}
latest_by_key.insert(entry.key.clone(), entry);
}
let mut entries: Vec<BlobIndexEntry> = first_seen_order
.into_iter()
.filter_map(|k| latest_by_key.get(&k).cloned())
.collect();
entries.sort_by_key(|e| e.created_at);
Ok(entries.into_iter().map(|e| e.key).collect())
}
}
@@ -1,44 +0,0 @@
//! JSON file–backed `SettingsRepository`.
//! Path: `<base_dir>/settings.json`
use std::path::Path;
use zesdex_domain::cms::{RepositoryError, Settings, SettingsRepository};
use crate::utils::write_json_atomic;
/// Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonSettingsRepository;
impl JsonSettingsRepository {
pub fn new() -> Self {
Self
}
}
impl SettingsRepository for JsonSettingsRepository {
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError> {
let path = base_dir.join("settings.json");
match std::fs::read_to_string(&path) {
Ok(s) => {
match serde_json::from_str(&s) {
Ok(settings) => Ok(settings),
Err(e) => {
tracing::warn!("settings.json at '{:?}' failed to parse ({e}); falling back to defaults", path);
Ok(Settings::default())
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
Err(e) => Err(RepositoryError::Io(e)),
}
}
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> {
std::fs::create_dir_all(base_dir)?;
let path = base_dir.join("settings.json");
write_json_atomic(&path, settings, None)?;
Ok(())
}
}
@@ -1,8 +0,0 @@
//! Filesystem-backed repository implementations for IAM entities.
//!
//! Implements domain repository traits using JSON file persistence for
//! sessions, OAuth tokens, and PID-file session locks.
pub mod oauth_repo;
pub mod session_lock_repo;
pub mod session_repo;
@@ -1,39 +0,0 @@
//! Filesystem-backed `OAuthRepository` implementation.
//!
//! Tokens are stored as a single JSON file with write-then-rename + fsync
//! for crash safety, and restrictive owner-only mode `0o600` on Unix.
use std::path::Path;
use zesdex_domain::auth::{OAuthRepository, OAuthToken, RepositoryError};
use crate::utils::write_json_atomic;
/// Concrete filesystem OAuth token repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemOAuthRepository;
impl FileSystemOAuthRepository {
pub fn new() -> Self {
FileSystemOAuthRepository
}
}
impl OAuthRepository for FileSystemOAuthRepository {
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_json_atomic(path, token, Some(0o600))?;
Ok(())
}
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError> {
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(path)?;
let token: OAuthToken = serde_json::from_str(&data)?;
Ok(Some(token))
}
}
@@ -1,105 +0,0 @@
//! Filesystem-backed `SessionLockRepository` implementation using a PID file
//! (`<session_dir>/.lock`) with atomic `O_CREAT|O_EXCL` acquisition.
use std::convert::TryInto;
use std::io::Write;
use std::path::Path;
use zesdex_domain::auth::{RepositoryError, SessionLockRepository};
/// Concrete filesystem session-lock repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemSessionLockRepository;
impl FileSystemSessionLockRepository {
pub fn new() -> Self {
FileSystemSessionLockRepository
}
}
impl SessionLockRepository for FileSystemSessionLockRepository {
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError> {
let path = session_dir.join(".lock");
let pid = std::process::id();
match std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
{
Ok(mut file) => {
write!(file, "{pid}")?;
file.sync_all()?;
return Ok(true);
}
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(RepositoryError::Io(e)),
}
let content = std::fs::read_to_string(&path).map_err(RepositoryError::Io)?;
if let Ok(existing_pid) = content.trim().parse::<u32>() {
if self.is_alive(existing_pid) {
return Ok(false);
}
}
let tmp = path.with_extension("lock.tmp");
{
let mut tmp_file = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp)
.map_err(|_| {
RepositoryError::Other("another process is replacing the lock".to_string())
})?;
write!(tmp_file, "{pid}")?;
tmp_file.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
if let Some(parent) = path.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
Ok(true)
}
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
let path = session_dir.join(".lock");
std::fs::remove_file(path)?;
Ok(())
}
fn is_alive(&self, pid: u32) -> bool {
let pid_signed: i32 = match pid.try_into() {
Ok(p) => p,
Err(_) => return false,
};
// Resolve our own executable path once.
let self_exe = match std::fs::read_link("/proc/self/exe") {
Ok(exe) => exe,
Err(_) => return false,
};
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
// Phase 1: read /proc/<pid>/exe and compare with self_exe.
let target = match std::fs::read_link(&proc_exe) {
Ok(t) => t,
Err(_) => return false,
};
if target != self_exe {
return false;
}
// Phase 2: verify the process is still alive.
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
// whether the process exists and the caller has permission to signal it.
if unsafe { libc::kill(pid_signed, 0) != 0 } {
return false;
}
// Phase 3: re-check /proc/<pid>/exe to detect PID reuse between
// Phase 1 and Phase 2.
matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe)
}
}
@@ -1,78 +0,0 @@
//! Filesystem-backed `SessionRepository` implementation.
//!
//! Each session is stored as `<base_dir>/sessions/<id>/session.json`.
//! Writes use a write-then-rename + fsync pattern for crash safety.
use std::path::Path;
use zesdex_domain::auth::{RepositoryError, Session, SessionId, SessionRepository};
use crate::utils::write_json_atomic;
/// Concrete filesystem session repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemSessionRepository;
impl FileSystemSessionRepository {
pub fn new() -> Self {
FileSystemSessionRepository
}
}
impl SessionRepository for FileSystemSessionRepository {
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError> {
let sessions_dir = base_dir.join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => return Err(RepositoryError::Io(e)),
};
let mut sessions = Vec::new();
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if let Ok(sid) = SessionId::new(&name) {
if let Ok(session) = self.load_session(base_dir, &sid) {
sessions.push(session);
}
}
}
Ok(sessions)
}
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError> {
let path = base_dir
.join("sessions")
.join(id.as_str())
.join("session.json");
if !path.exists() {
return Err(RepositoryError::NotFound(format!(
"session not found: {}",
id.as_str()
)));
}
let data = std::fs::read_to_string(&path)?;
let session: Session = serde_json::from_str(&data)?;
Ok(session)
}
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError> {
let dir = session.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
write_json_atomic(&path, session, None)?;
Ok(())
}
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError> {
let dir = base_dir.join("sessions").join(id.as_str());
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
}
@@ -1,16 +0,0 @@
//! Persistence adapters — concrete file-based repository implementations
//! for both IAM and CMS domain repository traits.
pub mod cms;
pub mod iam;
pub mod sqlite;
pub use cms::{
app_config_repo::JsonAppConfigRepository, conversation_repo::JsonConversationRepository,
edit_log_repo::JsonlEditLogRepository, memory_repo::MarkdownMemoryRepository,
rewind_blob_repo::FileRewindBlobRepository, settings_repo::JsonSettingsRepository,
};
pub use iam::{
oauth_repo::FileSystemOAuthRepository, session_lock_repo::FileSystemSessionLockRepository,
session_repo::FileSystemSessionRepository,
};
@@ -1,88 +0,0 @@
//! SQLite database connection initialisation and schema migrations.
use std::sync::{Arc, Mutex};
/// A shared SQLite connection wrapped for thread-safe access.
#[derive(Clone)]
pub struct DbConn {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl DbConn {
/// Execute a closure with a reference to the underlying connection.
pub fn with<F, T>(&self, f: F) -> anyhow::Result<T>
where
F: FnOnce(&rusqlite::Connection) -> anyhow::Result<T>,
{
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?;
f(&conn)
}
}
const SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
workspace_roots TEXT NOT NULL DEFAULT '[]',
message_count INTEGER NOT NULL DEFAULT 0,
token_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
summary TEXT
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
session_id TEXT PRIMARY KEY,
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
name TEXT PRIMARY KEY,
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS edit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
entry TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_edit_logs_session
ON edit_logs (session_id);
"#;
/// Initialise a shared SQLite connection at the given path.
pub fn init_db(db_path: &str) -> anyhow::Result<DbConn> {
let conn = rusqlite::Connection::open(db_path)
.map_err(|e| anyhow::anyhow!("failed to open SQLite database at '{db_path}': {e}"))?;
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
Ok(DbConn {
conn: Arc::new(Mutex::new(conn)),
})
}
/// Run embedded SQL schema migrations.
pub fn run_migrations(db: &DbConn) -> anyhow::Result<()> {
db.with(|conn| {
conn.execute_batch(SCHEMA_SQL)
.map_err(|e| anyhow::anyhow!("failed to execute database schema migrations: {e}"))
})?;
Ok(())
}
@@ -1,3 +0,0 @@
//! SQLite database connection management and schema migrations.
pub mod database;
-62
View File
@@ -1,62 +0,0 @@
//! Process-wide shared Tokio runtime for sync → async bridging.
//!
//! Many `Tool::run` implementations are synchronous but need to drive async
//! work (LLM calls, subagent execution). Creating a fresh
//! [`tokio::runtime::Runtime`] on every call is expensive (spawns a thread
//! pool + runtime each time) and can fail randomly under thread pressure.
//!
//! # Flow
//!
//! [`runtime()`] returns a lazily-initialised process-wide runtime created
//! exactly once via [`std::sync::OnceLock`]. Callers use
//! `runtime().block_on(...)` exactly like they would with a local runtime —
//! the only difference is the runtime is shared, so the cost is paid once per
//! process instead of once per tool call.
//!
//! # Safety
//!
//! `block_on` panics if called from within a running Tokio runtime. The
//! tools that use this helper are synchronous (`Tool::run`), so this is safe
//! in practice. Async code should never call `runtime().block_on`.
use std::sync::OnceLock;
/// Maximum worker threads for the shared runtime. Kept modest — tools are
/// mostly I/O-bound and rarely need more concurrency than this.
const RUNTIME_WORKER_THREADS: usize = 8;
static SHARED_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
/// Return the process-wide shared Tokio runtime, initialising it on first use.
///
/// The runtime is configured with `worker_threads = 8` and
/// `enable_all()` (time + IO drivers) so streams, timers, and network calls
/// all work. If initialisation fails (extremely rare — resource exhaustion at
/// startup), the process aborts with a clear message rather than returning
/// an error on every subsequent call.
pub fn runtime() -> &'static tokio::runtime::Runtime {
SHARED_RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(RUNTIME_WORKER_THREADS)
.thread_name("zesdex-shared-rt")
.enable_all()
.build()
.expect("failed to create shared tokio runtime")
})
}
#[cfg(test)]
mod tests {
use super::runtime;
#[test]
fn runtime_is_singleton() {
assert!(std::ptr::eq(runtime(), runtime()));
}
#[test]
fn runtime_blocks_and_resolves() {
let val = runtime().block_on(async { 6 * 7 });
assert_eq!(val, 42);
}
}
@@ -1,364 +0,0 @@
//! Auto-review engine — after edits, reviews AND auto-fixes issues using
//! tools + LLM, all synchronously in a background thread (no tokio runtime
//! needed).
//!
//! Flow: after an agent turn with edits completes:
//! 1. Emit `WorkflowAgentUpdate(Running)` → visible in workflow sidebar
//! 2. Run `git diff` to get the changed files
//! 3. Call LLM with the diff to identify issues and suggested fixes
//! 4. Apply fixes using sync tools (edit/write)
//! 5. Results stream as `TurnEvent::SystemNote` events
//! 6. Emit `WorkflowAgentUpdate(Completed)` when done
use std::collections::VecDeque;
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tracing::{debug, info, instrument, warn};
use crate::llm::provider::LlmClient;
use crate::subagent::division::{tools_for, AccessTier};
use crate::tools::{Tool, ToolCtx};
use crate::{AgentStatus, TurnEvent};
use zesdex_domain::core::ChatMessage;
const REVIEW_AGENT_ID: &str = "auto-review";
/// Spawn a background task that reviews changes and auto-fixes issues.
///
/// Runs asynchronously using tokio::spawn.
#[instrument(skip(turn_events))]
pub fn spawn_background_review(
workspace_roots: Vec<PathBuf>,
turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
api_key: String,
model: String,
api_base: Option<String>,
) {
let agent_id = REVIEW_AGENT_ID.to_string();
let agent_name = "Auto-Review".to_string();
tokio::spawn(async move {
let root = match workspace_roots.first() {
Some(r) => r.clone(),
None => {
debug!("auto-review: no workspace root, skipping");
return;
}
};
info!("auto-review: starting");
// Mark Running in workflow panel
push_event(
&turn_events,
TurnEvent::WorkflowAgentUpdate {
agent_id: agent_id.clone(),
agent_name: agent_name.clone(),
status: AgentStatus::Running,
},
);
// 1. Get the git diff to know what changed
let diff = match get_git_diff(&root) {
Ok(d) if !d.is_empty() => d,
Ok(_) => {
debug!("auto-review: no changes detected");
push_event(
&turn_events,
TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status: AgentStatus::Completed,
},
);
return;
}
Err(e) => {
debug!(error = %e, "auto-review: git diff failed");
push_event(
&turn_events,
TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status: AgentStatus::Failed(e),
},
);
return;
}
};
push_event(
&turn_events,
TurnEvent::SystemNote {
kind: "review".into(),
message: "🔍 Auto-review: examining and fixing issues...".into(),
},
);
// 2. Build tool context + load write-tier tools
let tool_ctx = ToolCtx::builder()
.session_dir(root.join(".zesdex").join("sessions").join("auto-review"))
.workspaces(workspace_roots.clone())
.turn_events(turn_events.clone())
.build();
let tools = tools_for(&AccessTier::Write);
// 3. Resolve LLM credentials
let base_url = api_base.unwrap_or_else(|| {
std::env::var("OPENAI_API_BASE")
.unwrap_or_else(|_| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string())
});
let client = LlmClient::new(api_key, model, Some(base_url));
// 4. Truncate diff if needed
const MAX_DIFF_CHARS: usize = 5000;
let truncated_diff = if diff.len() > MAX_DIFF_CHARS {
push_event(
&turn_events,
TurnEvent::SystemNote {
kind: "review".into(),
message: format!(
"📐 Diff is large ({} chars), reviewing first {} chars...",
diff.len(),
MAX_DIFF_CHARS
),
},
);
format!(
"{}...\n[diff truncated at {} characters]",
crate::utils::truncate_chars(&diff, MAX_DIFF_CHARS),
MAX_DIFF_CHARS
)
} else {
diff.to_string()
};
// 5. Call LLM to review the diff and suggest fixes.
let system_msg = ChatMessage::system(
"You are an auto-review subagent. Your ONLY job:\n\
1. Review the git diff below for:\n\
- Typos and spelling errors\n\
- Missing imports or undefined references\n\
- Syntax errors or type mismatches\n\
- Logic bugs or off-by-one errors\n\
- Missing error handling\n\
- Security issues\n\n\
2. For each issue found, output a command to fix it.\n\n\
Available commands:\n\
- `edit <file>` then provide the old text and new text\n\
- `write <file>` then provide the new content\n\n\
Output format:\n\
If no issues: NO_ISSUES_FOUND\n\n\
If issues found:\n\
---\n\
FILE: <path>\n\
ISSUE: <description>\n\
SEVERITY: HIGH|MEDIUM|LOW\n\
OLD: <exact text to replace>\n\
NEW: <replacement text>\n\
---"
.to_string(),
);
let user_msg = ChatMessage::user(format!(
"Review and fix this git diff:\n\n```diff\n{truncated_diff}\n```"
));
let response = run_llm_review(&client, &[system_msg, user_msg]).await;
let response_text = match response {
Ok(text) => text,
Err(e) => {
warn!(error = %e, "auto-review LLM call failed");
push_event(
&turn_events,
TurnEvent::SystemNote {
kind: "review".into(),
message: format!("⚠️ Auto-review failed: {e}"),
},
);
push_event(
&turn_events,
TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status: AgentStatus::Failed(e.to_string()),
},
);
return;
}
};
// 6. Parse and apply fixes
if response_text.trim() == "NO_ISSUES_FOUND" || response_text.trim().is_empty() {
push_event(
&turn_events,
TurnEvent::SystemNote {
kind: "review".into(),
message: "✅ Auto-review: no issues found.".into(),
},
);
info!("auto-review: no issues found");
} else {
// Try to apply structured fixes
let fix_count = apply_fixes_from_response(&response_text, &tools, &tool_ctx);
push_event(
&turn_events,
TurnEvent::SystemNote {
kind: "review_finding".into(),
message: format!(
"📋 Auto-review complete ({} fix(es) applied).\n{}",
fix_count,
response_text.trim()
),
},
);
info!(fix_count, "auto-review: completed with fixes");
}
push_event(
&turn_events,
TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status: AgentStatus::Completed,
},
);
});
}
/// Run the LLM review call asynchronously using ProviderService.
async fn run_llm_review(client: &LlmClient, messages: &[ChatMessage]) -> Result<String, String> {
use zesdex_application::ports::ProviderService;
match client.chat(messages, None, Some(1024), Some(0.3)).await {
Ok((msg, _)) => Ok(msg.content.unwrap_or_default()),
Err(e) => Err(e.to_string()),
}
}
/// Parse the LLM response for structured fix commands and apply them.
fn apply_fixes_from_response(response: &str, tools: &[Box<dyn Tool>], tool_ctx: &ToolCtx) -> usize {
let mut fix_count = 0;
// Parse structured fix blocks
let blocks: Vec<&str> = response.split("---").collect();
for block in &blocks {
let trimmed = block.trim();
if trimmed.is_empty() {
continue;
}
let lines: Vec<&str> = trimmed.lines().map(|l| l.trim()).collect();
if lines.len() < 4 {
continue;
}
// Try to extract structured fix
let file_path = extract_field(&lines, "FILE:").unwrap_or("");
let severity = extract_field(&lines, "SEVERITY:").unwrap_or("LOW");
let old_text = extract_field(&lines, "OLD:").unwrap_or("");
let new_text = extract_field(&lines, "NEW:").unwrap_or("");
if file_path.is_empty() || old_text.is_empty() || new_text.is_empty() {
continue;
}
// Only auto-fix HIGH and MEDIUM severity issues
if severity != "HIGH" && severity != "MEDIUM" {
debug!(severity, file = file_path, "skipping LOW severity fix");
continue;
}
// Try to apply the fix using the edit tool
if let Some(edit_tool) = tools.iter().find(|t| t.name() == "edit") {
let args = serde_json::json!({
"path": file_path,
"old": old_text,
"new": new_text,
"reason": "auto-review fix"
});
match edit_tool.run(tool_ctx, &args) {
Ok(result) => {
info!(file = file_path, "auto-review fix applied: {result}");
fix_count += 1;
}
Err(e) => {
debug!(file = file_path, error = %e, "auto-review fix failed");
}
}
}
}
fix_count
}
/// Extract a field value from parsed lines (e.g. "FILE: src/main.rs" → "src/main.rs").
fn extract_field<'a>(lines: &[&'a str], prefix: &str) -> Option<&'a str> {
for line in lines {
if let Some(val) = line.strip_prefix(prefix) {
let trimmed = val.trim();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
}
None
}
/// Run `git diff` to get workspace changes (both staged and unstaged).
fn get_git_diff(workspace_root: &PathBuf) -> Result<String, String> {
let git_dir = workspace_root.join(".git");
if !git_dir.exists() {
return Err("not a git repository".to_string());
}
let mut combined = String::new();
// Unstaged diff
if let Ok(out) = Command::new("git")
.args(["diff"])
.current_dir(workspace_root)
.output()
{
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
combined.push_str("=== Unstaged Changes ===\n");
combined.push_str(&stdout);
combined.push('\n');
}
}
// Staged diff
if let Ok(out) = Command::new("git")
.args(["diff", "--cached"])
.current_dir(workspace_root)
.output()
{
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
combined.push_str("=== Staged Changes ===\n");
combined.push_str(&stdout);
combined.push('\n');
}
}
if combined.is_empty() {
return Err("no changes".to_string());
}
Ok(combined)
}
/// Push a TurnEvent onto the shared event queue.
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
if let Ok(mut q) = queue.lock() {
q.push_back(event);
}
}
@@ -1,5 +0,0 @@
//! Auto-subagents — automatically run review/test subagents at the end of
//! each turn.
pub mod engine;
pub mod paths;
@@ -1,8 +0,0 @@
//! Auto-subagent path resolution.
use std::path::{Path, PathBuf};
/// Resolve paths for auto-subagent scripts.
pub fn auto_subagent_dir(base_dir: &Path) -> PathBuf {
base_dir.join("auto-agents")
}
@@ -1,47 +0,0 @@
//! Subagent execution context — wraps the shared state needed by a subagent.
//!
//! Includes LLM connection parameters (base_url, api_key, model) so the
//! engine can construct an `LlmClient` without loading settings itself.
use crate::tools::ToolCtx;
/// Context for a single subagent execution.
///
/// Flow: constructed by the caller (e.g. `execute_primitive`) with resolved
/// LLM credentials → passed to `engine::run_agent` → used to create the
/// `LlmClient` for LLM interaction.
pub struct SubagentContext {
/// The directive/instruction the subagent should execute.
pub directive: String,
/// Shared tool execution context (workspaces, session, memory paths).
pub tool_ctx: ToolCtx,
/// Access tier as a string (used for logging/serialization).
pub access_tier: String,
/// Base URL for the LLM provider API.
pub base_url: String,
/// API key for the LLM provider.
pub api_key: String,
/// Model identifier for the LLM provider.
pub model: String,
}
impl SubagentContext {
/// Create a new subagent context with all required fields.
pub fn new(
directive: String,
tool_ctx: ToolCtx,
access_tier: String,
base_url: String,
api_key: String,
model: String,
) -> Self {
SubagentContext {
directive,
tool_ctx,
access_tier,
base_url,
api_key,
model,
}
}
}
@@ -1,72 +0,0 @@
//! Subagent division — access-tier tool filtering for subagent permissions.
//!
//! Flow: the calling code picks an `AccessTier` → `tools_for()` returns the
//! subset of all built-in tools allowed at that tier → those tools are passed
//! to `engine::run_agent` for the subagent's tool-execution loop.
use crate::tools::Tool;
pub use zesdex_domain::subagent::AccessTier;
/// Filter the available tools to match the given access tier.
///
/// Flow: `all_tools()` → filter by tier → return owned `Vec<Box<dyn Tool>>`.
///
/// Read tier: non-mutating introspection and utility tools only.
/// Write tier: everything except dangerous system/network/process tools.
/// Full tier: all 37 tools.
pub fn tools_for(access: &AccessTier) -> Vec<Box<dyn Tool>> {
let all = crate::tools::all_tools();
match access {
AccessTier::Read => all
.into_iter()
.filter(|t| {
let name = t.name();
matches!(
name,
"read"
| "grep"
| "glob"
| "pong"
| "todowrite"
| "todofinish"
| "dir_list"
| "dir_cache_update"
| "cd"
| "remember"
| "recall"
| "forget"
)
})
.collect(),
AccessTier::Write => all
.into_iter()
.filter(|t| {
let name = t.name();
!matches!(
name,
"bash"
| "bash_output"
| "bash_kill"
| "git_operator"
| "git_worktree"
| "git_cred"
| "shell"
| "workflow_run"
| "note_finding"
| "read_findings"
| "hive_mind"
| "spawn_agents"
| "spawn_pipeline"
| "plan_enter"
| "plan_ready"
| "sequential_think"
)
})
.collect(),
AccessTier::Full => all, // everything
}
}

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