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,92 @@
//! Pure AppConfig entity — provider registry, model roles, and default model
//! selections.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`AppConfigRepository`](super::repository::AppConfigRepository).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Top-level application config: registered providers, named model roles,
/// and which provider/model to use by default.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub providers: HashMap<String, ProviderConfig>,
pub model_roles: HashMap<String, ModelRole>,
pub default_provider: String,
pub default_model: String,
pub default_context_window: u32,
}
/// Connection details for a single LLM provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub api_base: String,
pub api_key_env: Option<String>,
pub default_model: Option<String>,
pub default_api_key: Option<String>,
}
/// A named role (e.g. "default") mapping to a specific provider/model and
/// its generation parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRole {
pub provider: String,
pub model: String,
pub max_tokens: Option<u32>,
pub context_window: Option<u32>,
pub temperature: Option<f32>,
}
impl Default for AppConfig {
fn default() -> Self {
let mut providers = HashMap::new();
providers.insert(
"zen".to_string(),
ProviderConfig {
api_base: "https://opencode.ai/zen/v1".to_string(),
api_key_env: Some("API_KEY".to_string()),
default_model: Some("deepseek-v4-flash-free".to_string()),
default_api_key: None,
},
);
providers.insert(
"router".to_string(),
ProviderConfig {
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
api_key_env: Some("ROUTER_API_KEY".to_string()),
default_model: Some("claude-opus-4-8".to_string()),
default_api_key: None,
},
);
let mut model_roles = HashMap::new();
model_roles.insert(
"default".to_string(),
ModelRole {
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
max_tokens: None,
context_window: None,
temperature: Some(0.7),
},
);
Self {
providers,
model_roles,
default_provider: "zen".to_string(),
default_model: "deepseek-v4-flash-free".to_string(),
default_context_window: 256_000,
}
}
}
@@ -0,0 +1,145 @@
//! Pure Conversation entity — in-memory message history plus system prompt
//! and LLM generation parameters.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`ConversationRepository`](super::repository::ConversationRepository).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use serde::{Deserialize, Serialize};
/// A single message role / content pair.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role {
#[serde(rename = "user")]
User,
#[serde(rename = "assistant")]
Assistant,
#[serde(rename = "system")]
System,
#[serde(rename = "tool")]
Tool,
}
/// A single message in a conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: Role,
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl ChatMessage {
/// Build a user-role message with the given text content.
pub fn user(content: impl Into<String>) -> Self {
Self {
role: Role::User,
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
/// Build an assistant-role message with an optional text response.
pub fn assistant(content: Option<String>) -> Self {
Self {
role: Role::Assistant,
content,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
/// Build a system-role message with the given instruction text.
pub fn system(content: impl Into<String>) -> Self {
Self {
role: Role::System,
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
/// Build a tool-role result message referencing a prior tool call.
pub fn tool(tool_call_id: String, content: String) -> Self {
Self {
role: Role::Tool,
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call_id),
name: None,
}
}
}
/// A single conversation's message history and generation settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
pub messages: Vec<ChatMessage>,
pub system_prompt: String,
pub session_id: String,
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
}
impl Conversation {
/// Create an empty conversation with the given system prompt and
/// session id, using default model / token / temperature settings.
pub fn new(system_prompt: String, session_id: String) -> Self {
Self {
messages: Vec::new(),
system_prompt,
session_id,
model: "anthropic/claude-opus-4-8".to_string(),
max_tokens: None,
temperature: None,
}
}
/// Append a message to the conversation history.
pub fn push(&mut self, msg: ChatMessage) {
self.messages.push(msg);
}
/// Replace the system prompt and strip any prior `System`-role messages
/// from history.
pub fn rebuild_system(&mut self, new_prompt: String) {
self.system_prompt = new_prompt;
self.messages.retain(|m| !matches!(m.role, Role::System));
}
/// Build the message list to send to the LLM API, with the system
/// prompt prepended as the first message.
pub fn to_api_messages(&self) -> Vec<ChatMessage> {
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
msgs.push(ChatMessage::system(&self.system_prompt));
msgs.extend(self.messages.iter().cloned());
msgs
}
/// Number of messages in the conversation history (excluding the
/// synthesized system message).
pub fn len(&self) -> usize {
self.messages.len()
}
/// Returns `true` if the conversation has no messages.
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Pure EditLog entities — append-only log of file mutations for audit / undo.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`EditLogRepository`](super::repository::EditLogRepository).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use serde::{Deserialize, Serialize};
/// A single recorded file edit: which tool made it, to which path, why,
/// and a content hash/size delta for verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditLogEntry {
pub ts: i64,
pub tool: String,
pub path: String,
pub reason: String,
pub content_sha256: String,
pub bytes_delta: i64,
pub origin: String,
pub session_id: String,
}
/// Maximum number of edit entries held in memory at once.
/// Beyond this limit, old entries are dropped from the in-memory cache
/// to prevent unbounded memory growth in long sessions.
pub const MAX_MEMORY_ENTRIES: usize = 10_000;
/// In-memory view of a session's edit log.
#[derive(Debug, Clone)]
pub struct EditLog {
pub entries: Vec<EditLogEntry>,
}
impl EditLog {
/// Create an empty edit log.
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
/// Return the number of in-memory entries.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Return `true` if the log is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for EditLog {
fn default() -> Self {
Self::new()
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Pure Memory entity — long-term agent memory with frontmatter metadata
//! and free-form markdown content.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`MemoryRepository`](super::repository::MemoryRepository).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// A single memory entry (lesson, reference, etc.) with frontmatter
/// metadata and free-form markdown content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
pub name: String,
pub description: String,
pub content: String,
pub kind: String,
pub created_at: i64,
pub updated_at: i64,
pub outcome: Option<String>,
pub lifecycle: String,
pub scope: Option<String>,
pub before_snippet: Option<String>,
pub after_snippet: Option<String>,
pub provenances: Vec<String>,
}
impl Memory {
/// Convert an arbitrary string into a filesystem-safe slug.
///
/// Flow: lowercase → replace non-alphanumeric chars with `-` →
/// collapse/trim repeated `-`.
///
/// Returns `None` if the result is empty or exceeds 80 characters.
pub fn slugify(s: &str) -> Option<String> {
let slug: String = s
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let slug: String = slug
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() || slug.len() > 80 {
return None;
}
Some(slug)
}
/// Compute the on-disk path for a memory of the given name.
///
/// This is a **pure** computation: it takes `memory_dir` as a parameter
/// and performs no I/O itself.
///
/// Falls back to `"memory.md"` when `name` slugifies to nothing.
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
let clean: String = format!("{slug}.md")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
c
} else {
'-'
}
})
.collect();
let clean = clean.trim_start_matches('.').to_string();
memory_dir.join(if clean.is_empty() {
"memory.md".to_string()
} else {
clean
})
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Domain layer — pure entities, repository traits, and service trait definitions.
//!
//! This layer has zero infrastructure dependencies; all I/O is expressed through
//! repository traits defined in [`repository`].
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
pub mod app_config;
pub mod conversation;
pub mod edit_log;
pub mod memory;
pub mod repository;
pub mod service;
pub mod settings;
pub use app_config::AppConfig;
pub use app_config::ModelRole;
pub use app_config::ProviderConfig;
pub use conversation::Conversation;
pub use edit_log::EditLog;
pub use edit_log::EditLogEntry;
pub use memory::Memory;
pub use repository::AppConfigRepository;
pub use repository::ConversationRepository;
pub use repository::EditLogRepository;
pub use repository::MemoryRepository;
pub use repository::SettingsRepository;
pub use service::ConversationService;
pub use service::MemoryService;
pub use service::SettingsService;
pub use settings::InternetMode;
pub use settings::Settings;
pub use settings::SettingsFlags;
@@ -0,0 +1,76 @@
//! Repository traits — pure abstraction boundaries for persistence.
//!
//! Each trait defines load / save / query operations that infrastructure
//! adapters implement. The domain and application layers depend only on
//! these traits, never on concrete persistence implementations.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::path::Path;
use anyhow::Result;
use super::app_config::AppConfig;
use super::conversation::Conversation;
use super::edit_log::{EditLog, EditLogEntry};
use super::memory::Memory;
use super::settings::Settings;
/// Persistence contract for `Settings`.
pub trait SettingsRepository {
/// Load settings from a base directory.
fn load(&self, base_dir: &Path) -> Result<Settings>;
/// Save settings to a base directory.
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()>;
}
/// Persistence contract for `AppConfig`.
pub trait AppConfigRepository {
/// Load app config from a base directory.
fn load(&self, base_dir: &Path) -> Result<AppConfig>;
/// Save app config to a base directory.
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()>;
}
/// Persistence contract for `Conversation`.
pub trait ConversationRepository {
/// Load a conversation from a session directory.
fn load(&self, session_dir: &Path) -> Result<Conversation>;
/// Save a conversation to a session directory.
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()>;
}
/// Persistence contract for `Memory`.
pub trait MemoryRepository {
/// List all memory slugs in a memory directory.
fn list(&self, memory_dir: &Path) -> Result<Vec<String>>;
/// Load a single memory by name.
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory>;
/// Save (create or update) a memory.
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()>;
/// Delete a memory by name.
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()>;
}
/// Persistence contract for `EditLog`.
pub trait EditLogRepository {
/// Open (or start tracking) the edit log for a session directory.
fn open(&self, session_dir: &Path) -> Result<EditLog>;
/// Append one entry, persisting it immediately.
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()>;
/// Return a reference to all in-memory entries.
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
}
+53
View File
@@ -0,0 +1,53 @@
//! Service trait definitions — use-case boundaries for CMS operations.
//!
//! These traits are implemented by the application layer and consumed by
//! infrastructure adapters (HTTP handlers, CLI commands, etc.).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use anyhow::Result;
use super::conversation::{ChatMessage, Conversation};
use super::memory::Memory;
use super::settings::Settings;
/// Settings use cases.
pub trait SettingsService {
/// Load current settings from the default store.
fn load_settings(&self) -> Result<Settings>;
/// Persist updated settings.
fn save_settings(&self, settings: &Settings) -> Result<()>;
/// Update the provider configuration (name and details).
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) -> Result<()>;
}
/// Conversation management use cases.
pub trait ConversationService {
/// Load a conversation for the given session id.
fn load_conversation(&self, session_id: &str) -> Result<Conversation>;
/// Persist a conversation.
fn save_conversation(&self, conv: &Conversation) -> Result<()>;
/// Append a single message and persist.
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()>;
}
/// Memory management use cases.
pub trait MemoryService {
/// List all memory slugs.
fn list_memories(&self) -> Result<Vec<String>>;
/// Save (create or update) a memory.
fn save_memory(&self, memory: &Memory) -> Result<()>;
/// Delete a memory by name.
fn delete_memory(&self, name: &str) -> Result<()>;
}
+88
View File
@@ -0,0 +1,88 @@
//! Pure Settings entity — user configuration for LLM provider, model,
//! generation parameters, and feature flags.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`SettingsRepository`](super::repository::SettingsRepository).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Controls how much network access the agent is permitted during a session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum InternetMode {
#[default]
Off,
ReadOnly,
Full,
}
/// Boolean flags grouped to keep the top-level [`Settings`] struct below
/// clippy's default-too-many-fields threshold.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsFlags {
pub review_enabled: bool,
pub session_archive_enabled: bool,
pub lsp_auto_provision: bool,
}
impl Default for SettingsFlags {
fn default() -> Self {
Self {
review_enabled: true,
session_archive_enabled: true,
lsp_auto_provision: true,
}
}
}
/// Top-level application settings.
///
/// Serialized to `settings.json` by the infrastructure layer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub internet_mode: InternetMode,
pub provider: String,
pub model: String,
pub api_keys: HashMap<String, String>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub review_max_lessons_per_run: usize,
pub adaptive_review_max_skip: u32,
pub verify_command: Option<String>,
pub verify_timeout_ms: u64,
pub workflow_max_concurrency: usize,
#[serde(flatten)]
pub flags: SettingsFlags,
pub lsp_languages: Vec<String>,
pub hive_mind_node_timeout_ms: u64,
}
impl Default for Settings {
fn default() -> Self {
Self {
internet_mode: InternetMode::Off,
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
api_keys: HashMap::new(),
max_tokens: None,
temperature: None,
review_max_lessons_per_run: 5,
adaptive_review_max_skip: 3,
verify_command: None,
verify_timeout_ms: 30_000,
workflow_max_concurrency: 5,
flags: SettingsFlags::default(),
lsp_languages: Vec::new(),
hive_mind_node_timeout_ms: 600_000,
}
}
}