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:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
@@ -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;