refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
//! `cd` tool: verify and resolve a workspace-relative directory path.
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Tool that resolves a workspace-relative path and reports whether it exists and is a dir.
|
||||
pub struct Cd;
|
||||
|
||||
impl Tool for Cd {
|
||||
fn name(&self) -> &'static str {
|
||||
"cd"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Check if a directory exists within the workspace and print its resolved path. Use this to verify a directory path before running other commands there."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `path` against the workspace roots and report its status.
|
||||
///
|
||||
/// Flow: extract `path` → `resolve_path` (sandboxed to `ctx.workspaces`) →
|
||||
/// check `exists()` and `is_dir()` → canonicalize → return canonical path.
|
||||
///
|
||||
/// Why: the agent has no persistent cwd between tool calls; "cd" here is purely a
|
||||
/// verification + canonicalization helper rather than a state change.
|
||||
///
|
||||
/// Return: canonical path on success; explicit "does not exist" / "not a directory"
|
||||
/// message (still `Ok`) so the model can react without treating it as an error.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
||||
|
||||
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(format!(
|
||||
"path '{}' does not exist (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Ok(format!(
|
||||
"path '{}' is not a directory (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let canon = path.canonicalize().unwrap_or(path);
|
||||
Ok(format!("{}", canon.display()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Tool for refreshing the shared workspace directory cache.
|
||||
//!
|
||||
//! Flow: resolve the requested path against the workspace roots →
|
||||
//! non-recursively walk it → spin up a one-shot Tokio runtime (the agent
|
||||
//! turn runs on a plain `std::thread` with no async context) → write the
|
||||
//! entries into the shared `dir_cache` behind an async `RwLock`.
|
||||
//!
|
||||
//! Why: other tools rely on this cache for faster path resolution, so
|
||||
//! it must be kept fresh on demand rather than only populated at startup.
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Tool that refreshes the shared directory cache for a given path.
|
||||
pub struct DirCacheUpdate;
|
||||
|
||||
impl Tool for DirCacheUpdate {
|
||||
fn name(&self) -> &'static str {
|
||||
"dir_cache_update"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Update the cached directory listing for a path. The directory cache is used by other tools for faster path resolution."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to cache (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `path`, walk its immediate entries, and store them in the shared cache.
|
||||
///
|
||||
/// Flow: extract `path` argument → resolve against workspace roots →
|
||||
/// bail early with a plain message (not an error) if it doesn't exist
|
||||
/// → `walk_directory` collects direct children → spawn a temporary
|
||||
/// Tokio runtime to acquire the async `RwLock` write guard and call
|
||||
/// `cache.set(entries)`.
|
||||
///
|
||||
/// Why: uses a fresh one-shot runtime instead of `ctx`'s own executor
|
||||
/// because this tool can be invoked from a non-async thread.
|
||||
///
|
||||
/// Return: a confirmation string with the entry count, or an error if
|
||||
/// the `path` argument is missing or the temp runtime fails to start.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
||||
|
||||
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(format!(
|
||||
"path '{}' does not exist (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let entries = walk_directory(&path);
|
||||
let count = entries.len();
|
||||
let dc = ctx.dir_cache.clone();
|
||||
|
||||
// Create a one-shot runtime so this tool works from any thread (the
|
||||
// agent turn runs on a std::thread that has no tokio context).
|
||||
let rt = tokio::runtime::Runtime::new()
|
||||
.map_err(|e| anyhow!("failed to create temp runtime: {e}"))?;
|
||||
rt.block_on(async {
|
||||
let cache = dc.write().await;
|
||||
cache.set(entries).await;
|
||||
});
|
||||
|
||||
Ok(format!("cached {count} entries for {rel}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-recursively list the immediate entries of `path`.
|
||||
///
|
||||
/// Flow: `read_dir` → flatten Ok entries → collect their paths.
|
||||
///
|
||||
/// Why: silently skips unreadable entries (e.g. permission errors)
|
||||
/// rather than failing the whole cache update.
|
||||
///
|
||||
/// Return: paths of direct children; empty vec if `path` can't be read.
|
||||
fn walk_directory(path: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
result.push(entry.path());
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Tool for listing the immediate contents of a workspace directory.
|
||||
//!
|
||||
//! Flow: resolve the requested path against workspace roots → validate
|
||||
//! it exists and is a directory → read its direct children with
|
||||
//! `fs::read_dir`, tagging subdirectories with a trailing `/` → format
|
||||
//! into a header + newline-joined listing.
|
||||
//!
|
||||
//! Why: gives the agent a quick, one-level view of the workspace
|
||||
//! structure without pulling in the full recursive directory cache.
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
/// Tool that lists the immediate contents of a workspace directory.
|
||||
pub struct DirList;
|
||||
|
||||
impl Tool for DirList {
|
||||
fn name(&self) -> &'static str {
|
||||
"dir_list"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List files and directories in a directory. Use this to explore the workspace structure."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to list (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
/// List the immediate entries of the requested workspace directory.
|
||||
///
|
||||
/// Flow: extract `path` argument → resolve against workspace roots →
|
||||
/// short-circuit with a plain message if the path doesn't exist or
|
||||
/// isn't a directory → `read_dir` → map each entry to its name
|
||||
/// (appending `/` for subdirectories) → join into a formatted listing
|
||||
/// with an entry-count header showing the canonicalized path.
|
||||
///
|
||||
/// Why: entries whose metadata fails to read (`e.ok()` filter) are
|
||||
/// silently skipped rather than aborting the whole listing.
|
||||
///
|
||||
/// Return: header + newline-joined entry names, or an error if the
|
||||
/// `path` argument is missing or `read_dir` fails outright.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
||||
|
||||
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(format!(
|
||||
"path '{}' does not exist (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Ok(format!(
|
||||
"path '{}' is not a directory (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let entries: Vec<String> = fs::read_dir(&path)
|
||||
.map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
|
||||
if is_dir {
|
||||
format!("{name}/")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let canon = path.canonicalize().unwrap_or(path);
|
||||
let header = format!("{} entries in {}:\n", entries.len(), canon.display());
|
||||
if entries.is_empty() {
|
||||
Ok(format!("{} (empty directory)", header.trim()))
|
||||
} else {
|
||||
Ok(header + &entries.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Small standalone utility tools (cd, dir listing/caching, pong, todowrite).
|
||||
pub mod cd;
|
||||
pub mod dir_cache_update;
|
||||
pub mod dir_list;
|
||||
pub mod pong;
|
||||
pub mod todofinish;
|
||||
pub mod todowrite;
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Trivial connectivity-check tool.
|
||||
//!
|
||||
//! Flow: read the optional `message` argument → echo it back prefixed
|
||||
//! with `"pong: "`, defaulting to `"pong"` when no message is supplied.
|
||||
//!
|
||||
//! Why: gives callers a cheap, dependency-free way to verify the tool
|
||||
//! harness is reachable and responding before running real work.
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Tool that echoes back a message; used for connectivity/latency checks.
|
||||
pub struct Pong;
|
||||
|
||||
impl Tool for Pong {
|
||||
fn name(&self) -> &'static str {
|
||||
"pong"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Simple connectivity check. Echoes back any input for health checks and latency testing."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Message to echo back"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let msg = args
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("pong");
|
||||
Ok(format!("pong: {msg}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Tool for marking tasks as finished in the session's todo list.
|
||||
use super::super::{Tool, ToolCtx};
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Tool that marks tasks as finished in the session's todo.md.
|
||||
pub struct Todofinish;
|
||||
|
||||
impl Tool for Todofinish {
|
||||
fn name(&self) -> &'static str {
|
||||
"todofinish"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Mark tasks as finished in the session todo list (todo.md). You can mark all tasks as finished by leaving the 'task_index' empty, or specify a 1-based index to finish a specific task."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_index": {
|
||||
"type": "integer",
|
||||
"description": "Optional 1-based index of the task to mark as finished. If omitted, ALL unfinished tasks will be marked as finished."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let path: PathBuf = ctx.session_dir.join("todo.md");
|
||||
if !path.exists() {
|
||||
return Ok("No todo.md found in session directory. Nothing to finish.".to_string());
|
||||
}
|
||||
|
||||
let content =
|
||||
std::fs::read_to_string(&path).map_err(|e| anyhow!("failed to read todo.md: {e}"))?;
|
||||
|
||||
let task_index = args.get("task_index").and_then(serde_json::Value::as_i64);
|
||||
|
||||
let mut new_content = String::new();
|
||||
let mut task_count = 0;
|
||||
let mut modified = false;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.trim_start().starts_with("- [ ]") {
|
||||
task_count += 1;
|
||||
if let Some(target) = task_index {
|
||||
if task_count == target {
|
||||
new_content.push_str(&line.replacen("- [ ]", "- [x]", 1));
|
||||
modified = true;
|
||||
} else {
|
||||
new_content.push_str(line);
|
||||
}
|
||||
} else {
|
||||
// Mark all as finished
|
||||
new_content.push_str(&line.replacen("- [ ]", "- [x]", 1));
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
new_content.push_str(line);
|
||||
}
|
||||
new_content.push('\n');
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return Ok("No unfinished tasks found or index out of bounds.".to_string());
|
||||
}
|
||||
|
||||
std::fs::write(&path, new_content)
|
||||
.map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
|
||||
|
||||
if let Some(idx) = task_index {
|
||||
Ok(format!("Successfully marked task {idx} as finished."))
|
||||
} else {
|
||||
Ok("Successfully marked ALL tasks as finished.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Tool for appending timestamped tasks to the session's todo list.
|
||||
//!
|
||||
//! Flow: extract the `task` argument → format a Markdown checkbox line
|
||||
//! with a UTC timestamp → open `todo.md` in the session directory
|
||||
//! (creating it if needed) in append mode → write the line.
|
||||
//!
|
||||
//! Why: the file lives under `ctx.session_dir` so it persists per
|
||||
//! session and is picked up by the TUI's Todo panel; appending (rather
|
||||
//! than rewriting) keeps prior tasks intact.
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Tool that appends a timestamped task line to the session's todo.md.
|
||||
pub struct Todowrite;
|
||||
|
||||
impl Tool for Todowrite {
|
||||
fn name(&self) -> &'static str {
|
||||
"todowrite"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Append a task to the session todo list. The todo persists in the session directory and is visible in the Todo panel."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "Task description to add"
|
||||
}
|
||||
},
|
||||
"required": ["task"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Append a timestamped, unchecked task line to the session's `todo.md`.
|
||||
///
|
||||
/// Flow: extract `task` argument → build `- [ ] <task> (<timestamp>)`
|
||||
/// line with a UTC `%Y-%m-%d %H:%M:%S` timestamp → open (create if
|
||||
/// missing) `<session_dir>/todo.md` in append mode → write the line.
|
||||
///
|
||||
/// Why: append-only so the file acts as a running log rather than
|
||||
/// requiring the agent to track and rewrite existing content.
|
||||
///
|
||||
/// Return: confirmation string echoing the added task, or an error
|
||||
/// if the `task` argument is missing or the file can't be opened/written.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let task = args
|
||||
.get("task")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: task"))?;
|
||||
|
||||
let path: PathBuf = ctx.session_dir.join("todo.md");
|
||||
let now = chrono::Utc::now();
|
||||
let timestamp = now.format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("- [ ] {task} ({timestamp})\n");
|
||||
|
||||
fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| anyhow!("failed to open todo.md: {e}"))?
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
|
||||
|
||||
Ok(format!("added task to todo.md: {task}"))
|
||||
}
|
||||
}
|
||||
|
||||
use std::io::Write;
|
||||
Reference in New Issue
Block a user