chore: hapus semua source Rust + Cargo.toml/lock + .cargo (247 file .rs, migrasi ke TS/Bun)
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Background bash job management — spawn, track, and query long-running
|
||||
//! shell processes.
|
||||
|
||||
pub mod control;
|
||||
pub mod job;
|
||||
@@ -1,3 +0,0 @@
|
||||
//! Tool gate — per-tool access control and permissions.
|
||||
|
||||
pub mod patterns;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user