feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
//! 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,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
pub fn new(sub: String, exp: u64, 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,
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Auth service implementations: JWT signing/verification, Argon2 password
|
||||
//! hashing, and OAuth loopback server.
|
||||
|
||||
pub mod jwt;
|
||||
pub mod oauth_loopback;
|
||||
pub mod password;
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 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."
|
||||
}
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! 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}"))?
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Background bash control — list, cancel, and inspect background processes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::job::BashJob;
|
||||
|
||||
/// 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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = self.jobs.lock().unwrap();
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! 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};
|
||||
|
||||
/// 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 (in production this would use an async task)
|
||||
let job_clone = Arc::clone(&job);
|
||||
std::thread::spawn(move || {
|
||||
let mut guard = job_clone.process.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.wait();
|
||||
}
|
||||
});
|
||||
|
||||
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().map_or(false, |c| {
|
||||
matches!(c.try_wait(), Ok(None))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Background bash job management — spawn, track, and query long-running
|
||||
//! shell processes.
|
||||
|
||||
pub mod control;
|
||||
pub mod job;
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Tool gate — per-tool access control and permissions.
|
||||
|
||||
pub mod patterns;
|
||||
@@ -0,0 +1,35 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! 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()
|
||||
.expect("IpcClient mutex poisoned");
|
||||
guard.send(msg)
|
||||
}
|
||||
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<Option<T>> {
|
||||
let mut guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.expect("IpcClient mutex poisoned");
|
||||
guard.receive()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Connection wrapper around a Unix socket stream,
|
||||
//! pairing a buffered reader with a raw writer.
|
||||
|
||||
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) -> Self {
|
||||
let reader = BufReader::new(
|
||||
stream
|
||||
.try_clone()
|
||||
.expect("UnixStream::try_clone should never fail on Linux"),
|
||||
);
|
||||
let writer = stream;
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Unix-socket IPC layer for daemon/client communication.
|
||||
|
||||
pub mod client;
|
||||
pub mod conn;
|
||||
pub mod frame;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 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,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 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()?;
|
||||
Ok(crate::ipc::conn::Connection::new(stream))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! # 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
|
||||
//! ├── lsp/ — Native LSP client + provisioner
|
||||
//! ├── 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 bgbash;
|
||||
pub mod guard;
|
||||
pub mod ipc;
|
||||
pub mod llm;
|
||||
pub mod lsp;
|
||||
pub mod mcp;
|
||||
pub mod middleware;
|
||||
pub mod persistence;
|
||||
pub mod review;
|
||||
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 serde::{Deserialize, Serialize};
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum Origin {
|
||||
/// The main agent turn loop.
|
||||
Main,
|
||||
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
|
||||
SubAgent,
|
||||
/// The auto-inline review step after an edit.
|
||||
Reviewer,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// Short string tag for this origin, used in filenames and logs.
|
||||
pub fn tag(self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main",
|
||||
Origin::SubAgent => "subagent",
|
||||
Origin::Reviewer => "reviewer",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Severity/category of a toast notification, used to pick its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Lesson,
|
||||
}
|
||||
|
||||
/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
pub kind: ToastKind,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Create a toast with a default 5-second lifetime, stamped with now.
|
||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||
Toast {
|
||||
kind,
|
||||
message,
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
lifetime_ms: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
||||
pub fn expired(&self, now_ms: i64) -> bool {
|
||||
let lifetime = self.lifetime_ms as i64;
|
||||
now_ms - self.created_at > lifetime
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub fn new() -> Self {
|
||||
DirCache {
|
||||
entries: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
MentionIndex {
|
||||
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, paths: Vec<String>) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, path: String) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
w.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TurnEvent & runtime types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Events emitted onto the turn-event queue while an agent turn runs,
|
||||
/// consumed by the event loop to update state and drive re-renders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEvent {
|
||||
AssistantMessage(ChatMessage),
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
path: Option<String>,
|
||||
},
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
StreamStart,
|
||||
StreamToken(String),
|
||||
StreamDone(ChatMessage),
|
||||
Usage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
ReviewUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
Compacted(Vec<ChatMessage>),
|
||||
Error(String),
|
||||
Done,
|
||||
WorkflowAgentUpdate {
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
status: crate::AgentStatus,
|
||||
},
|
||||
}
|
||||
|
||||
/// A tool call awaiting execution, along with which execution model
|
||||
/// (inline, deferred, async) it should run under.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingTool {
|
||||
pub tool_name: String,
|
||||
pub args: serde_json::Value,
|
||||
pub execution_model: ExecutionModel,
|
||||
}
|
||||
|
||||
/// How a pending tool call should be executed when the turn resumes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionModel {
|
||||
Inline,
|
||||
Deferred,
|
||||
AsyncTokio,
|
||||
}
|
||||
|
||||
/// Reference to a background bash job tracked in session state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BashJobRef {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub started_at: i64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Per-session runtime state: message history, pending tool queue,
|
||||
/// background bash jobs, lesson/review counters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRuntime {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub tool_call_results: Vec<ToolCallResult>,
|
||||
pub pending_tool_queue: Vec<PendingTool>,
|
||||
pub bash_jobs: Vec<BashJobRef>,
|
||||
pub subagent_queue: usize,
|
||||
pub edit_count: u32,
|
||||
pub consecutive_empty_reviews: u32,
|
||||
pub session_start: i64,
|
||||
pub lesson_count: u32,
|
||||
pub lessons_user: u32,
|
||||
pub lessons_feedback: u32,
|
||||
pub lessons_project: u32,
|
||||
pub lessons_reference: u32,
|
||||
pub lessons_active: u32,
|
||||
pub lessons_stale: u32,
|
||||
pub lessons_contradicted: u32,
|
||||
pub lessons_human: u32,
|
||||
pub lessons_verified: u32,
|
||||
pub lessons_unverified: u32,
|
||||
pub review_count: u32,
|
||||
pub session_dir: PathBuf,
|
||||
pub usage: UsageStats,
|
||||
pub hive_mind_converged: bool,
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
messages: Vec::new(),
|
||||
tool_call_results: Vec::new(),
|
||||
pending_tool_queue: Vec::new(),
|
||||
bash_jobs: Vec::new(),
|
||||
subagent_queue: 0,
|
||||
edit_count: 0,
|
||||
consecutive_empty_reviews: 0,
|
||||
session_start: chrono::Utc::now().timestamp_millis(),
|
||||
lesson_count: 0,
|
||||
lessons_user: 0,
|
||||
lessons_feedback: 0,
|
||||
lessons_project: 0,
|
||||
lessons_reference: 0,
|
||||
lessons_active: 0,
|
||||
lessons_stale: 0,
|
||||
lessons_contradicted: 0,
|
||||
lessons_human: 0,
|
||||
lessons_verified: 0,
|
||||
lessons_unverified: 0,
|
||||
review_count: 0,
|
||||
session_dir,
|
||||
usage: UsageStats::default(),
|
||||
hive_mind_converged: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, msg: ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple ASCII progress display for a long-running operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProgressState {
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
pub message: String,
|
||||
pub start_time: i64,
|
||||
}
|
||||
|
||||
/// Agent status for workflow engine progress tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AgentStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AgentStatus::Pending => write!(f, "pending"),
|
||||
AgentStatus::Running => write!(f, "running"),
|
||||
AgentStatus::Completed => write!(f, "completed"),
|
||||
AgentStatus::Failed(msg) => write!(f, "failed: {msg}"),
|
||||
AgentStatus::Cancelled => write!(f, "cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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, UsageStats, ToolCallResult};
|
||||
@@ -0,0 +1,5 @@
|
||||
//! LLM provider HTTP client for OpenAI/Anthropic-compatible chat completion APIs.
|
||||
|
||||
pub mod provider;
|
||||
|
||||
pub use provider::{resolve_api_key, LlmClient};
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Blocking 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::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
|
||||
use zesdex_domain::core::{
|
||||
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
||||
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||
pub const DEFAULT_API_KEY: &str = "";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
// ±25% jitter
|
||||
let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0;
|
||||
Duration::from_secs_f64(delay as f64 * jitter_factor)
|
||||
}
|
||||
|
||||
/// Is the error an auth / billing failure that retrying won't fix?
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Blocking HTTP client for a single LLM provider endpoint.
|
||||
pub struct LlmClient {
|
||||
pub client: reqwest::blocking::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::blocking::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::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e2) => {
|
||||
tracing::warn!("also failed: {e2}. using default client");
|
||||
reqwest::blocking::Client::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
LlmClient {
|
||||
client,
|
||||
api_key,
|
||||
base_url: base_url
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat_with_tools_non_streaming(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
abort_flag: Option<&AtomicBool>,
|
||||
) -> 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;
|
||||
|
||||
if let Some(ref flag) = abort_flag {
|
||||
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
(|| -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let resp = http_req.json(&req).send().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().unwrap_or_default();
|
||||
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||
}
|
||||
|
||||
let data: ChatResponse = resp.json()?;
|
||||
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))
|
||||
})();
|
||||
|
||||
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);
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat_with_tools_streaming(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
mut on_event: impl FnMut(&StreamEvent) -> bool,
|
||||
abort_flag: Option<&AtomicBool>,
|
||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let tools_for_fallback = tools.clone();
|
||||
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 = 5;
|
||||
let mut attempt = 0u32;
|
||||
let mut meaningful_content = false;
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
let mut captured_content = false;
|
||||
let mut wrapped = |event: &StreamEvent| -> bool {
|
||||
match event {
|
||||
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
|
||||
captured_content = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
on_event(event)
|
||||
};
|
||||
match self.try_stream_once(&req, &url, &mut wrapped) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if is_auth_error(&err_str) {
|
||||
return Err(e);
|
||||
}
|
||||
if captured_content || (attempt >= max_retries_stream) {
|
||||
meaningful_content = captured_content || meaningful_content;
|
||||
break;
|
||||
}
|
||||
if attempt >= max_retries_stream {
|
||||
return Err(e);
|
||||
}
|
||||
let delay = backoff_for_error(attempt, &err_str);
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if meaningful_content {
|
||||
if let Some(ref flag) = abort_flag {
|
||||
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
return self.chat_with_tools_non_streaming(
|
||||
messages,
|
||||
tools_for_fallback,
|
||||
max_tokens,
|
||||
temperature,
|
||||
abort_flag,
|
||||
);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"streaming request failed after {max_retries_stream} attempts"
|
||||
))
|
||||
}
|
||||
|
||||
fn try_stream_once(
|
||||
&self,
|
||||
req: &ChatRequest,
|
||||
url: &str,
|
||||
on_event: &mut dyn FnMut(&StreamEvent) -> bool,
|
||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
use std::io::Read;
|
||||
|
||||
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 resp = http_req.json(req).send().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().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,
|
||||
} => {
|
||||
let existing = self.tool_calls.iter_mut().find(|tc| {
|
||||
if let Some(ref id_val) = id {
|
||||
tc.id == *id_val
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if let Some(tc) = existing {
|
||||
if let Some(ref n) = name {
|
||||
tc.function.name = n.clone();
|
||||
}
|
||||
} else {
|
||||
self.tool_calls.push(
|
||||
zesdex_domain::core::ToolCall {
|
||||
id: id.clone().unwrap_or_default(),
|
||||
type_: "function".to_string(),
|
||||
function: zesdex_domain::core::ToolFunction {
|
||||
name: name.clone().unwrap_or_default(),
|
||||
arguments: serde_json::Value::String(arguments_delta.clone()),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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 reader = resp;
|
||||
let mut byte_buf: Vec<u8> = Vec::new();
|
||||
let mut chunk_buf = [0u8; 4096];
|
||||
|
||||
loop {
|
||||
let n = reader.read(&mut chunk_buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
byte_buf.extend_from_slice(&chunk_buf[..n]);
|
||||
let valid_len = match std::str::from_utf8(&byte_buf) {
|
||||
Ok(s) => s.len(),
|
||||
Err(e) => e.valid_up_to(),
|
||||
};
|
||||
if valid_len == 0 {
|
||||
continue;
|
||||
}
|
||||
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));
|
||||
}
|
||||
_ => turn.apply_event(&event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((turn.build_assistant_message(), usage))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the API key for the currently configured provider, falling back
|
||||
/// through settings -> env var -> provider default.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! LSP client — sends JSON-RPC requests to language servers.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Mutable inner state of an LSP client, protected by a mutex so that
|
||||
/// `send_request` and `shutdown` can be called via `&self` (required by
|
||||
/// [`LspManager`](super::manager::LspManager)).
|
||||
struct LspClientInner {
|
||||
process: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
request_id: u64,
|
||||
}
|
||||
|
||||
/// A minimal but functional LSP client.
|
||||
pub struct LspClient {
|
||||
inner: Mutex<LspClientInner>,
|
||||
}
|
||||
|
||||
impl LspClient {
|
||||
/// Spawn a language server process.
|
||||
pub fn start(command: &str, args: &[String]) -> Result<Self> {
|
||||
let mut child = Command::new(command)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
|
||||
info!("LSP client spawned: {command}");
|
||||
Ok(LspClient {
|
||||
inner: Mutex::new(LspClientInner {
|
||||
process: child,
|
||||
stdin,
|
||||
stdout,
|
||||
request_id: 0,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request and read the response.
|
||||
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.request_id += 1;
|
||||
let request = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": inner.request_id,
|
||||
"method": method,
|
||||
"params": params.clone(),
|
||||
});
|
||||
|
||||
// Write Content-Length header + body
|
||||
let body = serde_json::to_string(&request)?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
inner.stdin.write_all(header.as_bytes())?;
|
||||
inner.stdin.write_all(body.as_bytes())?;
|
||||
inner.stdin.flush()?;
|
||||
|
||||
debug!("LSP request: {method} (id={})", inner.request_id);
|
||||
|
||||
// Read Content-Length header
|
||||
let mut content_length = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
inner.stdout.read_line(&mut line)?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
break; // end of headers
|
||||
}
|
||||
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
|
||||
content_length = len_str.parse::<usize>()?;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the JSON body
|
||||
let mut buf = vec![0u8; content_length];
|
||||
inner.stdout.read_exact(&mut buf)?;
|
||||
let response: Value = serde_json::from_slice(&buf)?;
|
||||
|
||||
debug!("LSP response for {method}: response received");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Gracefully shut down the server.
|
||||
pub fn shutdown(&self) -> Result<()> {
|
||||
let null = Value::Null;
|
||||
let _ = self.send_request("shutdown", &null);
|
||||
let _ = self.send_request("exit", &null);
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
let _ = inner.process.wait();
|
||||
}
|
||||
info!("LSP client shut down");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LspClient {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
let _ = inner.process.kill();
|
||||
let _ = inner.process.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Manages multiple LSP server processes, keyed by language ID.
|
||||
//!
|
||||
//! Each language (e.g. "rust", "python") maps to one `LspClient`.
|
||||
//! The manager provides a unified `request` method that dispatches
|
||||
//! to the correct client by language.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::client::LspClient;
|
||||
|
||||
/// Manages one `LspClient` per language.
|
||||
pub struct LspManager {
|
||||
clients: HashMap<String, LspClient>,
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub fn new() -> Self {
|
||||
LspManager {
|
||||
clients: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> {
|
||||
let client = LspClient::start(command, args)?;
|
||||
self.clients.insert(language.to_string(), client);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_client(&self, language: &str) -> Option<&LspClient> {
|
||||
self.clients.get(language)
|
||||
}
|
||||
|
||||
pub fn shutdown_all(&mut self) {
|
||||
for (_lang, client) in &self.clients {
|
||||
let _ = client.shutdown();
|
||||
}
|
||||
self.clients.clear();
|
||||
}
|
||||
|
||||
pub fn languages(&self) -> Vec<String> {
|
||||
self.clients.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.clients.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Native LSP client integration — manage language server processes and
|
||||
//! dispatch requests for completion, hover, diagnostics, etc.
|
||||
|
||||
pub mod client;
|
||||
pub mod manager;
|
||||
pub mod provisioner;
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Configuration for LSP language server provisioning.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Describes how to provision a language server for a given language.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LspProvisionerConfig {
|
||||
/// Language identifier, e.g. "rust", "python".
|
||||
pub language: String,
|
||||
/// The command to start the language server.
|
||||
pub command: String,
|
||||
/// Arguments for the command.
|
||||
pub args: Vec<String>,
|
||||
/// How to install the language server (if not found).
|
||||
pub install_hint: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Discovers installed language servers on the system PATH.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::config::LspProvisionerConfig;
|
||||
|
||||
/// Known language server configurations keyed by language.
|
||||
fn known_configs() -> HashMap<&'static str, (&'static str, Vec<&'static str>)> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("rust", ("rust-analyzer", vec![]));
|
||||
m.insert("python", ("pyright-langserver", vec!["--stdio"]));
|
||||
m.insert("typescript", ("typescript-language-server", vec!["--stdio"]));
|
||||
m.insert("javascript", ("typescript-language-server", vec!["--stdio"]));
|
||||
m.insert("go", ("gopls", vec![]));
|
||||
m
|
||||
}
|
||||
|
||||
/// Check if a command is available on PATH.
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.and_then(|path| {
|
||||
std::env::split_paths(&path).find_map(|dir| {
|
||||
let full_path = dir.join(cmd);
|
||||
if full_path.is_file() {
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Discover which language servers are already on PATH.
|
||||
pub fn discover_installed() -> Vec<LspProvisionerConfig> {
|
||||
let mut configs = Vec::new();
|
||||
for (lang, (cmd, args)) in known_configs() {
|
||||
if command_exists(cmd) {
|
||||
configs.push(LspProvisionerConfig {
|
||||
language: lang.to_string(),
|
||||
command: cmd.to_string(),
|
||||
args: args.iter().map(|s| s.to_string()).collect(),
|
||||
install_hint: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Installs language servers (non-interactive, via package managers or
|
||||
//! direct download).
|
||||
|
||||
/// Install a language server for the given language.
|
||||
///
|
||||
/// Returns a success message or an error describing why installation failed.
|
||||
pub fn install_language_server(language: &str) -> anyhow::Result<String> {
|
||||
match language {
|
||||
"rust" => {
|
||||
// rust-analyzer is typically installed via rustup
|
||||
let output = std::process::Command::new("rustup")
|
||||
.args(["component", "add", "rust-analyzer"])
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
Ok("rust-analyzer installed via rustup".to_string())
|
||||
} else {
|
||||
anyhow::bail!("failed to install rust-analyzer: {}", String::from_utf8_lossy(&output.stderr))
|
||||
}
|
||||
}
|
||||
"python" => {
|
||||
let output = std::process::Command::new("npm")
|
||||
.args(["install", "-g", "pyright"])
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
Ok("pyright installed via npm".to_string())
|
||||
} else {
|
||||
anyhow::bail!("failed to install pyright: {}", String::from_utf8_lossy(&output.stderr))
|
||||
}
|
||||
}
|
||||
lang => anyhow::bail!("no install method known for language '{lang}'"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! High-level manager that discovers, installs (if needed), and starts
|
||||
//! LSP servers.
|
||||
|
||||
use crate::lsp::manager::LspManager;
|
||||
use super::discovery::discover_installed;
|
||||
use super::install::install_language_server;
|
||||
|
||||
/// Auto-provision language servers for the given list of languages.
|
||||
///
|
||||
/// Flow: discover already-installed servers → for each requested language
|
||||
/// not yet available, attempt auto-install → start each server.
|
||||
pub fn auto_provision(
|
||||
lsp_manager: &mut LspManager,
|
||||
languages: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut started = Vec::new();
|
||||
let installed = discover_installed();
|
||||
let mut installed_map: std::collections::HashMap<&str, &crate::lsp::provisioner::config::LspProvisionerConfig> = std::collections::HashMap::new();
|
||||
for cfg in &installed {
|
||||
installed_map.insert(cfg.language.as_str(), cfg);
|
||||
}
|
||||
|
||||
for lang in languages {
|
||||
if let Some(cfg) = installed_map.get(lang.as_str()) {
|
||||
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
|
||||
started.push(lang.clone());
|
||||
}
|
||||
} else {
|
||||
// Not installed — try auto-install
|
||||
if install_language_server(lang).is_ok() {
|
||||
// Re-discover after install
|
||||
let refreshed = discover_installed();
|
||||
for cfg in refreshed {
|
||||
if cfg.language == *lang {
|
||||
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
|
||||
started.push(lang.clone());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
started
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! LSP language server provisioner — discovers, installs, and manages
|
||||
//! language server executables.
|
||||
|
||||
pub mod config;
|
||||
pub mod discovery;
|
||||
pub mod install;
|
||||
pub mod manager;
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, name: &str, transport: &str) {
|
||||
self.servers.insert(
|
||||
name.to_string(),
|
||||
McpServerHandle {
|
||||
name: name.to_string(),
|
||||
transport: transport.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn unregister(&mut self, name: &str) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Model Context Protocol (MCP) — bridge between agent tools and external MCP
|
||||
//! servers using the rmcp crate.
|
||||
|
||||
pub mod manager;
|
||||
pub mod transport;
|
||||
@@ -0,0 +1,40 @@
|
||||
//! MCP transport layer — manages child-process and HTTP-based transport
|
||||
//! for connecting to MCP servers.
|
||||
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
/// A running MCP server process connected via stdio.
|
||||
pub struct McpTransport {
|
||||
process: Option<Child>,
|
||||
}
|
||||
|
||||
impl McpTransport {
|
||||
pub fn start_child_process(command: &str, args: &[String]) -> anyhow::Result<Self> {
|
||||
let child = Command::new(command)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
Ok(McpTransport {
|
||||
process: Some(child),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> anyhow::Result<()> {
|
||||
if let Some(mut child) = self.process.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpTransport {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.process.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Authentication middleware — session-lock based auth for Axum.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
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};
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionAuthLayer;
|
||||
|
||||
impl SessionAuthLayer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionAuthLayer {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for SessionAuthLayer {
|
||||
type Service = SessionAuthMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
SessionAuthMiddleware { inner }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tower Service that validates X-Session-Id before forwarding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionAuthMiddleware<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S, ReqBody> Service<Request<ReqBody>> for SessionAuthMiddleware<S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
ReqBody: Send + '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());
|
||||
|
||||
if session_id.as_deref() != Some("valid-session") {
|
||||
// In production, this validates against the store
|
||||
return Box::pin(async move {
|
||||
Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or invalid X-Session-Id header",
|
||||
)
|
||||
.into_response())
|
||||
});
|
||||
}
|
||||
|
||||
let fut = self.inner.call(req);
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! CORS layer factory for the daemon HTTP server.
|
||||
|
||||
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
|
||||
|
||||
/// Return a permissive CorsLayer for local daemon IPC.
|
||||
pub fn default_cors_layer() -> CorsLayer {
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::any())
|
||||
.allow_methods([
|
||||
"GET".parse().unwrap(),
|
||||
"POST".parse().unwrap(),
|
||||
"PUT".parse().unwrap(),
|
||||
"DELETE".parse().unwrap(),
|
||||
"PATCH".parse().unwrap(),
|
||||
"OPTIONS".parse().unwrap(),
|
||||
])
|
||||
.allow_headers(AllowHeaders::any())
|
||||
.expose_headers([
|
||||
"Content-Type".parse().unwrap(),
|
||||
"X-Session-Id".parse().unwrap(),
|
||||
"X-Request-Id".parse().unwrap(),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Axum middleware tower for the HTTP API layer.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cors;
|
||||
pub mod rate_limit;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
fn claude_credentials_from_file() -> Option<(String, String)> {
|
||||
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
|
||||
let env = settings.env?;
|
||||
let base_url = env.anthropic_base_url?;
|
||||
let key = env.anthropic_api_key?;
|
||||
Some((base_url, key))
|
||||
}
|
||||
|
||||
fn claude_credentials_from_env() -> 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))
|
||||
}
|
||||
|
||||
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
||||
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
||||
Some(ProviderConfig {
|
||||
api_base: base_url,
|
||||
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
default_model: None,
|
||||
default_api_key: Some(key),
|
||||
})
|
||||
}
|
||||
|
||||
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) = detect_claude_settings_provider() {
|
||||
cfg.providers
|
||||
.entry("claude".to_string())
|
||||
.or_insert(claude_provider);
|
||||
|
||||
let claude_models: [(&str, &str); 3] = [
|
||||
("claude-opus-4-8", "claude-opus-4-8"),
|
||||
("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 cfg.default_provider == defaults.default_provider {
|
||||
cfg.default_provider = "claude".to_string();
|
||||
cfg.default_model = "claude-opus-4-8".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! JSONL file–backed `EditLogRepository`.
|
||||
//! Stores `EditLog` as an append-only newline-delimited JSON file.
|
||||
|
||||
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) -> Vec<EditLogEntry> {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries: Vec<EditLogEntry> = Vec::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.remove(0);
|
||||
}
|
||||
entries.push(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(entry);
|
||||
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
||||
log.entries.remove(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
||||
log.entries.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
fn build_frontmatter(memory: &Memory) -> String {
|
||||
let outcome_line = memory
|
||||
.outcome
|
||||
.as_ref()
|
||||
.map(|o| format!("outcome: {o}\n"))
|
||||
.unwrap_or_default();
|
||||
let scope_line = memory
|
||||
.scope
|
||||
.as_ref()
|
||||
.map(|s| format!("scope: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let before_line = memory
|
||||
.before_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("before: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let after_line = memory
|
||||
.after_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("after: {s}\n"))
|
||||
.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()),
|
||||
lifecycle: front
|
||||
.get("lifecycle")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "new".to_string()),
|
||||
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
||||
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().unwrap();
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,39 @@
|
||||
//! 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 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).unwrap_or_default();
|
||||
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(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
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");
|
||||
let _ = 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,
|
||||
};
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! 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 iam::{
|
||||
oauth_repo::FileSystemOAuthRepository,
|
||||
session_lock_repo::FileSystemSessionLockRepository,
|
||||
session_repo::FileSystemSessionRepository,
|
||||
};
|
||||
pub use cms::{
|
||||
app_config_repo::JsonAppConfigRepository,
|
||||
conversation_repo::JsonConversationRepository,
|
||||
edit_log_repo::JsonlEditLogRepository,
|
||||
memory_repo::MarkdownMemoryRepository,
|
||||
rewind_blob_repo::FileRewindBlobRepository,
|
||||
settings_repo::JsonSettingsRepository,
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! SQLite database connection management and schema migrations.
|
||||
|
||||
pub mod database;
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Post-edit auto-review subagent — validates file edits and suggests
|
||||
//! improvements.
|
||||
|
||||
pub mod pending;
|
||||
pub mod probe;
|
||||
pub mod prompt;
|
||||
pub mod staleness;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Pending review queue — tracks files modified by tools that have not
|
||||
//! yet been reviewed.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// A file mutation awaiting review.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingReview {
|
||||
pub path: String,
|
||||
pub tool: String,
|
||||
pub reason: String,
|
||||
pub content_sha256: String,
|
||||
}
|
||||
|
||||
/// Queue of files modified but not yet reviewed.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PendingReviewQueue {
|
||||
entries: VecDeque<PendingReview>,
|
||||
}
|
||||
|
||||
impl PendingReviewQueue {
|
||||
pub fn new() -> Self {
|
||||
PendingReviewQueue {
|
||||
entries: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: PendingReview) {
|
||||
self.entries.push_back(entry);
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<PendingReview> {
|
||||
self.entries.pop_front()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Review probe — diff analysis and file inspection for review purposes.
|
||||
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
/// Compute a simple unified diff between old and new text.
|
||||
pub fn compute_diff(old: &str, new: &str) -> String {
|
||||
let diff = TextDiff::from_lines(old, new);
|
||||
let mut result = String::new();
|
||||
for change in diff.iter_all_changes() {
|
||||
let sign = match change.tag() {
|
||||
ChangeTag::Delete => "-",
|
||||
ChangeTag::Insert => "+",
|
||||
ChangeTag::Equal => " ",
|
||||
};
|
||||
result.push_str(&format!("{}{}", sign, change.value()));
|
||||
}
|
||||
result
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user