docs: tambah doc comment, logging, dan inline comments di semua 255 file
Meliputi: - File-level //! doc comment: tujuan file, alur kerja, komponen utama - Function-level /// doc comment: apa, parameter, return, flow, edge cases - Struct/enum/trait /// doc comment: peran, field docs - Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi - Inline comments untuk variable dan branching logic penting - Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities, zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils - Build: 0 errors, 242/242 tests passed
This commit is contained in:
@@ -1,23 +1,46 @@
|
||||
//! Conversation use-case implementations.
|
||||
//! Conversation use-case implementations for the CMS.
|
||||
//!
|
||||
//! `ConversationServiceImpl` is generic over `R: ConversationRepository`,
|
||||
//! delegating all persistence to that adapter.
|
||||
//! `ConversationServiceImpl` implements `ConversationService` (defined in
|
||||
//! `domain::service`) and is generic over `R: ConversationRepository`
|
||||
//! (defined in `domain::repository`), delegating all persistence to that
|
||||
//! adapter. The repository is injected at composition root.
|
||||
//!
|
||||
//! ## Flow
|
||||
//! Each method computes the session directory from the session ID, then
|
||||
//! delegates the actual I/O to the injected `repo`. Error context is
|
||||
//! added at this layer to identify which session caused the failure.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing;
|
||||
|
||||
use crate::domain::conversation::{ChatMessage, Conversation};
|
||||
use crate::domain::repository::ConversationRepository;
|
||||
use crate::domain::service::ConversationService;
|
||||
|
||||
/// Generic conversation service backed by an injected repository.
|
||||
/// Service implementation for conversation CRUD operations.
|
||||
///
|
||||
/// Generic over `R: ConversationRepository` so the persistence layer
|
||||
/// can be swapped without changing business logic.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `repo` — injected conversation repository implementation
|
||||
/// - `sessions_dir` — base path under which session directories live
|
||||
pub struct ConversationServiceImpl<R> {
|
||||
pub repo: R,
|
||||
pub sessions_dir: std::path::PathBuf,
|
||||
/// Base directory containing session subdirectories.
|
||||
pub sessions_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
||||
/// Create a new service with the given repository and sessions directory.
|
||||
pub fn new(repo: R, sessions_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `repo` — the repository adapter to delegate persistence to
|
||||
/// - `sessions_dir` — base path for session directories (converted via `Into`)
|
||||
pub fn new(repo: R, sessions_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating ConversationServiceImpl");
|
||||
Self {
|
||||
repo,
|
||||
sessions_dir: sessions_dir.into(),
|
||||
@@ -25,20 +48,30 @@ impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
||||
}
|
||||
|
||||
/// Compute the session directory for a given session id.
|
||||
fn session_dir(&self, session_id: &str) -> std::path::PathBuf {
|
||||
///
|
||||
/// Returns `{sessions_dir}/{session_id}`.
|
||||
fn session_dir(&self, session_id: &str) -> PathBuf {
|
||||
self.sessions_dir.join(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<R> {
|
||||
/// Load a conversation from disk for the given session.
|
||||
///
|
||||
/// Flow: resolve session dir → delegate to repo.load() → wrap error with context.
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation> {
|
||||
tracing::debug!("loading conversation for session {session_id}");
|
||||
let dir = self.session_dir(session_id);
|
||||
self.repo
|
||||
.load(&dir)
|
||||
.with_context(|| format!("failed to load conversation for session '{session_id}'"))
|
||||
}
|
||||
|
||||
/// Persist a conversation to disk.
|
||||
///
|
||||
/// Flow: resolve session dir from conv.session_id → delegate to repo.save() → wrap error.
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
|
||||
tracing::debug!("saving conversation for session {}", conv.session_id);
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo.save(&dir, conv).with_context(|| {
|
||||
format!(
|
||||
@@ -48,8 +81,16 @@ impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a message to a conversation and persist immediately.
|
||||
///
|
||||
/// Flow: push message to in-memory conversation → resolve session dir → delegate save.
|
||||
///
|
||||
/// ## Note
|
||||
/// This is a write-through operation: the message is appended to the
|
||||
/// in-memory `Conversation` and then the full conversation is persisted.
|
||||
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()> {
|
||||
conv.push(msg);
|
||||
tracing::debug!("adding message to session {}", conv.session_id);
|
||||
conv.push(msg); // append message to in-memory conversation
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo.save(&dir, conv).with_context(|| {
|
||||
format!(
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
//! Memory use-case implementations.
|
||||
//! Memory use-case implementations for the CMS.
|
||||
//!
|
||||
//! `MemoryServiceImpl` is generic over `R: MemoryRepository`, delegating
|
||||
//! all persistence to that adapter.
|
||||
//! `MemoryServiceImpl` implements `MemoryService` (defined in
|
||||
//! `domain::service`) and is generic over `R: MemoryRepository`
|
||||
//! (defined in `domain::repository`), delegating all persistence to that
|
||||
//! adapter. The repository is injected at composition root.
|
||||
//!
|
||||
//! ## Flow
|
||||
//! Each method delegates to the injected `repo` with the configured
|
||||
//! `memory_dir`. Error context is added at this layer to identify which
|
||||
//! memory operation failed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing;
|
||||
|
||||
use crate::domain::memory::Memory;
|
||||
use crate::domain::repository::MemoryRepository;
|
||||
use crate::domain::service::MemoryService;
|
||||
|
||||
/// Generic memory service backed by an injected repository.
|
||||
/// Service implementation for memory CRUD operations.
|
||||
///
|
||||
/// Generic over `R: MemoryRepository` so the persistence layer can be
|
||||
/// swapped without changing business logic.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `repo` — injected memory repository implementation
|
||||
/// - `memory_dir` — base path where memory files are stored
|
||||
pub struct MemoryServiceImpl<R> {
|
||||
pub repo: R,
|
||||
pub memory_dir: std::path::PathBuf,
|
||||
/// Base directory for memory storage files.
|
||||
pub memory_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> MemoryServiceImpl<R> {
|
||||
/// Create a new service with the given repository and memory directory.
|
||||
pub fn new(repo: R, memory_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `repo` — the repository adapter to delegate persistence to
|
||||
/// - `memory_dir` — base path for memory files (converted via `Into`)
|
||||
pub fn new(repo: R, memory_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating MemoryServiceImpl");
|
||||
Self {
|
||||
repo,
|
||||
memory_dir: memory_dir.into(),
|
||||
@@ -26,19 +49,31 @@ impl<R: MemoryRepository> MemoryServiceImpl<R> {
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> MemoryService for MemoryServiceImpl<R> {
|
||||
/// List all stored memory names.
|
||||
///
|
||||
/// Flow: delegate to repo.list() → wrap error with context.
|
||||
fn list_memories(&self) -> Result<Vec<String>> {
|
||||
tracing::debug!("listing memories from {:?}", self.memory_dir);
|
||||
self.repo
|
||||
.list(&self.memory_dir)
|
||||
.context("failed to list memories")
|
||||
}
|
||||
|
||||
/// Persist a memory to disk.
|
||||
///
|
||||
/// Flow: delegate to repo.save() → wrap error with memory name context.
|
||||
fn save_memory(&self, memory: &Memory) -> Result<()> {
|
||||
tracing::debug!("saving memory '{}'", memory.name);
|
||||
self.repo
|
||||
.save(&self.memory_dir, memory)
|
||||
.with_context(|| format!("failed to save memory '{}'", memory.name))
|
||||
}
|
||||
|
||||
/// Delete a memory by name.
|
||||
///
|
||||
/// Flow: delegate to repo.delete() → wrap error with memory name context.
|
||||
fn delete_memory(&self, name: &str) -> Result<()> {
|
||||
tracing::debug!("deleting memory '{name}'");
|
||||
self.repo
|
||||
.delete(&self.memory_dir, name)
|
||||
.with_context(|| format!("failed to delete memory '{name}'"))
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
//! Application layer — use-case implementations.
|
||||
//! Application layer — use-case service implementations.
|
||||
//!
|
||||
//! Each service is generic over its repository trait so the concrete
|
||||
//! persistence adapter is injected at composition root.
|
||||
//! This module defines the concrete service types that orchestrate
|
||||
//! business operations. Each service is generic over its repository
|
||||
//! trait (from `domain::repository`), so the concrete persistence
|
||||
//! adapter is injected at composition root via dependency inversion.
|
||||
//!
|
||||
//! ## Services
|
||||
//! - `ConversationServiceImpl` — CRUD for conversations and messages
|
||||
//! - `MemoryServiceImpl` — CRUD for session memories
|
||||
//! - `SettingsServiceImpl` — Read/write for application settings and config
|
||||
//!
|
||||
//! ## Architecture
|
||||
//! Application services depend only on domain trait abstractions.
|
||||
//! They never reference infrastructure types directly.
|
||||
|
||||
/// Conversation use-case: create, read, update, delete conversations.
|
||||
pub mod conversation_service;
|
||||
/// Memory use-case: store, retrieve, rewrite, delete session memories.
|
||||
pub mod memory_service;
|
||||
/// Settings use-case: load, save application settings and configuration.
|
||||
pub mod settings_service;
|
||||
|
||||
pub use conversation_service::ConversationServiceImpl;
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
//! Settings use-case implementations.
|
||||
//! Settings and app-config use-case implementations for the CMS.
|
||||
//!
|
||||
//! `SettingsServiceImpl` is generic over `S: SettingsRepository` and
|
||||
//! `C: AppConfigRepository`, delegating all persistence to those adapters.
|
||||
//! `SettingsServiceImpl` implements `SettingsService` (defined in
|
||||
//! `domain::service`) and is generic over `S: SettingsRepository` and
|
||||
//! `C: AppConfigRepository` (defined in `domain::repository`), delegating
|
||||
//! persistence to those adapters. Both repositories are injected at
|
||||
//! composition root.
|
||||
//!
|
||||
//! ## Flow
|
||||
//! Each method delegates to the appropriate injected repository with the
|
||||
//! configured `base_dir`. The `update_provider` method coordinates
|
||||
//! between both repositories: load app config → mutate provider map →
|
||||
//! save app config.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing;
|
||||
|
||||
use crate::domain::app_config::{AppConfig, ProviderConfig};
|
||||
use crate::domain::repository::{AppConfigRepository, SettingsRepository};
|
||||
use crate::domain::service::SettingsService;
|
||||
use crate::domain::settings::Settings;
|
||||
|
||||
/// Generic settings service backed by injected repository implementations.
|
||||
/// Service implementation for settings and app-config operations.
|
||||
///
|
||||
/// Generic over `S: SettingsRepository` and `C: AppConfigRepository` so
|
||||
/// the persistence layer can be swapped without changing business logic.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `settings_repo` — injected settings repository implementation
|
||||
/// - `app_config_repo` — injected app-config repository implementation
|
||||
/// - `base_dir` — base path where config files are stored
|
||||
pub struct SettingsServiceImpl<S, C> {
|
||||
pub settings_repo: S,
|
||||
pub app_config_repo: C,
|
||||
pub base_dir: std::path::PathBuf,
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||
/// Create a new service with the given repositories and base directory.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `settings_repo` — the settings repository adapter
|
||||
/// - `app_config_repo` — the app-config repository adapter
|
||||
/// - `base_dir` — base path for configuration files (converted via `Into`)
|
||||
pub fn new(
|
||||
settings_repo: S,
|
||||
app_config_repo: C,
|
||||
base_dir: impl Into<std::path::PathBuf>,
|
||||
base_dir: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
tracing::debug!("creating SettingsServiceImpl");
|
||||
Self {
|
||||
settings_repo,
|
||||
app_config_repo,
|
||||
@@ -33,19 +59,39 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsService for SettingsServiceImpl<S, C> {
|
||||
/// Load application settings from disk.
|
||||
///
|
||||
/// Flow: delegate to settings_repo.load() at base_dir.
|
||||
fn load_settings(&self) -> Result<Settings> {
|
||||
tracing::debug!("loading settings");
|
||||
self.settings_repo.load(&self.base_dir)
|
||||
}
|
||||
|
||||
/// Save application settings to disk.
|
||||
///
|
||||
/// Flow: delegate to settings_repo.save() at base_dir.
|
||||
fn save_settings(&self, settings: &Settings) -> Result<()> {
|
||||
tracing::debug!("saving settings");
|
||||
self.settings_repo.save(&self.base_dir, settings)
|
||||
}
|
||||
|
||||
/// Update (or insert) a provider configuration in the app config.
|
||||
///
|
||||
/// Flow: load existing AppConfig → insert/update provider entry →
|
||||
/// persist AppConfig back to disk.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `name` — provider name (key in the providers map)
|
||||
/// - `config` — the provider configuration to store
|
||||
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> {
|
||||
tracing::debug!("updating provider '{name}'");
|
||||
// Load current app config from disk
|
||||
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
||||
// Insert or overwrite the provider entry
|
||||
app_config
|
||||
.providers
|
||||
.insert(name.to_string(), config.clone());
|
||||
// Persist the modified app config
|
||||
self.app_config_repo.save(&self.base_dir, &app_config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
//! Pure AppConfig entity — provider registry, model roles, and default model
|
||||
//! selections.
|
||||
//! Pure domain entity for application configuration.
|
||||
//!
|
||||
//! Defines `AppConfig`, `ProviderConfig`, and `ModelRole` — the data
|
||||
//! structures that describe which LLM providers are registered, which
|
||||
//! model roles exist, and which provider/model is the default.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`AppConfigRepository`](super::repository::AppConfigRepository).
|
||||
//! These are pure data structures with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `AppConfigRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. `AppConfig` is deserialised from `app_config.json` at startup
|
||||
//! 2. The HTTP handler layer calls `SettingsService::update_provider()`
|
||||
//! to mutate the provider map
|
||||
//! 3. The modified `AppConfig` is serialised back to `app_config.json`
|
||||
|
||||
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.
|
||||
/// Top-level application configuration.
|
||||
///
|
||||
/// Holds the registry of configured LLM providers, named model roles
|
||||
/// (logical profiles mapping to a provider+model pair), and the default
|
||||
/// provider/model selection.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `providers` — map of provider name → connection details
|
||||
/// - `model_roles` — map of role name → provider/model/temperature
|
||||
/// - `default_provider` — the provider to use when none is specified
|
||||
/// - `default_model` — the model to use when none is specified
|
||||
/// - `default_context_window` — fallback context window size in tokens
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
@@ -20,7 +39,13 @@ pub struct AppConfig {
|
||||
pub default_context_window: u32,
|
||||
}
|
||||
|
||||
/// Connection details for a single LLM provider.
|
||||
/// Connection details for a single LLM provider endpoint.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `api_base` — base URL for the provider API
|
||||
/// - `api_key_env` — optional environment variable name holding the API key
|
||||
/// - `default_model` — optional default model name for this provider
|
||||
/// - `default_api_key` — optional inline API key (less secure than env var)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
@@ -29,8 +54,17 @@ pub struct ProviderConfig {
|
||||
pub default_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A named role (e.g. "default") mapping to a specific provider/model and
|
||||
/// its generation parameters.
|
||||
/// A named model role mapping to a specific provider/model with parameters.
|
||||
///
|
||||
/// Roles allow the UI to present logical profiles (e.g. "fast", "reasoning")
|
||||
/// that abstract over concrete provider+model strings.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `provider` — which provider serves this role
|
||||
/// - `model` — which model to use for this role
|
||||
/// - `max_tokens` — optional maximum output token limit
|
||||
/// - `context_window` — optional context window override
|
||||
/// - `temperature` — optional generation temperature
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
@@ -40,7 +74,15 @@ pub struct ModelRole {
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Returns the default AppConfig with built-in "zen" and "router" providers.
|
||||
impl Default for AppConfig {
|
||||
/// Construct an AppConfig with the default "zen" and "router" providers.
|
||||
///
|
||||
/// ## Defaults
|
||||
/// - Zen provider: `deepseek-v4-flash-free` model
|
||||
/// - Router provider: `claude-opus-4-8` model
|
||||
/// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7
|
||||
/// - `default_context_window`: 256,000 tokens
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert(
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
//! Pure Conversation entity
|
||||
//! Pure domain entity for conversations and chat messages.
|
||||
//!
|
||||
//! Re-exported from zesdex_entities for consistency.
|
||||
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
|
||||
//! types from `zesdex_entities` to provide a consistent domain import
|
||||
//! boundary within the `zesdex-cms` crate. All CMS code references
|
||||
//! conversation types through this module rather than depending on the
|
||||
//! entities crate directly.
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//! - `Conversation` — top-level conversation container with message list
|
||||
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
||||
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
||||
|
||||
pub use zesdex_entities::domain::common::message::{ChatMessage, Role};
|
||||
pub use zesdex_entities::domain::common::conversation::Conversation;
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
//! Pure EditLog entities — append-only log of file mutations for audit / undo.
|
||||
//! Pure domain entity for the edit log — an append-only log of file mutations.
|
||||
//!
|
||||
//! Records every file mutation made by any tool, enabling audit trails
|
||||
//! and potential undo operations. Each entry captures the tool name,
|
||||
//! target path, reason, content hash, and byte delta.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`EditLogRepository`](super::repository::EditLogRepository).
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `EditLogRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. Tools call `EditLog::push()` to record each mutation
|
||||
//! 2. The in-memory `EditLog` is periodically flushed to disk by the repo
|
||||
//! 3. Oldest entries are evicted from the in-memory cache when
|
||||
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
|
||||
|
||||
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.
|
||||
/// A single recorded file edit event.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `ts` — Unix timestamp (seconds) when the edit occurred
|
||||
/// - `tool` — name of the tool that performed the edit (e.g. "Bash", "Edit")
|
||||
/// - `path` — absolute file path that was modified
|
||||
/// - `reason` — human-readable explanation of why the edit was made
|
||||
/// - `content_sha256` — SHA-256 hex digest of the content *after* the edit
|
||||
/// - `bytes_delta` — signed byte count change (+added, -removed)
|
||||
/// - `origin` — origin identifier (which agent / session context)
|
||||
/// - `session_id` — session in which this edit was performed
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditLogEntry {
|
||||
pub ts: i64,
|
||||
@@ -21,18 +40,22 @@ pub struct EditLogEntry {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// to prevent unbounded memory growth in long-running sessions.
|
||||
pub const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||
|
||||
/// In-memory view of a session's edit log.
|
||||
///
|
||||
/// Wraps a `Vec<EditLogEntry>` and provides basic query helpers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
/// Ordered list of edit entries (newest appended last).
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Create an empty edit log.
|
||||
/// Create an empty edit log with no entries.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
@@ -44,13 +67,14 @@ impl EditLog {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Return `true` if the log is empty.
|
||||
/// Return `true` if the log contains no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditLog {
|
||||
/// Returns an empty `EditLog` via `EditLog::new()`.
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
//! Pure Memory entity — long-term agent memory with frontmatter metadata
|
||||
//! and free-form markdown content.
|
||||
//! Pure domain entity for long-term agent memory.
|
||||
//!
|
||||
//! A `Memory` entry stores a named, kinded piece of information (lesson,
|
||||
//! reference, fact) with frontmatter metadata and free-form markdown
|
||||
//! content. Memories are persisted as individual `.md` files with YAML
|
||||
//! frontmatter.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`MemoryRepository`](super::repository::MemoryRepository).
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `MemoryRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Utility Functions
|
||||
//! - `slugify()` — converts a name string into a filesystem-safe slug
|
||||
//! - `path()` — computes the on-disk path for a given memory name
|
||||
//!
|
||||
//! Both are pure computations that take parameters and perform no I/O.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single memory entry (lesson, reference, etc.) with frontmatter
|
||||
/// metadata and free-form markdown content.
|
||||
/// A single memory entry with frontmatter metadata and markdown content.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `name` — unique identifier / title for this memory
|
||||
/// - `description` — short summary of what this memory contains
|
||||
/// - `content` — free-form markdown body
|
||||
/// - `kind` — category/tag (e.g. "lesson", "reference", "fact")
|
||||
/// - `created_at` — Unix timestamp of creation
|
||||
/// - `updated_at` — Unix timestamp of last modification
|
||||
/// - `outcome` — optional outcome of applying this memory
|
||||
/// - `lifecycle` — lifecycle stage (e.g. "active", "archived")
|
||||
/// - `scope` — optional scope qualifier (which session/context this applies to)
|
||||
/// - `before_snippet` — optional context snapshot before memory was applied
|
||||
/// - `after_snippet` — optional context snapshot after memory was applied
|
||||
/// - `provenances` — list of origin identifiers that created or confirmed this memory
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memory {
|
||||
pub name: String,
|
||||
@@ -34,12 +57,20 @@ impl Memory {
|
||||
/// collapse/trim repeated `-`.
|
||||
///
|
||||
/// Returns `None` if the result is empty or exceeds 80 characters.
|
||||
///
|
||||
/// ## Example
|
||||
/// ```
|
||||
/// # use zesdex_cms::domain::memory::Memory;
|
||||
/// assert_eq!(Memory::slugify("Hello World!").unwrap(), "hello-world");
|
||||
/// ```
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
// Phase 1: replace every non-alphanumeric character with '-'
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
// Phase 2: collapse consecutive '-' separators
|
||||
let slug: String = slug
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -53,10 +84,15 @@ impl Memory {
|
||||
|
||||
/// 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.
|
||||
/// ## Parameters
|
||||
/// - `memory_dir` — the base directory for memory storage
|
||||
/// - `name` — the memory name (will be slugified internally)
|
||||
///
|
||||
/// Falls back to `"memory.md"` when `name` slugifies to nothing.
|
||||
/// Falls back to `"memory.md"` when the name slugifies to an empty
|
||||
/// or invalid string.
|
||||
///
|
||||
/// ## Pure Computation
|
||||
/// This function performs **no I/O** — it only computes a path.
|
||||
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")
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
//! Domain layer — pure entities, repository traits, and service trait definitions.
|
||||
//! Domain layer — pure entities, value objects, repository traits, and service interfaces.
|
||||
//!
|
||||
//! This layer has zero infrastructure dependencies; all I/O is expressed through
|
||||
//! repository traits defined in [`repository`].
|
||||
//! This is the innermost layer of the Clean Architecture onion. It has **zero
|
||||
//! infrastructure dependencies** — all I/O is expressed through repository
|
||||
//! traits defined in [`repository`], and business operations through service
|
||||
//! traits in [`service`].
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`)
|
||||
//! - `conversation` — conversation entity + chat message model (`Conversation`, `ChatMessage`)
|
||||
//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`)
|
||||
//! - `memory` — memory file model (`Memory`)
|
||||
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
|
||||
//! - `repository` — trait definitions for all persistence adapters
|
||||
//! - `service` — trait definitions for all application services
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//! Domain types are plain Rust structs with `serde` for serialisation.
|
||||
//! They contain no I/O, no framework imports, and no side effects.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod conversation;
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
//! 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.
|
||||
//! adapters implement. The domain and application layers depend **only**
|
||||
//! on these traits, never on concrete persistence implementations.
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsRepository` — load/save `Settings` from/to a base directory
|
||||
//! - `AppConfigRepository` — load/save `AppConfig` from/to a base directory
|
||||
//! - `ConversationRepository` — load/save `Conversation` from/to a session directory
|
||||
//! - `MemoryRepository` — list/load/save/delete `Memory` entries
|
||||
//! - `RewindBlobRepository` — store/retrieve/list binary blobs per session
|
||||
//! - `EditLogRepository` — open/append/query edit log entries per session
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application services accept these traits as generic type parameters,
|
||||
//! allowing the composition root to inject concrete implementations
|
||||
//! (file-based, SQLite-backed, etc.) without changing business logic.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -14,52 +27,80 @@ use super::edit_log::{EditLog, EditLogEntry};
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Persistence contract for `Settings`.
|
||||
/// Persistence contract for `Settings` (application settings model).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait SettingsRepository {
|
||||
/// Load settings from a base directory.
|
||||
/// Load `Settings` from the given base directory.
|
||||
///
|
||||
/// Flow: read and deserialize `settings.json` from `base_dir`.
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings>;
|
||||
|
||||
/// Save settings to a base directory.
|
||||
/// Persist `Settings` to the given base directory.
|
||||
///
|
||||
/// Flow: serialize and write `settings.json` to `base_dir`.
|
||||
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `AppConfig`.
|
||||
/// Persistence contract for `AppConfig` (provider and model configuration).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait AppConfigRepository {
|
||||
/// Load app config from a base directory.
|
||||
/// Load `AppConfig` from the given base directory.
|
||||
///
|
||||
/// Flow: read and deserialize `app_config.json` from `base_dir`.
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig>;
|
||||
|
||||
/// Save app config to a base directory.
|
||||
/// Persist `AppConfig` to the given base directory.
|
||||
///
|
||||
/// Flow: serialize and write `app_config.json` to `base_dir`.
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Conversation`.
|
||||
/// Persistence contract for `Conversation` (session conversation data).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait ConversationRepository {
|
||||
/// Load a conversation from a session directory.
|
||||
/// Load a `Conversation` from the given session directory.
|
||||
///
|
||||
/// Flow: read and deserialize `conversation.json` from `session_dir`.
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation>;
|
||||
|
||||
/// Save a conversation to a session directory.
|
||||
/// Persist a `Conversation` to the given session directory.
|
||||
///
|
||||
/// Flow: serialize and write `conversation.json` to `session_dir`.
|
||||
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Memory`.
|
||||
/// Persistence contract for `Memory` (long-term agent memory entries).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. per-memory markdown files).
|
||||
pub trait MemoryRepository {
|
||||
/// List all memory slugs in a memory directory.
|
||||
/// List all memory slugs (filenames without extension) in the memory directory.
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>>;
|
||||
|
||||
/// Load a single memory by name.
|
||||
/// Load a single `Memory` by name from the memory directory.
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory>;
|
||||
|
||||
/// Save (create or update) a memory.
|
||||
/// Save (create or overwrite) a `Memory` in the memory directory.
|
||||
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()>;
|
||||
|
||||
/// Delete a memory by name.
|
||||
/// Delete a `Memory` by name from the memory directory.
|
||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Repository for rewind-snapshot binary blobs, keyed by an arbitrary
|
||||
/// caller-supplied key (e.g. a tool-call id) within a session.
|
||||
/// Persistence contract for rewind-snapshot binary blobs.
|
||||
///
|
||||
/// Blobs are keyed by an arbitrary caller-supplied key (e.g. a tool-call ID)
|
||||
/// within a session. They capture file snapshots for the "rewind" feature.
|
||||
pub trait RewindBlobRepository {
|
||||
/// Store (or overwrite) a blob under `blob_key` for this session.
|
||||
/// Store (or overwrite) a binary blob under `blob_key` for this session.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `session_dir` — the session directory to store the blob in
|
||||
/// - `blob_key` — arbitrary caller-supplied key (e.g. tool-call ID)
|
||||
/// - `data` — raw byte content of the blob
|
||||
/// - `mime_type` — optional MIME type hint
|
||||
fn store_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
@@ -68,21 +109,28 @@ pub trait RewindBlobRepository {
|
||||
mime_type: Option<&str>,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Retrieve a blob's bytes by key, or `None` if not found.
|
||||
/// Retrieve a blob's raw bytes by key, or `None` if not found.
|
||||
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> anyhow::Result<Option<Vec<u8>>>;
|
||||
|
||||
/// List all blob keys for this session, oldest first.
|
||||
/// List all blob keys for this session, ordered oldest-first.
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> anyhow::Result<Vec<String>>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `EditLog`.
|
||||
/// Persistence contract for `EditLog` (append-only file mutation log).
|
||||
///
|
||||
/// Implementors manage an append-only log of `EditLogEntry` items per session,
|
||||
/// typically persisted to a file for audit and potential undo.
|
||||
pub trait EditLogRepository {
|
||||
/// Open (or start tracking) the edit log for a session directory.
|
||||
/// Open (or initialise) the edit log for a session directory.
|
||||
///
|
||||
/// Flow: load existing log file if present, or create an empty log.
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog>;
|
||||
|
||||
/// Append one entry, persisting it immediately.
|
||||
/// Append one entry to the log and persist immediately (write-through).
|
||||
///
|
||||
/// Flow: push entry to in-memory log → append to disk file.
|
||||
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()>;
|
||||
|
||||
/// Return a reference to all in-memory entries.
|
||||
/// Return a cloned copy of all in-memory entries for inspection.
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
//! 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.).
|
||||
//! These traits define the public API of the application use-cases.
|
||||
//! They are implemented by concrete types in the `application` layer
|
||||
//! and consumed by infrastructure adapters (HTTP handlers, CLI commands).
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsService` — load/save settings, update provider config
|
||||
//! - `ConversationService` — load/save conversations, add messages
|
||||
//! - `MemoryService` — list/save/delete session memories
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application service implementations accept repository traits as generic
|
||||
//! type parameters. Infrastructure adapters depend only on these service
|
||||
//! traits, never on concrete implementations.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -9,39 +20,43 @@ use super::conversation::{ChatMessage, Conversation};
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Settings use cases.
|
||||
/// Use-cases for application settings.
|
||||
pub trait SettingsService {
|
||||
/// Load current settings from the default store.
|
||||
/// Load the current `Settings` from the default store location.
|
||||
fn load_settings(&self) -> Result<Settings>;
|
||||
|
||||
/// Persist updated settings.
|
||||
/// Persist updated `Settings` to the default store location.
|
||||
fn save_settings(&self, settings: &Settings) -> Result<()>;
|
||||
|
||||
/// Update the provider configuration (name and details).
|
||||
/// Update (or insert) a provider configuration entry.
|
||||
///
|
||||
/// Flow: load current AppConfig → mutate provider map → save.
|
||||
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig)
|
||||
-> Result<()>;
|
||||
}
|
||||
|
||||
/// Conversation management use cases.
|
||||
/// Use-cases for conversation (session message) management.
|
||||
pub trait ConversationService {
|
||||
/// Load a conversation for the given session id.
|
||||
/// Load a `Conversation` for the given session ID.
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation>;
|
||||
|
||||
/// Persist a conversation.
|
||||
/// Persist a `Conversation` to its session storage.
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<()>;
|
||||
|
||||
/// Append a single message and persist.
|
||||
/// Append a single `ChatMessage` to the conversation and persist.
|
||||
///
|
||||
/// Flow: push message to in-memory conv → persist full conversation.
|
||||
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Memory management use cases.
|
||||
/// Use-cases for long-term memory management.
|
||||
pub trait MemoryService {
|
||||
/// List all memory slugs.
|
||||
/// List all memory slugs (filenames without extension).
|
||||
fn list_memories(&self) -> Result<Vec<String>>;
|
||||
|
||||
/// Save (create or update) a memory.
|
||||
/// Save (create or overwrite) a `Memory`.
|
||||
fn save_memory(&self, memory: &Memory) -> Result<()>;
|
||||
|
||||
/// Delete a memory by name.
|
||||
/// Delete a `Memory` by its slug/name.
|
||||
fn delete_memory(&self, name: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
//! Pure Settings entity — user configuration for LLM provider, model,
|
||||
//! generation parameters, and feature flags.
|
||||
//! Pure domain entity for application settings.
|
||||
//!
|
||||
//! Defines `Settings` (top-level user configuration), `SettingsFlags`
|
||||
//! (grouped boolean toggles), and `InternetMode` (network access level).
|
||||
//! Serialised to `settings.json` by the infrastructure layer.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`SettingsRepository`](super::repository::SettingsRepository).
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `SettingsRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Settings Fields
|
||||
//! - `internet_mode` — network access policy (Off / ReadOnly / Full)
|
||||
//! - `provider` / `model` — default LLM provider and model name
|
||||
//! - `api_keys` — per-provider API key overrides (name → key)
|
||||
//! - `max_tokens` / `temperature` — generation parameter defaults
|
||||
//! - `review_max_lessons_per_run` — max lessons per auto-review pass
|
||||
//! - `verify_command` — optional shell command to run for verification
|
||||
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
|
||||
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
|
||||
//! - `flags` — grouped boolean feature toggles
|
||||
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls how much network access the agent is permitted during a session.
|
||||
///
|
||||
/// ## Variants
|
||||
/// - `Off` — no network access
|
||||
/// - `ReadOnly` — HTTP GET / HEAD only
|
||||
/// - `Full` — any HTTP method permitted
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum InternetMode {
|
||||
/// No network access permitted.
|
||||
#[default]
|
||||
Off,
|
||||
/// HTTP GET / HEAD requests only.
|
||||
ReadOnly,
|
||||
/// Any HTTP method permitted.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Boolean flags grouped to keep the top-level [`Settings`] struct below
|
||||
/// clippy's default-too-many-fields threshold.
|
||||
/// Grouped boolean feature toggles for the application.
|
||||
///
|
||||
/// Kept as a separate struct to avoid clippy's
|
||||
/// `default-too-many-fields` threshold on `Settings`.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `review_enabled` — enable automatic inline review after edits
|
||||
/// - `session_archive_enabled` — enable periodic session archiving
|
||||
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsFlags {
|
||||
pub review_enabled: bool,
|
||||
@@ -28,6 +58,7 @@ pub struct SettingsFlags {
|
||||
}
|
||||
|
||||
impl Default for SettingsFlags {
|
||||
/// Returns the default flags with all features enabled.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
review_enabled: true,
|
||||
@@ -37,13 +68,16 @@ impl Default for SettingsFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default hive-mind node timeout (600 seconds).
|
||||
fn default_hive_mind_node_timeout_ms() -> u64 {
|
||||
600_000
|
||||
}
|
||||
|
||||
/// Top-level application settings.
|
||||
/// Top-level application settings model.
|
||||
///
|
||||
/// Serialized to `settings.json` by the infrastructure layer.
|
||||
/// Serialised to `settings.json` by the infrastructure persistence layer.
|
||||
/// Holds LLM provider selection, generation parameters, feature flags,
|
||||
/// and workflow configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
//! CMS-specific DTOs (Data Transfer Objects) for the REST API.
|
||||
//! Data Transfer Objects (DTOs) for the CMS REST API.
|
||||
//!
|
||||
//! These types define the wire format accepted and returned by HTTP handlers.
|
||||
//! They are independent of the domain entities so the API contract can
|
||||
//! evolve without coupling to the domain model.
|
||||
//! They are intentionally independent of the domain entities so the API
|
||||
//! contract can evolve without coupling to the domain model.
|
||||
//!
|
||||
//! ## DTOs
|
||||
//! - `SettingsUpdateRequest` — partial-update body for PUT /settings
|
||||
//! - `SettingsResponse` — response body for GET /settings (API keys redacted)
|
||||
//! - `MemoryCreateRequest` — request body for POST /memories
|
||||
//! - `MemoryResponse` — response body for memory operations
|
||||
//! - `ConversationResponse` — response body for GET /conversation
|
||||
//!
|
||||
//! ## Conventions
|
||||
//! - `From<DomainType>` impls convert domain entities → DTO responses
|
||||
//! - API key values are **redacted** in responses (only key names exposed)
|
||||
//! - Request fields use `Option` to support partial updates
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -53,6 +65,10 @@ pub struct SettingsResponse {
|
||||
pub hive_mind_node_timeout_ms: u64,
|
||||
}
|
||||
|
||||
/// Convert a domain `Settings` entity into its API response representation.
|
||||
///
|
||||
/// ## Side-effects
|
||||
/// - API key **values are redacted** — only key names are exposed.
|
||||
impl From<crate::domain::settings::Settings> for SettingsResponse {
|
||||
fn from(s: crate::domain::settings::Settings) -> Self {
|
||||
Self {
|
||||
@@ -112,6 +128,7 @@ pub struct MemoryResponse {
|
||||
pub provenances: Vec<String>,
|
||||
}
|
||||
|
||||
/// Convert a domain `Memory` entity into its API response representation.
|
||||
impl From<crate::domain::memory::Memory> for MemoryResponse {
|
||||
fn from(m: crate::domain::memory::Memory) -> Self {
|
||||
Self {
|
||||
@@ -146,6 +163,11 @@ pub struct ConversationResponse {
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Convert a domain `Conversation` entity into its API response summary.
|
||||
///
|
||||
/// ## Note
|
||||
/// Only metadata is included (message count, model, system prompt);
|
||||
/// individual messages are not returned in this response.
|
||||
impl From<crate::domain::conversation::Conversation> for ConversationResponse {
|
||||
fn from(c: crate::domain::conversation::Conversation) -> Self {
|
||||
let message_count = c.len();
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
//! HTTP handler functions for CMS endpoints.
|
||||
//! HTTP handler functions for the CMS REST API.
|
||||
//!
|
||||
//! Each handler takes a service trait (via generics or trait objects) and
|
||||
//! returns domain-level results. These functions are agnostic about the
|
||||
//! HTTP framework — callers (e.g. Axum routes) are responsible for mapping
|
||||
//! `Result` into HTTP responses.
|
||||
//! returns domain-level results. These functions are agnostic about the
|
||||
//! HTTP framework — callers (e.g. hyper/Axum routes) are responsible for
|
||||
//! mapping `Result` into HTTP responses with appropriate status codes.
|
||||
//!
|
||||
//! ## Handlers
|
||||
//! - `handle_get_settings` — GET /settings → full settings response
|
||||
//! - `handle_update_settings` — PUT /settings → partial update + full response
|
||||
//! - `handle_list_memories` — GET /memories → list of memory summaries
|
||||
//! - `handle_create_memory` — POST /memories → create/update memory response
|
||||
//!
|
||||
//! ## Design
|
||||
//! Handlers are pure Rust functions with no dependency on the HTTP framework.
|
||||
//! They receive service trait objects (`&S` or `&M`) and return `Result<T>`.
|
||||
//! The caller (e.g. a hyper `Service`) is responsible for serialising the
|
||||
//! response and setting HTTP status codes.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing;
|
||||
|
||||
use crate::domain::memory::Memory;
|
||||
use crate::domain::service::{MemoryService, SettingsService};
|
||||
@@ -16,24 +29,34 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings
|
||||
/// Handle `GET /settings`
|
||||
///
|
||||
/// Returns the current settings as a `SettingsResponse`.
|
||||
///
|
||||
/// Flow: load settings from service → convert to DTO → return.
|
||||
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
|
||||
tracing::debug!("handling GET /settings");
|
||||
let settings = service.load_settings().context("failed to load settings")?;
|
||||
Ok(SettingsResponse::from(settings))
|
||||
}
|
||||
|
||||
/// Handle `PUT /settings`
|
||||
///
|
||||
/// Applies the partial update from `req` to the current settings, persists
|
||||
/// Applies a partial update from `req` to the current settings, persists
|
||||
/// the result, and returns the updated `SettingsResponse`.
|
||||
///
|
||||
/// Flow: load current settings → apply each optional field → save → return DTO.
|
||||
///
|
||||
/// ## Validation
|
||||
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
|
||||
pub fn handle_update_settings<S: SettingsService>(
|
||||
service: &S,
|
||||
req: SettingsUpdateRequest,
|
||||
) -> Result<SettingsResponse> {
|
||||
tracing::debug!("handling PUT /settings");
|
||||
// Load current settings as baseline for partial update
|
||||
let mut settings: Settings = service
|
||||
.load_settings()
|
||||
.context("failed to load current settings for update")?;
|
||||
|
||||
// Apply partial updates
|
||||
// Apply each optional field from the request (None = skip, Some = overwrite)
|
||||
if let Some(val) = req.internet_mode {
|
||||
settings.internet_mode = match val.as_str() {
|
||||
"Off" => crate::domain::settings::InternetMode::Off,
|
||||
@@ -102,8 +125,13 @@ pub fn handle_update_settings<S: SettingsService>(
|
||||
|
||||
/// Handle `GET /memories`
|
||||
///
|
||||
/// Lists all memory slugs, then loads each memory to return full responses.
|
||||
/// Lists all memory slugs, returning summary responses for each.
|
||||
/// Full content is not loaded — callers who need full content should
|
||||
/// use a dedicated endpoint.
|
||||
///
|
||||
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
|
||||
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
|
||||
tracing::debug!("handling GET /memories");
|
||||
let slugs = service.list_memories().context("failed to list memories")?;
|
||||
|
||||
// We can't load individual memories without a load_memory method on the
|
||||
@@ -132,10 +160,17 @@ pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryR
|
||||
/// Handle `POST /memories`
|
||||
///
|
||||
/// Creates or updates a memory from the request body.
|
||||
///
|
||||
/// Flow: build Memory from request DTO → save via service → return MemoryResponse.
|
||||
///
|
||||
/// ## Defaults
|
||||
/// - `kind` defaults to "reference" if not specified
|
||||
/// - `lifecycle` defaults to "new" if not specified
|
||||
pub fn handle_create_memory<M: MemoryService>(
|
||||
service: &M,
|
||||
req: MemoryCreateRequest,
|
||||
) -> Result<MemoryResponse> {
|
||||
tracing::debug!("handling POST /memories for '{}'", req.name);
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let memory = Memory {
|
||||
name: req.name,
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
//! HTTP adapter — handler functions and DTOs for CMS REST endpoints.
|
||||
//! HTTP adapter — handler functions and DTOs for the CMS REST API.
|
||||
//!
|
||||
//! Provides hyper-based request handlers and serialisation types for
|
||||
//! the CMS HTTP endpoints. Handlers receive domain service trait objects
|
||||
//! via dependency injection (Arc-wrapped trait objects) and translate
|
||||
//! between HTTP request/response formats and domain types.
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `dto` — request/response DTO types (JSON serialisation)
|
||||
//! - `handlers` — hyper request handler functions
|
||||
//!
|
||||
//! ## Endpoints
|
||||
//! - `GET /settings` — load current application settings
|
||||
//! - `PUT /settings` — update application settings
|
||||
//! - `GET /memories` — list all memory slugs
|
||||
//! - `POST /memories` — create a new memory entry
|
||||
//! - `POST /memories/{name}` — (future) update memory
|
||||
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
//! Infrastructure layer — adapters and external concerns.
|
||||
//! Infrastructure layer — concrete adapters and external-concern implementations.
|
||||
//!
|
||||
//! Contains persistence implementations (file I/O) and HTTP handler adapters.
|
||||
//! This layer implements the traits defined in `domain::repository` and
|
||||
//! provides HTTP handler adapters that consume `domain::service` traits.
|
||||
//! It is the outermost ring of the Clean Architecture onion.
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `persistence` — file-based repository implementations (JSON, markdown, SQLite)
|
||||
//! - `http` — hyper-based HTTP API handlers and DTO types
|
||||
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
//! JSON file–backed `AppConfigRepository`.
|
||||
//! JSON file–backed `AppConfigRepository` implementation.
|
||||
//!
|
||||
//! Path: `<base_dir>/app_config.json`
|
||||
//!
|
||||
//! On load, auto-detects Claude credentials from the environment or
|
||||
//! Stores `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`.
|
||||
//! On load, auto-detects Claude credentials from the environment or from
|
||||
//! `~/.claude/settings.json` and merges them into the provider map.
|
||||
//!
|
||||
//! ## Auto-Detection Flow
|
||||
//! 1. Load `app_config.json` from disk (or use defaults if absent)
|
||||
//! 2. Merge any default providers not present in the loaded config
|
||||
//! 3. Detect Claude credentials from `~/.claude/settings.json` or env vars
|
||||
//! 4. If Claude detected, add "claude" provider + model roles, set as default
|
||||
//!
|
||||
//! ## Atomicity
|
||||
//! Writes use `write_json_atomic` (temp file + rename) to prevent corruption.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -14,32 +22,41 @@ use zesdex_utils::write_json_atomic;
|
||||
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
|
||||
use crate::domain::repository::AppConfigRepository;
|
||||
|
||||
/// Persists `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`.
|
||||
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
|
||||
///
|
||||
/// On load, auto-detects Claude credentials and merges them into the
|
||||
/// provider map (see module docs for the full flow).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonAppConfigRepository;
|
||||
|
||||
impl JsonAppConfigRepository {
|
||||
/// Create a new repository instance.
|
||||
/// Create a new repository instance (zero allocation).
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration structure inside `~/.claude/settings.json`.
|
||||
/// Internal helper: the `env` block inside `~/.claude/settings.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeEnv {
|
||||
/// Override URL for the Anthropic API.
|
||||
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
||||
anthropic_base_url: Option<String>,
|
||||
/// Override API key for the Anthropic API.
|
||||
#[serde(alias = "ANTHROPIC_API_KEY")]
|
||||
anthropic_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Internal helper: top-level structure of `~/.claude/settings.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeSettings {
|
||||
/// Environment variable overrides block.
|
||||
env: Option<ClaudeEnv>,
|
||||
}
|
||||
|
||||
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
|
||||
///
|
||||
/// Returns `(base_url, api_key)` if both are present, or `None`.
|
||||
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()?;
|
||||
@@ -50,15 +67,18 @@ fn claude_credentials_from_file() -> Option<(String, String)> {
|
||||
Some((base_url, key))
|
||||
}
|
||||
|
||||
/// Try to read Claude credentials from environment variables.
|
||||
/// Try to read Claude credentials from the process environment variables.
|
||||
///
|
||||
/// Returns `(ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY)` if both are set, or `None`.
|
||||
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))
|
||||
}
|
||||
|
||||
/// Return a `ProviderConfig` for the Claude provider, checking both
|
||||
/// `~/.claude/settings.json` and the process environment.
|
||||
/// Return a `ProviderConfig` for the Claude provider, checking both sources.
|
||||
///
|
||||
/// Flow: try ~/.claude/settings.json → fall back to env vars → return None if neither found.
|
||||
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
||||
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
||||
Some(ProviderConfig {
|
||||
@@ -70,8 +90,15 @@ fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
||||
}
|
||||
|
||||
impl AppConfigRepository for JsonAppConfigRepository {
|
||||
/// Load `AppConfig` from `<base_dir>/app_config.json`.
|
||||
///
|
||||
/// Flow: read file → parse JSON → merge default providers → auto-detect Claude → return.
|
||||
///
|
||||
/// If the file is missing, returns `AppConfig::default()`.
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig> {
|
||||
tracing::debug!("loading app_config from {base_dir:?}");
|
||||
let path = base_dir.join("app_config.json");
|
||||
// Try to read and parse the config file
|
||||
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => serde_json::from_str(&s)
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse app_config.json: {e}"))?,
|
||||
@@ -84,13 +111,13 @@ impl AppConfigRepository for JsonAppConfigRepository {
|
||||
}
|
||||
};
|
||||
|
||||
// Merge any default providers not present in the loaded config
|
||||
// Phase 1: merge default providers that are not yet in the loaded config
|
||||
let defaults = AppConfig::default();
|
||||
for (name, provider) in defaults.providers {
|
||||
cfg.providers.entry(name).or_insert(provider);
|
||||
}
|
||||
|
||||
// Auto-detect Claude provider
|
||||
// Phase 2: auto-detect Claude provider from file or environment
|
||||
if let Some(claude_provider) = detect_claude_settings_provider() {
|
||||
cfg.providers
|
||||
.entry("claude".to_string())
|
||||
@@ -123,7 +150,11 @@ impl AppConfigRepository for JsonAppConfigRepository {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Persist `AppConfig` to `<base_dir>/app_config.json`.
|
||||
///
|
||||
/// Flow: create base dir → atomic JSON write → log success.
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> {
|
||||
tracing::debug!("saving app_config to {base_dir:?}");
|
||||
std::fs::create_dir_all(base_dir)
|
||||
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
|
||||
let path = base_dir.join("app_config.json");
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! JSON file–backed `ConversationRepository`.
|
||||
//! JSON file–backed `ConversationRepository` implementation.
|
||||
//!
|
||||
//! Path: `<session_dir>/conversation.json`
|
||||
//! Stores `Conversation` as pretty-printed JSON at `<session_dir>/conversation.json`.
|
||||
//! Uses atomic write (temp file + rename + fsync) for crash safety.
|
||||
//!
|
||||
//! Uses write-then-rename with fsync for crash safety.
|
||||
//! ## Data Flow
|
||||
//! - `load()`: read file → deserialize JSON → return Conversation
|
||||
//! - `save()`: serialize Conversation → atomic write to conversation.json
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -12,7 +15,9 @@ use zesdex_utils::write_json_atomic;
|
||||
use crate::domain::conversation::Conversation;
|
||||
use crate::domain::repository::ConversationRepository;
|
||||
|
||||
/// Persists `Conversation` as pretty-printed JSON at `<session_dir>/conversation.json`.
|
||||
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
|
||||
///
|
||||
/// Zero-allocation: the struct is a unit type marker.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonConversationRepository;
|
||||
|
||||
@@ -24,6 +29,9 @@ impl JsonConversationRepository {
|
||||
}
|
||||
|
||||
impl ConversationRepository for JsonConversationRepository {
|
||||
/// Load a `Conversation` from `<session_dir>/conversation.json`.
|
||||
///
|
||||
/// Flow: read file → parse JSON → return Conversation.
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation> {
|
||||
let path = session_dir.join("conversation.json");
|
||||
let data = std::fs::read_to_string(&path)
|
||||
@@ -33,7 +41,11 @@ impl ConversationRepository for JsonConversationRepository {
|
||||
Ok(conv)
|
||||
}
|
||||
|
||||
/// Persist a `Conversation` to `<session_dir>/conversation.json`.
|
||||
///
|
||||
/// Flow: create session dir → atomic JSON write → log success.
|
||||
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()> {
|
||||
tracing::debug!("saving conversation to {session_dir:?}");
|
||||
std::fs::create_dir_all(session_dir)
|
||||
.with_context(|| format!("failed to create session dir '{}'", session_dir.display()))?;
|
||||
let path = session_dir.join("conversation.json");
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
//! JSONL file–backed `EditLogRepository`.
|
||||
//! JSONL file–backed `EditLogRepository` implementation.
|
||||
//!
|
||||
//! Path: `<session_dir>/edits.jsonl`
|
||||
//! Stores `EditLog` as an append-only newline-delimited JSON file at
|
||||
//! `<session_dir>/edits.jsonl`. New entries are appended to the file,
|
||||
//! never rewritten, making this a durable write-ahead log.
|
||||
//!
|
||||
//! Append-only log: new entries are appended to the file, never rewritten.
|
||||
//! In-memory cache is capped at 10K entries to prevent unbounded growth.
|
||||
//! ## Data Flow
|
||||
//! - `open()`: read existing JSONL lines from disk → parse into in-memory Vec
|
||||
//! - `append()`: serialize entry as JSON line → fsync to disk → push to memory
|
||||
//!
|
||||
//! ## Memory Management
|
||||
//! The in-memory cache is capped at `MAX_MEMORY_ENTRIES` (10K) to prevent
|
||||
//! unbounded growth in long-running sessions. Old entries are evicted
|
||||
//! from memory but remain on disk.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
@@ -13,7 +21,9 @@ use anyhow::{Context, Result};
|
||||
use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES};
|
||||
use crate::domain::repository::EditLogRepository;
|
||||
|
||||
/// Persists `EditLog` as an append-only JSONL file at `<session_dir>/edits.jsonl`.
|
||||
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
|
||||
///
|
||||
/// Append-only JSONL format: each entry is one JSON line.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonlEditLogRepository;
|
||||
|
||||
@@ -24,6 +34,9 @@ impl JsonlEditLogRepository {
|
||||
}
|
||||
|
||||
/// Read existing entries from disk into memory, capped at `MAX_MEMORY_ENTRIES`.
|
||||
///
|
||||
/// Flow: open file → read lines → parse JSON → cap at MAX_MEMORY_ENTRIES → return.
|
||||
/// Silently skips malformed lines.
|
||||
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return Vec::new();
|
||||
@@ -46,7 +59,12 @@ impl JsonlEditLogRepository {
|
||||
}
|
||||
|
||||
impl EditLogRepository for JsonlEditLogRepository {
|
||||
/// Open (or initialise) the edit log for a session directory.
|
||||
///
|
||||
/// Flow: ensure parent dir exists → load existing entries from disk →
|
||||
/// touch file if absent → return in-memory EditLog.
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog> {
|
||||
tracing::debug!("opening edit log for {session_dir:?}");
|
||||
let path = session_dir.join("edits.jsonl");
|
||||
// Ensure parent dir exists
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -65,7 +83,12 @@ impl EditLogRepository for JsonlEditLogRepository {
|
||||
Ok(EditLog { entries })
|
||||
}
|
||||
|
||||
/// Append one entry to the edit log and persist immediately (write-through).
|
||||
///
|
||||
/// Flow: serialize entry → open file (append mode) → write line → fsync →
|
||||
/// push to in-memory Vec → evict oldest if over cap.
|
||||
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> {
|
||||
tracing::debug!("appending edit log entry for {session_dir:?}");
|
||||
let path = session_dir.join("edits.jsonl");
|
||||
let line =
|
||||
serde_json::to_string(&entry).context("failed to serialize edit log entry")? + "\n";
|
||||
@@ -91,6 +114,7 @@ impl EditLogRepository for JsonlEditLogRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a cloned copy of all in-memory entries for inspection.
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
||||
log.entries.clone()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
//! Markdown file–backed `MemoryRepository`.
|
||||
//! Markdown file–backed `MemoryRepository` implementation.
|
||||
//!
|
||||
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
|
||||
//! Filenames are derived from the memory's `name` via slugification.
|
||||
//! Filenames are derived from the memory's `name` via slugification
|
||||
//! (see `Memory::slugify`).
|
||||
//!
|
||||
//! Frontmatter fields parsed from `---\n...\n---\n` header:
|
||||
//! name, description, kind, created_at, updated_at, lifecycle,
|
||||
//! outcome, scope, before, after, provenances
|
||||
//! ## File Format
|
||||
//! ```text
|
||||
//! ---
|
||||
//! name: my-memory
|
||||
//! description: A useful lesson
|
||||
//! kind: lesson
|
||||
//! created_at: 1700000000
|
||||
//! updated_at: 1700000000
|
||||
//! lifecycle: active
|
||||
//! outcome: success
|
||||
//! scope: global
|
||||
//! before: old content
|
||||
//! after: new content
|
||||
//! provenances: tool1, tool2
|
||||
//! ---
|
||||
//! Free-form markdown content body...
|
||||
//! ```
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! - `list()`: scan `*.md` files (excluding `MEMORY.md`), return slugs
|
||||
//! - `load()`: read file → strip `---\n...\n---\n` frontmatter → parse fields
|
||||
//! - `save()`: build frontmatter → write to temp file → rename atomically
|
||||
//! - `delete()`: remove file from disk
|
||||
//!
|
||||
//! ## Atomicity
|
||||
//! Writes use temp-file + rename + parent-directory fsync for crash safety.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
@@ -16,7 +40,10 @@ use anyhow::{Context, Result};
|
||||
use crate::domain::memory::Memory;
|
||||
use crate::domain::repository::MemoryRepository;
|
||||
|
||||
/// Persists `Memory` as markdown files with YAML-ish frontmatter.
|
||||
/// File-based `MemoryRepository` that stores memories as `.md` files with frontmatter.
|
||||
///
|
||||
/// Each file has a YAML-ish `---\n...\n---\n` header followed by free-form
|
||||
/// markdown content. Filenames are derived from `Memory.name` via slugification.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MarkdownMemoryRepository;
|
||||
|
||||
@@ -26,7 +53,9 @@ impl MarkdownMemoryRepository {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Build the frontmatter lines for a memory.
|
||||
/// Build the YAML-ish frontmatter string for a memory.
|
||||
///
|
||||
/// Only non-empty optional fields are included in the output.
|
||||
fn build_frontmatter(memory: &Memory) -> String {
|
||||
let outcome_line = memory
|
||||
.outcome
|
||||
@@ -71,7 +100,10 @@ impl MarkdownMemoryRepository {
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse frontmatter lines into a `HashMap`.
|
||||
/// Parse frontmatter lines into a `HashMap<String, String>`.
|
||||
///
|
||||
/// Flow: split lines → for each non-empty line, split on first ':' → insert.
|
||||
/// Malformed lines (no ':') are silently skipped.
|
||||
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
|
||||
front
|
||||
.lines()
|
||||
@@ -82,7 +114,12 @@ impl MarkdownMemoryRepository {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
|
||||
/// Parse a memory file's full contents (frontmatter + body) into a `Memory`.
|
||||
///
|
||||
/// Flow: strip `---\n` prefix → split on `\n---\n` → parse front half with
|
||||
/// `parse_frontmatter()` → use back half as content body → build Memory.
|
||||
///
|
||||
/// Returns `InvalidData` error if the frontmatter delimiter is missing.
|
||||
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();
|
||||
@@ -132,6 +169,10 @@ impl MarkdownMemoryRepository {
|
||||
}
|
||||
|
||||
impl MemoryRepository for MarkdownMemoryRepository {
|
||||
/// List all memory slugs in `memory_dir` by scanning `*.md` files.
|
||||
///
|
||||
/// Flow: read directory entries → filter `*.md` → strip extension → exclude MEMORY.md.
|
||||
/// Returns empty Vec if the directory doesn't exist.
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>> {
|
||||
let Ok(entries) = std::fs::read_dir(memory_dir) else {
|
||||
return Ok(Vec::new());
|
||||
@@ -152,7 +193,11 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
||||
Ok(slugs)
|
||||
}
|
||||
|
||||
/// Load a single `Memory` by name from `memory_dir`.
|
||||
///
|
||||
/// Flow: resolve file path → read file → parse frontmatter + body → return Memory.
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory> {
|
||||
tracing::debug!("loading memory '{name}'");
|
||||
let path = Memory::path(memory_dir, name);
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read memory '{name}' at '{}'", path.display()))?;
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
//! Persistence adapters — concrete file-based repository implementations.
|
||||
//!
|
||||
//! Each module implements one of the repository traits from `domain::repository`
|
||||
//! using file-based storage (JSON, markdown, or newline-delimited JSON).
|
||||
//! These are the outermost adapters that perform actual filesystem I/O.
|
||||
//!
|
||||
//! ## Repository Implementations
|
||||
//! - `JsonSettingsRepository` — reads/writes `settings.json` (JSON)
|
||||
//! - `JsonAppConfigRepository` — reads/writes `app_config.json` (JSON)
|
||||
//! - `JsonConversationRepository` — reads/writes `conversation.json` (JSON)
|
||||
//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files (markdown + YAML frontmatter)
|
||||
//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl` (newline-delimited JSON)
|
||||
//! - `FileRewindBlobRepository` — stores blobs as files in a `blobs/` subdirectory
|
||||
//!
|
||||
//! ## Atomicity
|
||||
//! JSON writes use a temp-file + rename pattern to prevent partial writes
|
||||
//! from corrupting configuration files during crashes.
|
||||
|
||||
pub mod app_config_repo;
|
||||
pub mod conversation_repo;
|
||||
|
||||
@@ -18,13 +18,22 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing;
|
||||
|
||||
use crate::domain::repository::RewindBlobRepository;
|
||||
|
||||
/// A single entry in the append-only blob index (`index.jsonl`).
|
||||
///
|
||||
/// Each `store_blob` call appends one line; later entries with the same key
|
||||
/// shadow earlier ones during `list_blob_keys` (keeping the last `created_at`
|
||||
/// for ordering).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BlobIndexEntry {
|
||||
/// The blob key (unique logical identifier).
|
||||
key: String,
|
||||
/// Optional MIME type hint (e.g. `"text/plain"`, `"image/png"`).
|
||||
mime_type: Option<String>,
|
||||
/// Epoch timestamp in milliseconds when the blob was stored.
|
||||
created_at: i64,
|
||||
}
|
||||
|
||||
@@ -35,23 +44,37 @@ pub struct FileRewindBlobRepository;
|
||||
impl FileRewindBlobRepository {
|
||||
/// Create a new filesystem rewind-blob repository.
|
||||
pub fn new() -> Self {
|
||||
tracing::debug!("FileRewindBlobRepository created");
|
||||
Self
|
||||
}
|
||||
|
||||
/// Return the blob storage directory for a given session directory.
|
||||
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
|
||||
session_dir.join("blobs")
|
||||
}
|
||||
|
||||
/// Return the on-disk path for a single blob file.
|
||||
///
|
||||
/// The key is hex-encoded before being used as the filename to avoid
|
||||
/// path-traversal or invalid-filename-character issues.
|
||||
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())))
|
||||
}
|
||||
|
||||
/// Return the path to the append-only blob index file.
|
||||
fn index_path(session_dir: &Path) -> std::path::PathBuf {
|
||||
Self::blobs_dir(session_dir).join("index.jsonl")
|
||||
}
|
||||
}
|
||||
|
||||
impl RewindBlobRepository for FileRewindBlobRepository {
|
||||
/// Persist `data` under `blob_key` in the session's blob directory.
|
||||
///
|
||||
/// Flow: write to a `.bin.tmp` temp file → fsync → rename to `.bin` →
|
||||
/// append a JSON line to `index.jsonl` → fsync index.
|
||||
///
|
||||
/// This write-then-rename pattern ensures the blob file is never
|
||||
/// observed in a partially-written state.
|
||||
fn store_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
@@ -63,6 +86,7 @@ impl RewindBlobRepository for FileRewindBlobRepository {
|
||||
std::fs::create_dir_all(&blobs_dir)
|
||||
.with_context(|| format!("failed to create blobs dir '{}'", blobs_dir.display()))?;
|
||||
|
||||
// Write blob data atomically: temp → fsync → rename
|
||||
let path = Self::blob_file_path(session_dir, blob_key);
|
||||
let tmp = path.with_extension("bin.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
@@ -70,6 +94,7 @@ impl RewindBlobRepository for FileRewindBlobRepository {
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
|
||||
// Append index entry (JSONL line)
|
||||
let entry = BlobIndexEntry {
|
||||
key: blob_key.to_string(),
|
||||
mime_type: mime_type.map(String::from),
|
||||
@@ -84,22 +109,45 @@ impl RewindBlobRepository for FileRewindBlobRepository {
|
||||
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
|
||||
f.sync_all()?;
|
||||
|
||||
tracing::debug!(
|
||||
"stored blob key={} size={} mime={:?}",
|
||||
blob_key,
|
||||
data.len(),
|
||||
mime_type
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read back a previously stored blob by key.
|
||||
///
|
||||
/// Returns `None` when no blob file exists for `blob_key` (i.e. the
|
||||
/// blob was never stored or the session directory does not exist).
|
||||
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let path = Self::blob_file_path(session_dir, blob_key);
|
||||
if !path.exists() {
|
||||
tracing::debug!("blob key={} not found (path does not exist)", blob_key);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(std::fs::read(&path)?))
|
||||
let data = std::fs::read(&path)?;
|
||||
tracing::debug!("retrieved blob key={} size={}", blob_key, data.len());
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// List all unique blob keys in first-seen (oldest-first) order.
|
||||
///
|
||||
/// Flow: read `index.jsonl` → parse each line → de-duplicate by keeping
|
||||
/// the *last* occurrence of each key → sort by `created_at` ASC.
|
||||
///
|
||||
/// When a key has been overwritten, it appears exactly once in the output
|
||||
/// (pointing to the latest stored data). Returns an empty vec if the
|
||||
/// index file does not exist yet.
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>> {
|
||||
let index_path = Self::index_path(session_dir);
|
||||
let Ok(content) = std::fs::read_to_string(&index_path) else {
|
||||
tracing::debug!("no blob index file yet at '{}'", index_path.display());
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// Keep only the last occurrence of each key (later overwrites win),
|
||||
// but remember first-seen order for the final ascending sort.
|
||||
let mut first_seen_order: Vec<String> = Vec::new();
|
||||
@@ -107,6 +155,8 @@ impl RewindBlobRepository for FileRewindBlobRepository {
|
||||
std::collections::HashMap::new();
|
||||
for line in content.lines() {
|
||||
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
|
||||
// Silently skip malformed lines — they may come from an
|
||||
// interrupted write in a previous session.
|
||||
continue;
|
||||
};
|
||||
if !latest_by_key.contains_key(&entry.key) {
|
||||
@@ -114,12 +164,17 @@ impl RewindBlobRepository for FileRewindBlobRepository {
|
||||
}
|
||||
latest_by_key.insert(entry.key.clone(), entry);
|
||||
}
|
||||
|
||||
// Sort entries by creation timestamp ascending (oldest first).
|
||||
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())
|
||||
let keys: Vec<String> = entries.into_iter().map(|e| e.key).collect();
|
||||
|
||||
tracing::debug!("listed {} blob keys from index", keys.len());
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing;
|
||||
use zesdex_utils::write_json_atomic;
|
||||
|
||||
use crate::domain::repository::SettingsRepository;
|
||||
@@ -24,12 +25,24 @@ impl JsonSettingsRepository {
|
||||
}
|
||||
|
||||
impl SettingsRepository for JsonSettingsRepository {
|
||||
/// Load settings from `<base_dir>/settings.json`.
|
||||
///
|
||||
/// Flow: read JSON file → deserialise → return `Settings`.
|
||||
///
|
||||
/// Graceful degradation: returns `Settings::default()` when the file is
|
||||
/// missing (first run) *or* when it exists but fails to parse (e.g. a
|
||||
/// newer field was added after the file was written).
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings> {
|
||||
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),
|
||||
Ok(settings) => {
|
||||
tracing::debug!("settings loaded from '{}'", path.display());
|
||||
Ok(settings)
|
||||
}
|
||||
Err(e) => {
|
||||
// Parse failure: a new field was added since the file
|
||||
// was written → fall back to defaults gracefully.
|
||||
tracing::warn!(
|
||||
"settings.json at '{}' failed to parse ({e}); falling back to defaults",
|
||||
path.display()
|
||||
@@ -45,6 +58,10 @@ impl SettingsRepository for JsonSettingsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist `settings` as pretty-printed JSON at `<base_dir>/settings.json`.
|
||||
///
|
||||
/// Flow: create base dir (if missing) → atomic JSON write via
|
||||
/// `write_json_atomic` (write to temp → fsync → rename).
|
||||
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()> {
|
||||
std::fs::create_dir_all(base_dir)
|
||||
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
//! `zesdex-cms` — Content Management System
|
||||
//! `zesdex-cms` — Content Management System crate.
|
||||
//!
|
||||
//! Clean Architecture / DDD crate layout:
|
||||
//! - **domain** — Pure entities and repository/service traits
|
||||
//! - **application** — Use-case implementations
|
||||
//! - **infrastructure** — Persistence adapters + HTTP handlers
|
||||
//! Provides the domain model, application services, and infrastructure
|
||||
//! adapters for managing conversations, memories, settings, and app
|
||||
//! configuration in the zesdex platform.
|
||||
//!
|
||||
//! ## Architecture (Clean Architecture / DDD)
|
||||
//! - **`domain`** — Pure entities, value objects, repository traits, and
|
||||
//! service interfaces. Zero external framework dependencies.
|
||||
//! - **`application`** — Use-case orchestration (conversation, memory,
|
||||
//! settings services) that depend only on domain traits.
|
||||
//! - **`infrastructure`** — Concrete adapters: file-based persistence
|
||||
//! repositories and an HTTP API layer (hyper-based handlers + DTOs).
|
||||
//!
|
||||
//! ## Key Design Decisions
|
||||
//! - All repositories in `infrastructure::persistence` operate on the
|
||||
//! filesystem via the `Store` base path — no database server needed.
|
||||
//! - HTTP handlers in `infrastructure::http` are thin — they delegate to
|
||||
//! application services which hold the business logic.
|
||||
//! - Domain types are plain Rust structs with `serde` serialisation,
|
||||
//! stored as JSON files on disk.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
|
||||
Reference in New Issue
Block a user