feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "zesdex-application"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Application layer — port traits (interfaces), use cases, DTOs.
# Depends ONLY on domain. Application services orchestrate domain objects
# through port traits without knowing concrete implementations.
[dependencies]
zesdex-domain = { path = "../domain" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tracing.workspace = true
tokio.workspace = true
base64.workspace = true
sha2.workspace = true
url.workspace = true
+16
View File
@@ -0,0 +1,16 @@
//! Auth use-case implementations.
//!
//! Contains concrete service types that implement the domain's
//! authentication and session management traits by coordinating
//! injected repository and port dependencies.
//!
//! # Use Cases
//!
//! - [`oauth_service`] — `OAuthUseCase`: OAuth 2.0 authorization-code + PKCE flow
//! - [`session_service`] — `SessionServiceImpl`: session CRUD lifecycle
pub mod oauth_service;
pub mod session_service;
pub use oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger};
pub use session_service::SessionServiceImpl;
+250
View File
@@ -0,0 +1,250 @@
//! OAuth 2.0 authorization-code + PKCE flow use-case.
//!
//! `OAuthUseCase` orchestrates the standard PKCE-enhanced OAuth flow:
//!
//! 1. **`start_flow`** — generates a cryptographic PKCE code verifier,
//! derives its S256 challenge, creates a CSRF state token, persists
//! the verifier + state via `OAuthFlowStore`, and builds an
//! authorization URL with all required parameters.
//! 2. **`complete_flow`** — validates the returned `state` against the
//! stored value (CSRF check), reads the stored verifier, delegates
//! the token-code exchange to an injected `TokenExchanger`, and
//! persists the resulting `OAuthToken` via `OAuthRepository`.
//! 3. **`get_token`** — loads the stored OAuth token (if any).
//!
//! # Portability
//!
//! The service is generic over three injected dependencies:
//! - `R: OAuthRepository` — token persistence
//! - `S: OAuthFlowStore` — ephemeral flow state (verifier + CSRF state)
//! - `E: TokenExchanger` — the HTTP token-endpoint exchange
//!
//! This keeps all I/O and protocol-level concerns abstracted behind
//! port traits; the service itself contains only orchestration logic.
use std::path::PathBuf;
use tracing;
use zesdex_domain::auth::{OAuthConfig, OAuthRepository, OAuthToken, ServiceError};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use sha2::{Digest, Sha256};
// ---------------------------------------------------------------------------
// Port traits (defined here because they are specific to this use-case)
// ---------------------------------------------------------------------------
/// Persistence contract for ephemeral OAuth flow state.
///
/// Between `start_flow` and `complete_flow` the verifier and CSRF state
/// must survive across process boundaries (the user opens a browser, the
/// provider redirects back to a loopback listener on the next invocation).
///
/// Implementors store key-value pairs to disk or another durable medium
/// and clear them after a successful (or failed) flow completion.
pub trait OAuthFlowStore: Send + Sync {
/// Persist the PKCE code verifier and CSRF state token.
fn save_flow_state(
&self,
verifier: &str,
state: &str,
) -> Result<(), ServiceError>;
/// Load the stored PKCE code verifier.
fn load_verifier(&self) -> Result<String, ServiceError>;
/// Load the stored CSRF state token.
fn load_state(&self) -> Result<String, ServiceError>;
/// Clear stored flow state (verifier + state).
fn clear(&self) -> Result<(), ServiceError>;
}
/// Abstraction for exchanging an authorization code for tokens.
///
/// Implementors handle the HTTP POST to the provider's token endpoint
/// with the appropriate form-encoded parameters, parse the JSON
/// response, and return the extracted `OAuthToken`.
pub trait TokenExchanger: Send + Sync {
/// Exchange an authorization code for an access token.
///
/// ## Parameters
/// - `token_url` — the provider's token endpoint URL
/// - `client_id` — OAuth client identifier
/// - `client_secret` — optional client secret
/// - `redirect_uri` — must match the URI used in `start_flow`
/// - `code` — the authorization code from the provider's redirect
/// - `code_verifier` — the PKCE verifier from `start_flow`
fn exchange_code(
&self,
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
redirect_uri: &str,
code: &str,
code_verifier: &str,
) -> Result<OAuthToken, ServiceError>;
}
// ---------------------------------------------------------------------------
// PKCE helpers
// ---------------------------------------------------------------------------
/// Generate a PKCE code-verifier and its S256 code-challenge.
///
/// Uses 32 cryptographically random bytes, base64url-encoded (no padding)
/// for the verifier, then SHA-256 hashes the verifier and base64url-encodes
/// the digest for the challenge. This satisfies the PKCE `S256` method
/// which requires a minimum verifier length of 43 characters.
fn generate_pkce_pair() -> (String, String) {
// 32 random bytes → 43 base64url chars (well above the 43-char PKCE
// minimum).
let mut bytes = [0u8; 32];
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
let verifier = URL_SAFE_NO_PAD.encode(&bytes);
let challenge = {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
};
(verifier, challenge)
}
/// Generate a random CSRF state token (UUID-based, 36 chars).
fn generate_state_token() -> String {
uuid::Uuid::new_v4().to_string()
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/// Concrete OAuth flow use-case.
///
/// Generic over three dependencies:
/// - `R` — token persistence (`OAuthRepository`)
/// - `S` — flow-state persistence (`OAuthFlowStore`)
/// - `E` — token-endpoint HTTP exchange (`TokenExchanger`)
pub struct OAuthUseCase<R, S, E> {
/// Repository for persisting / loading OAuth tokens.
pub token_repo: R,
/// Store for ephemeral flow state (verifier + CSRF state).
pub flow_store: S,
/// Token-endpoint HTTP exchanger.
pub token_exchanger: E,
/// File path for the token JSON file.
pub token_path: PathBuf,
}
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
/// Create a new OAuth use-case.
pub fn new(
token_repo: R,
flow_store: S,
token_exchanger: E,
token_path: PathBuf,
) -> Self {
OAuthUseCase {
token_repo,
flow_store,
token_exchanger,
token_path,
}
}
}
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E>
{
fn start_flow(
&self,
config: &OAuthConfig,
redirect_uri: &str,
) -> Result<(String, String), ServiceError> {
if config.auth_url.is_empty() {
return Err(ServiceError::InvalidConfig(
"OAuth auth_url is empty".to_string(),
));
}
let (verifier, challenge) = generate_pkce_pair();
let state = generate_state_token();
// Persist verifier + state so `complete_flow` can retrieve them.
self.flow_store.save_flow_state(&verifier, &state)?;
tracing::debug!(
auth_url = %config.auth_url,
redirect_uri = %redirect_uri,
state_len = state.len(),
"starting OAuth flow",
);
let mut url = url::Url::parse(&config.auth_url)
.map_err(|e| {
ServiceError::InvalidConfig(format!(
"invalid auth_url '{}': {e}",
config.auth_url
))
})?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", redirect_uri)
.append_pair("scope", &config.scopes.join(" "))
.append_pair("state", &state)
.append_pair("code_challenge_method", "S256")
.append_pair("code_challenge", &challenge);
Ok((url.to_string(), state))
}
fn complete_flow(
&self,
config: &OAuthConfig,
redirect_uri: &str,
code: &str,
state: &str,
) -> Result<OAuthToken, ServiceError> {
// CSRF check: validate the returned state against the stored value.
let expected_state = self.flow_store.load_state()?;
if expected_state != state {
return Err(ServiceError::StateMismatch);
}
// Read the PKCE verifier that was saved in `start_flow`.
let verifier = self.flow_store.load_verifier()?;
tracing::debug!(
token_url = %config.token_url,
code_len = code.len(),
"completing OAuth flow — exchanging code for token",
);
// Delegate the HTTP token exchange to the injected exchanger.
let token = self.token_exchanger.exchange_code(
&config.token_url,
&config.client_id,
config.client_secret.as_deref(),
redirect_uri,
code,
&verifier,
)?;
// Persist the token and clean up flow state.
self.token_repo.save_token(&self.token_path, &token)?;
let _ = self.flow_store.clear();
Ok(token)
}
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError> {
self.token_repo
.load_token(&self.token_path)
.map_err(ServiceError::Repository)
}
}
@@ -0,0 +1,89 @@
//! Session management use-case.
//!
//! `SessionServiceImpl` implements [`SessionService`] from the domain
//! layer by delegating CRUD operations to injected repository traits.
//!
//! # Flow
//!
//! - **`create_session`** — generates a UUID v4 id, creates a `Session`
//! entity with the given title, persists via `SessionRepository`.
//! - **`list_all`** — delegates to `SessionRepository::list_sessions`.
//! - **`archive_session`** — loads session, sets `archived = true`,
//! persists the updated entity.
//!
//! # Generics
//!
//! - `R: SessionRepository` — session CRUD persistence
//! - `L: SessionLockRepository` — session lock acquire/release
use std::path::PathBuf;
use tracing;
use uuid::Uuid;
use zesdex_domain::auth::{
ServiceError, Session, SessionId, SessionLockRepository, SessionRepository,
};
/// Concrete session service backed by injected repository implementations.
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
/// Repository for session CRUD operations.
pub session_repo: R,
/// Repository for session lock acquire/release.
pub lock_repo: L,
/// Base data directory passed to repository methods.
pub base_dir: PathBuf,
}
impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
/// Create a new session service with the given repositories and base
/// data directory.
pub fn new(session_repo: R, lock_repo: L, base_dir: PathBuf) -> Self {
SessionServiceImpl {
session_repo,
lock_repo,
base_dir,
}
}
}
impl<R: SessionRepository, L: SessionLockRepository>
zesdex_domain::auth::SessionService for SessionServiceImpl<R, L>
{
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
let id = SessionId::new(&Uuid::new_v4().to_string())
.map_err(|e| ServiceError::Other(e))?;
let title_owned = if title.is_empty() {
"New Session".to_string()
} else {
title.to_string()
};
let session = Session::new(id.into_string(), title_owned);
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
self.session_repo
.save_session(&self.base_dir, &session)?;
Ok(session)
}
fn list_all(&self) -> Result<Vec<Session>, ServiceError> {
tracing::debug!("listing all sessions");
self.session_repo
.list_sessions(&self.base_dir)
.map_err(ServiceError::Repository)
}
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
tracing::debug!(session_id = %id, "archiving session");
let mut session = self
.session_repo
.load_session(&self.base_dir, &id)?;
session.archived = true;
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
self.session_repo
.save_session(&self.base_dir, &session)?;
Ok(())
}
}
@@ -0,0 +1,72 @@
//! Conversation use-case implementation.
//!
//! `ConversationServiceImpl` implements [`ConversationService`] from the
//! domain layer. It is generic over `R: ConversationRepository`, delegating
//! all persistence to that adapter.
//!
//! # 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 tracing;
use zesdex_domain::cms::{Conversation, ConversationRepository, ServiceError};
use zesdex_domain::core::ChatMessage;
/// Service implementation for conversation CRUD operations.
///
/// Generic over `R: ConversationRepository` so the persistence layer
/// can be swapped without changing business logic.
pub struct ConversationServiceImpl<R> {
pub repo: R,
/// 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<PathBuf>) -> Self {
tracing::debug!("creating ConversationServiceImpl");
Self {
repo,
sessions_dir: sessions_dir.into(),
}
}
/// Compute the session directory for a given session id.
fn session_dir(&self, session_id: &str) -> PathBuf {
self.sessions_dir.join(session_id)
}
}
impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
for ConversationServiceImpl<R>
{
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
tracing::debug!("loading conversation for session {session_id}");
let dir = self.session_dir(session_id);
self.repo.load(&dir).map_err(ServiceError::Repository)
}
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> {
tracing::debug!("saving conversation for session {}", conv.session_id);
let dir = self.session_dir(&conv.session_id);
self.repo.save(&dir, conv)?;
Ok(())
}
fn add_message(
&self,
conv: &mut Conversation,
msg: ChatMessage,
) -> Result<(), ServiceError> {
tracing::debug!("adding message to session {}", conv.session_id);
conv.push(msg);
let dir = self.session_dir(&conv.session_id);
self.repo.save(&dir, conv)?;
Ok(())
}
}
@@ -0,0 +1,59 @@
//! Memory use-case implementation.
//!
//! `MemoryServiceImpl` implements [`MemoryService`] from the domain
//! layer. It is generic over `R: MemoryRepository`, delegating all
//! persistence to that adapter.
//!
//! # 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 tracing;
use zesdex_domain::cms::{Memory, MemoryRepository, ServiceError};
/// Service implementation for memory CRUD operations.
///
/// Generic over `R: MemoryRepository` so the persistence layer can be
/// swapped without changing business logic.
pub struct MemoryServiceImpl<R> {
pub repo: R,
/// 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<PathBuf>) -> Self {
tracing::debug!("creating MemoryServiceImpl");
Self {
repo,
memory_dir: memory_dir.into(),
}
}
}
impl<R: MemoryRepository> zesdex_domain::cms::MemoryService for MemoryServiceImpl<R> {
fn list_memories(&self) -> Result<Vec<String>, ServiceError> {
tracing::debug!("listing memories from {:?}", self.memory_dir);
self.repo
.list(&self.memory_dir)
.map_err(ServiceError::Repository)
}
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError> {
tracing::debug!("saving memory '{}'", memory.name);
self.repo.save(&self.memory_dir, memory)?;
Ok(())
}
fn delete_memory(&self, name: &str) -> Result<(), ServiceError> {
tracing::debug!("deleting memory '{name}'");
self.repo
.delete(&self.memory_dir, name)
.map_err(ServiceError::Repository)
}
}
+18
View File
@@ -0,0 +1,18 @@
//! CMS use-case implementations.
//!
//! Contains concrete service types that implement the domain's CMS
//! service traits by coordinating injected repository dependencies.
//!
//! # Use Cases
//!
//! - [`conversation_service`] — `ConversationServiceImpl`: conversation CRUD
//! - [`memory_service`] — `MemoryServiceImpl`: long-term memory management
//! - [`settings_service`] — `SettingsServiceImpl`: settings & app-config management
pub mod conversation_service;
pub mod memory_service;
pub mod settings_service;
pub use conversation_service::ConversationServiceImpl;
pub use memory_service::MemoryServiceImpl;
pub use settings_service::SettingsServiceImpl;
@@ -0,0 +1,76 @@
//! Settings and app-config use-case implementation.
//!
//! `SettingsServiceImpl` implements [`SettingsService`] from the domain
//! layer. It is generic over `S: SettingsRepository` and `C: AppConfigRepository`,
//! delegating persistence to those adapters.
//!
//! # 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 tracing;
use zesdex_domain::cms::{
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings,
SettingsRepository,
};
/// 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.
pub struct SettingsServiceImpl<S, C> {
pub settings_repo: S,
pub app_config_repo: C,
pub base_dir: PathBuf,
}
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
/// Create a new service with the given repositories and base directory.
pub fn new(
settings_repo: S,
app_config_repo: C,
base_dir: impl Into<PathBuf>,
) -> Self {
tracing::debug!("creating SettingsServiceImpl");
Self {
settings_repo,
app_config_repo,
base_dir: base_dir.into(),
}
}
}
impl<S: SettingsRepository, C: AppConfigRepository>
zesdex_domain::cms::SettingsService for SettingsServiceImpl<S, C>
{
fn load_settings(&self) -> Result<Settings, ServiceError> {
tracing::debug!("loading settings");
self.settings_repo
.load(&self.base_dir)
.map_err(ServiceError::Repository)
}
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError> {
tracing::debug!("saving settings");
self.settings_repo.save(&self.base_dir, settings)?;
Ok(())
}
fn update_provider(
&self,
name: &str,
config: &ProviderConfig,
) -> Result<(), ServiceError> {
tracing::debug!("updating provider '{name}'");
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
app_config
.providers
.insert(name.to_string(), config.clone());
self.app_config_repo.save(&self.base_dir, &app_config)?;
Ok(())
}
}
+51
View File
@@ -0,0 +1,51 @@
//! # Zesdex Application Layer
//!
//! Defines port traits (interfaces) and use-case implementations for the
//! Zesdex application. This crate depends **only** on the domain crate;
//! it has no knowledge of infrastructure or interface adapters.
//!
//! ## Architecture
//!
//! ```text
//! apps/application/src/
//! ├── lib.rs — crate root, re-exports
//! ├── ports/ — Port traits (interfaces to external services)
//! │ ├── provider.rs -- ProviderService (LLM chat completion)
//! │ ├── password.rs -- PasswordService (hash / verify)
//! │ ├── token.rs -- TokenService (JWT create / verify)
//! │ └── authentication.rs -- AuthService (combined auth)
//! ├── auth/ — Auth use-cases
//! │ ├── oauth_service.rs -- OAuth 2.0 PKCE flow
//! │ └── session_service.rs -- Session CRUD lifecycle
//! └── cms/ — CMS use-cases
//! ├── conversation_service.rs -- Conversation CRUD
//! ├── memory_service.rs -- Long-term memory management
//! └── settings_service.rs -- Settings & app-config management
//! ```
//!
//! ## Key Design Principle
//!
//! Application services are generic over their repository/port dependencies.
//! Concrete implementations are injected at the composition root, keeping
//! the use-case logic independent of any specific persistence or infrastructure
//! technology.
pub mod auth;
pub mod cms;
pub mod ports;
// Re-export port traits for ergonomic access.
pub use ports::*;
// Re-export auth use-cases.
pub use auth::{
oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger},
session_service::SessionServiceImpl,
};
// Re-export CMS use-cases.
pub use cms::{
conversation_service::ConversationServiceImpl,
memory_service::MemoryServiceImpl,
settings_service::SettingsServiceImpl,
};
@@ -0,0 +1,35 @@
//! AuthService port — combined authentication operations.
//!
//! Defines a high-level authentication trait that composes password
//! verification and token generation into a single use-case boundary.
//! Implementations delegate to the injected `PasswordService` and
//! `TokenService` adapters.
use anyhow::Result;
use std::future::Future;
/// High-level authentication service combining password verification
/// and token issuance (login flow).
///
/// # Flow
///
/// 1. **`authenticate`** — verify a subject's password against a stored hash.
/// 2. **`issue_tokens`** — generate an access + refresh token pair for a subject.
///
/// Implementations are generic over `PasswordService` and `TokenService`
/// port traits.
pub trait AuthService: Send + Sync {
/// Authenticate a user by verifying a password against a stored hash.
///
/// Returns `true` if the password matches, `false` otherwise.
fn authenticate(
&self,
password: &str,
hash: &str,
) -> impl Future<Output = Result<bool>> + Send;
/// Issue a new access + refresh token pair for the given subject.
///
/// Returns `(access_token, refresh_token)`.
fn issue_tokens(&self, sub: &str) -> Result<(String, String)>;
}
+22
View File
@@ -0,0 +1,22 @@
//! Port traits — interfaces for external / infrastructure services.
//!
//! These traits define the boundaries between the application layer and
//! the outside world. Infrastructure adapters implement these traits;
//! the application layer depends only on the trait definitions.
//!
//! # Ports
//!
//! - [`provider`] — `ProviderService`: LLM chat completion (streaming + non-streaming)
//! - [`password`] — `PasswordService`: password hashing and verification
//! - [`token`] — `TokenService`: JWT access/refresh token generation and verification
//! - [`authentication`] — `AuthService`: combined authentication operations
pub mod authentication;
pub mod password;
pub mod provider;
pub mod token;
pub use authentication::AuthService;
pub use password::PasswordService;
pub use provider::ProviderService;
pub use token::TokenService;
+24
View File
@@ -0,0 +1,24 @@
//! PasswordService port — password hashing and verification abstraction.
//!
//! Defines the trait that password-hashing adapters (argon2, bcrypt, etc.)
//! implement. The application layer depends only on this trait, never on
//! a concrete hashing library.
use anyhow::Result;
use std::future::Future;
/// Abstraction for password hashing and verification.
///
/// Implementors handle the actual hashing algorithm (argon2, bcrypt, etc.)
/// and parameter selection. The trait is `Send + Sync` for use in async
/// service layers.
pub trait PasswordService: Send + Sync {
/// Hash a plaintext password and return the encoded hash string
/// (suitable for storage in a credential store).
fn hash(&self, password: &str) -> impl Future<Output = Result<String>> + Send;
/// Verify a plaintext password against a previously-hashed string.
///
/// Returns `true` if the password matches the hash, `false` otherwise.
fn verify(&self, password: &str, hash: &str) -> impl Future<Output = Result<bool>> + Send;
}
+56
View File
@@ -0,0 +1,56 @@
//! ProviderService port — LLM chat completion provider abstraction.
//!
//! Defines the trait that HTTP-based provider clients (OpenAI, Anthropic,
//! etc.) implement. Supports both non-streaming and SSE-streaming chat
//! completion requests.
//!
//! # Flow
//!
//! 1. Caller builds a message list and optional tool definitions.
//! 2. `chat` sends a non-streaming request and returns the full response.
//! 3. `chat_stream` sends a streaming request and invokes `on_event` for
//! each parsed `StreamEvent` as it arrives, then returns the assembled
//! message and usage.
use anyhow::Result;
use std::future::Future;
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
/// Abstraction for an LLM provider chat-completion service.
///
/// Both methods accept a message list, optional tool definitions, and
/// generation parameters. Implementors handle authentication, HTTP
/// transport, retry logic, and response parsing internally.
///
/// # Send + Sync
///
/// This trait is `Send + Sync` so it can be shared across async tasks
/// and injected into service structs that require thread safety.
pub trait ProviderService: Send + Sync {
/// Send a non-streaming chat completion request.
///
/// Returns the assistant's `ChatMessage` and optional token usage
/// `(prompt_tokens, completion_tokens)`.
fn chat(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
max_tokens: Option<u32>,
temperature: Option<f32>,
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
/// Send a streaming chat completion request.
///
/// `on_event` is called for every parsed SSE event and returns `false`
/// to signal abort (caller cancellation). Returns the fully assembled
/// assistant message and optional usage once the stream completes.
fn chat_stream(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
max_tokens: Option<u32>,
temperature: Option<f32>,
on_event: Box<dyn FnMut(&StreamEvent) -> bool + Send>,
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
}
+26
View File
@@ -0,0 +1,26 @@
//! TokenService port — JWT access and refresh token abstraction.
//!
//! Defines the trait that JWT adapter implementations provide. Covers
//! token generation (pair of access + refresh tokens) and access token
//! verification (returns the subject claim).
use anyhow::Result;
/// Abstraction for JWT-based token generation and verification.
///
/// Implementors handle signing key management, token serialisation,
/// and expiry validation. The trait is `Send + Sync` for use across
/// thread boundaries.
pub trait TokenService: Send + Sync {
/// Generate an access + refresh token pair for the given subject
/// identifier.
///
/// Returns `(access_token, refresh_token)`.
fn generate_tokens(&self, sub: &str) -> Result<(String, String)>;
/// Verify an access token and return the embedded subject claim.
///
/// Returns `Err` if the token is expired, malformed, or has an
/// invalid signature.
fn verify_access_token(&self, token: &str) -> Result<String>;
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "zesdex-bootstrap"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Bootstrap binary — seeds initial system data (permissions, roles,
# admin user) idempotently. Run once after first deployment.
[[bin]]
name = "bootstrap"
path = "src/main.rs"
[dependencies]
zesdex-domain = { path = "../domain" }
zesdex-application = { path = "../application" }
zesdex-infrastructure = { path = "../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
dirs.workspace = true
+2
View File
@@ -0,0 +1,2 @@
//! Bootstrap library — shared utilities for the bootstrap binary.
//! The main entry point is in `main.rs`.
+38
View File
@@ -0,0 +1,38 @@
//! Bootstrap binary — seeds initial system data idempotently.
//!
//! Creates default permissions, roles, and admin user if they don't
//! already exist. Run once after first deployment.
//!
//! Usage: cargo run --bin bootstrap
fn main() -> anyhow::Result<()> {
println!("Zesdex Bootstrap — seeding initial data...");
let store = zesdex_domain::core::Store::new();
store.ensure_dirs()?;
// Seed default settings if not present
let settings_path = store.base_dir.join("settings.json");
if !settings_path.exists() {
let settings = zesdex_domain::cms::Settings::default();
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&settings_path, content)?;
println!(" ✓ Default settings created");
} else {
println!(" · Settings already exist, skipping");
}
// Seed default app config if not present
let config_path = store.base_dir.join("app_config.json");
if !config_path.exists() {
let config = zesdex_domain::cms::AppConfig::default();
let content = serde_json::to_string_pretty(&config)?;
std::fs::write(&config_path, content)?;
println!(" ✓ Default app_config created");
} else {
println!(" · App config already exists, skipping");
}
println!("Bootstrap complete.");
Ok(())
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "zesdex-domain"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Domain layer — PURE entities, value objects, repository/service traits.
# Zero framework dependencies. Only serde for serialization, chrono for
# timestamps, uuid for identity.
[dependencies]
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
base64.workspace = true
sha2.workspace = true
url.workspace = true
libc.workspace = true
anyhow.workspace = true
tracing.workspace = true
+30
View File
@@ -0,0 +1,30 @@
//! Command types for IAM domain operations.
//!
//! Following the `NewXxx` / command pattern from clean architecture,
//! these types encapsulate the input data for create/update operations
//! on domain entities. They decouple presentation DTOs from the entity
//! mutation surface and provide a clear boundary for validation.
/// Command to create a new session.
///
/// Carries only the data needed to construct a session entity — the
/// service generates the UUID and timestamp internally.
#[derive(Debug, Clone)]
pub struct NewSession {
/// Human-readable session title.
pub title: String,
}
impl From<String> for NewSession {
fn from(title: String) -> Self {
Self { title }
}
}
impl From<&str> for NewSession {
fn from(title: &str) -> Self {
Self {
title: title.to_string(),
}
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Domain error types for the IAM (auth) module.
//!
//! Typed error enums replace `anyhow::Result` in domain traits and
//! application services, enabling callers to match on specific error
//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking.
//!
//! # Components
//!
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
//! - [`ServiceError`] — use-case / orchestration errors (config, state
//! mismatch, provider failures)
use std::fmt;
use crate::error::DomainError;
/// Shared repository error type for IAM persistence operations.
pub type RepositoryError = DomainError;
/// Errors from service / use-case operations in the IAM domain.
#[derive(Debug)]
pub enum ServiceError {
/// A repository operation failed.
Repository(DomainError),
/// The provided configuration is invalid.
InvalidConfig(String),
/// OAuth state mismatch — possible CSRF attack.
StateMismatch,
/// The OAuth provider returned an error.
OAuthProvider(String),
/// A generic error with a message.
Other(String),
}
impl From<DomainError> for ServiceError {
fn from(err: DomainError) -> Self {
ServiceError::Repository(err)
}
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
ServiceError::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"),
ServiceError::StateMismatch => {
write!(f, "OAuth state mismatch — possible CSRF attack")
}
ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {msg}"),
ServiceError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ServiceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ServiceError::Repository(err) => Some(err),
_ => None,
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//! IAM Session re-export.
//!
//! Re-exports `Session` from the auth module for consistent IAM-boundary
//! imports. Consumers of the IAM module import `Session` from here rather
//! than from the core session module directly, keeping the dependency
//! internal and allowing the IAM crate to own its domain vocabulary.
pub use super::session::Session;
/// Alias for `Session` used in IAM contexts to distinguish from other
/// session types in the system.
pub type IamSession = Session;
+36
View File
@@ -0,0 +1,36 @@
//! Authentication domain entities, commands, errors, and repository/service traits.
//!
//! Combines the session types from `zesdex-entities` (auth sub-module) with the
//! IAM domain types (commands, OAuth, repository/service traits) from `zesdex-iam`.
//!
//! # Sub-modules
//!
//! - [`session`] — `Session` entity (session metadata)
//! - [`session_id`] — `SessionId` value object (validated newtype)
//! - [`session_lock`] — `SessionLock` RAII guard (PID-file lock)
//! - [`oauth`] — `OAuthToken`, `OAuthConfig` entities
//! - [`iam_session`] — Re-export of `Session` for IAM-boundary consistency
//! - [`commands`] — `NewSession` command type
//! - [`error`] — `RepositoryError`, `ServiceError` types
//! - [`repository`] — `SessionRepository`, `SessionLockRepository`, `OAuthRepository`
//! - [`service`] — `SessionService`, `OAuthService` traits
pub mod commands;
pub mod error;
pub mod iam_session;
pub mod oauth;
pub mod repository;
pub mod service;
pub mod session;
pub mod session_id;
pub mod session_lock;
pub use commands::NewSession;
pub use error::{RepositoryError, ServiceError};
pub use iam_session::IamSession;
pub use oauth::{OAuthConfig, OAuthToken};
pub use repository::{OAuthRepository, SessionLockRepository, SessionRepository};
pub use service::{OAuthService, SessionService};
pub use session::Session;
pub use session_id::SessionId;
pub use session_lock::SessionLock;
+53
View File
@@ -0,0 +1,53 @@
//! Pure OAuth entities — no HTTP or persistence logic.
//!
//! # Components
//!
//! - [`OAuthToken`] — access token with optional refresh token, epoch expiry
//! - [`OAuthConfig`] — provider configuration (auth URL, token URL, client id,
//! optional client secret, scopes)
use serde::{Deserialize, Serialize};
/// An OAuth 2.0 access token with optional refresh token and absolute
/// expiry time (epoch seconds).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken {
/// The OAuth 2.0 access token string.
pub access_token: String,
/// Optional refresh token for long-lived access.
pub refresh_token: Option<String>,
/// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH).
pub expires_at: u64,
/// Token type, e.g. `"Bearer"`.
pub token_type: String,
}
/// Static configuration for an OAuth provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
/// Authorization endpoint URL.
pub auth_url: String,
/// Token exchange endpoint URL.
pub token_url: String,
/// OAuth client identifier.
pub client_id: String,
/// Optional client secret (not all flows require it).
pub client_secret: Option<String>,
/// Space-separated list of requested scopes.
pub scopes: Vec<String>,
}
impl Default for OAuthConfig {
fn default() -> Self {
OAuthConfig {
auth_url: String::new(),
token_url: String::new(),
client_id: String::new(),
client_secret: None,
scopes: vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
],
}
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Repository trait definitions (pure — no impls, no concrete persistence).
//!
//! Defines the repository contracts that infrastructure adapters implement.
//! Following clean architecture, domain code depends only on these traits,
//! not on concrete persistence libraries.
//!
//! # Traits
//!
//! - [`SessionRepository`] — CRUD for session metadata
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
//! - [`OAuthRepository`] — persist/load OAuth tokens
use std::path::Path;
use crate::auth::error::RepositoryError;
use crate::auth::oauth::OAuthToken;
use crate::auth::session::Session;
use crate::auth::session_id::SessionId;
/// Repository for loading, saving, listing, and deleting sessions.
pub trait SessionRepository {
/// List all loadable sessions under `<base_dir>/sessions/`.
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError>;
/// Load a single session by id.
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError>;
/// Save a session's metadata to disk.
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError>;
/// Delete a session directory and all its contents.
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError>;
}
/// Repository for per-session PID-file advisory locks.
pub trait SessionLockRepository {
/// Try to acquire the lock for a session directory.
/// Returns `true` if the lock was acquired, `false` if another live
/// process holds it.
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError>;
/// Release the lock by removing the lock file.
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError>;
/// Check whether a process with the given PID is alive.
fn is_alive(&self, pid: u32) -> bool;
}
/// Repository for persisting and loading OAuth tokens.
pub trait OAuthRepository {
/// Persist an OAuth token to a JSON file.
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError>;
/// Load an OAuth token from a JSON file, returning `None` if the file
/// does not exist.
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError>;
}
+56
View File
@@ -0,0 +1,56 @@
//! Service trait definitions — use-case interfaces for session management
//! and OAuth flows.
//!
//! These traits define the boundary between the application orchestration
//! layer and the domain. Implementations live in the application layer.
//!
//! # Traits
//!
//! - [`SessionService`] — create, list, archive sessions
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
use crate::auth::error::ServiceError;
use crate::auth::oauth::{OAuthConfig, OAuthToken};
use crate::auth::session::Session;
use crate::auth::session_id::SessionId;
/// Session management use-case boundary.
pub trait SessionService {
/// Create a new session with a generated UUID and the given title.
fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
/// List all available sessions.
fn list_all(&self) -> Result<Vec<Session>, ServiceError>;
/// Archive a session by id (sets `archived = true`).
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>;
}
/// OAuth flow use-case boundary.
pub trait OAuthService {
/// Start an OAuth authorization-code + PKCE flow for the given
/// `redirect_uri` (the caller is responsible for actually listening on
/// it — e.g. a bound `LoopbackServer`). Returns `(auth_url, state)`:
/// the URL to send the user to, and the CSRF state token that must be
/// passed back into `complete_flow` unchanged.
fn start_flow(
&self,
config: &OAuthConfig,
redirect_uri: &str,
) -> Result<(String, String), ServiceError>;
/// Complete the OAuth flow: validates `state` against the value
/// persisted during `start_flow` (bailing on mismatch — this is the
/// CSRF check), then exchanges `code` for a token using the same
/// `redirect_uri` passed to `start_flow`.
fn complete_flow(
&self,
config: &OAuthConfig,
redirect_uri: &str,
code: &str,
state: &str,
) -> Result<OAuthToken, ServiceError>;
/// Retrieve the currently stored OAuth token (if any).
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
}
+71
View File
@@ -0,0 +1,71 @@
//! Session metadata: id, title, workspace roots, and message/token counts,
//! persisted as `session.json` per session directory.
//!
//! # Flow
//!
//! Created via [`Session::new`] → mutated in-memory → persisted via repository.
//!
//! # Components
//!
//! - `Session` struct — fields for all session metadata
//! - `new` — timestamped constructor
//! - `session_dir` / `conversation_path` — pure path computation
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Metadata for one conversation session (distinct from the message
/// history itself, which lives in `Conversation`/the msglog).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
/// Unique session identifier (validated against path traversal in `load`).
pub id: String,
/// Epoch-millis timestamp of creation (`Utc::now().timestamp_millis()`).
pub created_at: i64,
/// Epoch-millis timestamp of last update.
pub updated_at: i64,
/// Human-readable title for the conversation.
pub title: String,
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
pub model: String,
/// Workspace root directories associated with this session.
pub workspace_roots: Vec<PathBuf>,
/// Running count of messages in the conversation.
pub message_count: u32,
/// Running count of tokens consumed.
pub token_count: u32,
/// Soft-delete flag — archived sessions are hidden from the default list.
pub archived: bool,
/// Optional AI-generated conversation summary (used for compact context).
pub summary: Option<String>,
}
impl Session {
/// Create a new session with the given id/title, defaulting the
/// model, workspace root (current dir), and counters.
pub fn new(id: String, title: String) -> Self {
let now = Utc::now().timestamp_millis();
Session {
id,
created_at: now,
updated_at: now,
title,
model: "anthropic/claude-opus-4-8".to_string(),
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
message_count: 0,
token_count: 0,
archived: false,
summary: None,
}
}
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
base_dir.join("sessions").join(&self.id)
}
/// Compute this session's `conversation.json` path.
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("conversation.json")
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Validated session identifier newtype.
//!
//! [`SessionId`] wraps a `String` that has been checked for path-traversal
//! characters. Construction via `SessionId::new(str)` validates the input
//! once; the guarantee is then enforced by the type system for all
//! downstream use.
//!
//! # Validation rules
//!
//! - Must not be empty
//! - Must only contain alphanumeric characters, hyphens, and underscores
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
/// A validated session identifier.
///
/// Guarantees the inner string is non-empty and contains no path-traversal
/// characters (`/`, `\\`, `..`) or other unsafe delimiters.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct SessionId(String);
impl SessionId {
/// Validate and construct a `SessionId`.
///
/// Returns `Err(msg)` if the input contains path separators, `..`, or
/// is empty.
pub fn new(id: &str) -> Result<Self, String> {
if id.is_empty() {
return Err("session id must not be empty".to_string());
}
if id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(format!(
"session id '{id}' must not contain path separators"
));
}
Ok(SessionId(id.to_string()))
}
/// Return the underlying string.
pub fn as_str(&self) -> &str {
&self.0
}
/// Return the underlying owned string.
pub fn into_string(self) -> String {
self.0
}
/// Append this session id as a component of `base_dir`, yielding
/// `base_dir / self.0`.
///
/// Safe because the id has been validated to contain no path separators.
pub fn join_to(&self, base_dir: &Path) -> PathBuf {
base_dir.join(&self.0)
}
}
impl AsRef<str> for SessionId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<SessionId> for String {
fn from(sid: SessionId) -> Self {
sid.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_uuids() {
assert!(SessionId::new("550e8400-e29b-41d4-a716-446655440000").is_ok());
assert!(SessionId::new("my-session_123").is_ok());
}
#[test]
fn test_rejects_path_traversal() {
assert!(SessionId::new("../etc/passwd").is_err());
assert!(SessionId::new("foo/../../bar").is_err());
assert!(SessionId::new("foo\\..\\bar").is_err());
}
#[test]
fn test_rejects_empty() {
assert!(SessionId::new("").is_err());
}
#[test]
fn test_into_string() {
let sid = SessionId::new("abc-123").unwrap();
assert_eq!(sid.into_string(), "abc-123");
}
}
+154
View File
@@ -0,0 +1,154 @@
//! PID-file based advisory lock preventing two processes from operating on
//! the same session directory concurrently.
//!
//! # Flow
//!
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
//! owning PID is checked via liveness verification. Stale locks are
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
//! the lock file is removed automatically.
//!
//! # Components
//!
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
//! - `unlock` / `Drop` — explicit and implicit release
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing;
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
/// auto-removed on drop.
#[derive(Debug)]
pub struct SessionLock {
/// Path to the `.lock` file inside the session directory.
pub(crate) path: PathBuf,
/// Process ID that holds (or will hold) this lock.
pub(crate) pid: u32,
}
impl SessionLock {
/// Construct a lock handle for a session directory (does not acquire
/// the lock yet — call `try_lock`).
pub fn new(session_dir: &Path) -> Self {
SessionLock {
path: session_dir.join(".lock"),
pid: std::process::id(),
}
}
/// Attempt to acquire the session lock using an atomic file creation.
///
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
/// succeeds, the lock is ours — write our PID and return ok. If the
/// file already exists, read the PID inside it and check whether that
/// PID is still alive: if the process is still running, fail to acquire;
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
///
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
/// process holds it, `Err` on I/O failure.
pub fn try_lock(&self) -> std::io::Result<bool> {
// Phase 1: try atomic create. If it succeeds, the lock is ours.
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&self.path)
{
Ok(mut file) => {
write!(file, "{}", self.pid)?;
file.sync_all()?;
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
return Ok(true);
}
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness");
// Lock file exists — check if it's stale.
}
Err(e) => return Err(e),
}
// Phase 2: lock file exists — check liveness of the owning process.
let content = fs::read_to_string(&self.path).unwrap_or_default();
if let Ok(pid) = content.trim().parse::<u32>() {
if Self::is_alive(pid) {
tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process");
return Ok(false);
}
tracing::debug!(stale = pid, "stale lock detected, overwriting");
}
// Phase 3: stale lock — overwrite it atomically (best-effort).
// Use a temp file + rename to avoid partial writes corrupting the lock.
let tmp = self.path.with_extension("lock.tmp");
{
let mut tmp_file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
write!(tmp_file, "{}", self.pid)?;
tmp_file.sync_all()?;
}
fs::rename(&tmp, &self.path)?;
// Sync the parent directory so the rename survives a crash.
if let Some(parent) = self.path.parent() {
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
}
Ok(true)
}
/// Explicitly release the lock by removing the lock file.
pub fn unlock(&self) {
let _ = fs::remove_file(&self.path);
}
/// Check whether a process with the given PID is currently alive.
///
/// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production;
/// here we provide a best-effort check using the process table.
/// On non-Unix platforms this always returns `true` (conservative).
fn is_alive(pid: u32) -> bool {
// On Unix, signal 0 checks process existence without sending a signal.
#[cfg(unix)]
{
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
// whether the process exists and the caller has permission to signal it.
// The integer argument is a PID validated by `try_lock`.
let pid_signed: i32 = match pid.try_into() {
Ok(p) => p,
Err(_) => return false,
};
if unsafe { libc::kill(pid_signed, 0) != 0 } {
return false;
}
// Extra check: verify the PID belongs to a zesdex process via
// /proc/<pid>/exe to mitigate the PID-reuse race.
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
if let Ok(target) = std::fs::read_link(&proc_exe) {
if let Ok(exe) = std::env::current_exe() {
if target != exe {
return false;
}
}
}
true
}
#[cfg(not(unix))]
{
// Fallback: always assume alive (conservative).
let _ = pid;
true
}
}
}
impl Drop for SessionLock {
/// Release the lock automatically when the guard goes out of scope,
/// so an ungracefully-exited process doesn't leave a dangling lock.
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
+127
View File
@@ -0,0 +1,127 @@
//! 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
//! 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 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>,
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 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,
pub api_key_env: Option<String>,
pub default_model: Option<String>,
pub default_api_key: Option<String>,
}
/// 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,
pub model: String,
pub max_tokens: Option<u32>,
pub context_window: Option<u32>,
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(
"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,
}
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Command types for CMS domain operations.
//!
//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture,
//! these types encapsulate the input data for create/update operations
//! on domain entities. They decouple presentation DTOs from the entity
//! mutation surface and provide a clear boundary for validation.
use std::collections::HashMap;
use super::settings::InternetMode;
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
/// Partial update command for `Settings`.
///
/// Every field is `Option`al — only non-`None` fields are applied to the
/// existing settings instance. Use `apply_to()` to merge into a `Settings`
/// value.
#[derive(Debug, Clone, Default)]
pub struct SettingsPatch {
/// Override the internet access mode.
pub internet_mode: Option<String>,
/// Override the active provider name.
pub provider: Option<String>,
/// Override the active model name.
pub model: Option<String>,
/// Replace the entire API-keys map.
pub api_keys: Option<HashMap<String, String>>,
/// Override the max tokens for completions.
pub max_tokens: Option<Option<u32>>,
/// Override the temperature for completions.
pub temperature: Option<Option<f32>>,
/// Override the review max lessons per run.
pub review_max_lessons_per_run: Option<usize>,
/// Override the adaptive review max skip count.
pub adaptive_review_max_skip: Option<u32>,
/// Override the verify shell command.
pub verify_command: Option<Option<String>>,
/// Override the verify timeout in milliseconds.
pub verify_timeout_ms: Option<u64>,
/// Override the max concurrency for workflow execution.
pub workflow_max_concurrency: Option<usize>,
/// Override the review-enabled flag.
pub review_enabled: Option<bool>,
/// Override the session-archive-enabled flag.
pub session_archive_enabled: Option<bool>,
/// Override the LSP auto-provision flag.
pub lsp_auto_provision: Option<bool>,
/// Override the list of LSP-managed languages.
pub lsp_languages: Option<Vec<String>>,
/// Override the hive-mind node timeout in milliseconds.
pub hive_mind_node_timeout_ms: Option<u64>,
}
impl SettingsPatch {
/// Merge this patch into `settings`, overwriting each non-`None` field.
///
/// Flow: for each optional field, if `Some`, assign it to the target.
///
/// ## Errors
/// Returns `Err` with a message if `internet_mode` is set to an
/// unrecognised value.
pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> {
if let Some(ref val) = self.internet_mode {
settings.internet_mode = match val.as_str() {
"Off" => InternetMode::Off,
"ReadOnly" => InternetMode::ReadOnly,
"Full" => InternetMode::Full,
_ => {
return Err(format!(
"invalid internet_mode '{val}'; expected Off, ReadOnly, or Full"
))
}
};
}
if let Some(ref val) = self.provider {
settings.provider = val.clone();
}
if let Some(ref val) = self.model {
settings.model = val.clone();
}
if let Some(ref val) = self.api_keys {
settings.api_keys = val.clone();
}
if let Some(val) = self.max_tokens {
settings.max_tokens = val;
}
if let Some(val) = self.temperature {
settings.temperature = val;
}
if let Some(val) = self.review_max_lessons_per_run {
settings.review_max_lessons_per_run = val;
}
if let Some(val) = self.adaptive_review_max_skip {
settings.adaptive_review_max_skip = val;
}
if let Some(ref val) = self.verify_command {
settings.verify_command = val.clone();
}
if let Some(val) = self.verify_timeout_ms {
settings.verify_timeout_ms = val;
}
if let Some(val) = self.workflow_max_concurrency {
settings.workflow_max_concurrency = val;
}
if let Some(val) = self.review_enabled {
settings.flags.review_enabled = val;
}
if let Some(val) = self.session_archive_enabled {
settings.flags.session_archive_enabled = val;
}
if let Some(val) = self.lsp_auto_provision {
settings.flags.lsp_auto_provision = val;
}
if let Some(ref val) = self.lsp_languages {
settings.lsp_languages = val.clone();
}
if let Some(val) = self.hive_mind_node_timeout_ms {
settings.hive_mind_node_timeout_ms = val;
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Memory
// ---------------------------------------------------------------------------
/// Command to create a new memory entry.
///
/// All required fields are non-optional; optional fields use `Option`
/// and default to sensible values (empty or the service default).
#[derive(Debug, Clone)]
pub struct NewMemory {
/// Unique name / slug for the memory.
pub name: String,
/// One-line summary of what the memory captures.
pub description: String,
/// The full memory content.
pub content: String,
/// Category kind (defaults to "reference" in the handler).
pub kind: Option<String>,
/// Outcome of the remembered action.
pub outcome: Option<String>,
/// Lifecycle stage (defaults to "new" in the handler).
pub lifecycle: Option<String>,
/// Scope context for the memory.
pub scope: Option<String>,
/// Code snippet captured before the action.
pub before_snippet: Option<String>,
/// Code snippet captured after the action.
pub after_snippet: Option<String>,
/// Source provenances (files, conversations, etc.).
pub provenances: Option<Vec<String>>,
}
+15
View File
@@ -0,0 +1,15 @@
//! Pure domain entity for conversations and chat messages.
//!
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
//! types from the core module to provide a consistent domain import
//! boundary within the CMS module. All CMS code references conversation
//! types through this module rather than depending on the core module
//! 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 crate::core::message::{ChatMessage, Role};
pub use crate::core::conversation::Conversation;
+81
View File
@@ -0,0 +1,81 @@
//! 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` (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 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,
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-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 with no entries.
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 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()
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Domain error types for the CMS module.
//!
//! Typed error enums for repository and service operations.
//!
//! # Components
//!
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
//! - [`ServiceError`] — use-case / orchestration errors (invalid input, generic)
use std::fmt;
use crate::error::DomainError;
/// Shared repository error type for CMS persistence operations.
pub type RepositoryError = DomainError;
/// Errors from service / use-case operations in the CMS domain.
#[derive(Debug)]
pub enum ServiceError {
/// A repository operation failed.
Repository(DomainError),
/// The provided input is invalid.
InvalidInput(String),
/// A generic error with a message.
Other(String),
}
impl From<DomainError> for ServiceError {
fn from(err: DomainError) -> Self {
ServiceError::Repository(err)
}
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
ServiceError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ServiceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ServiceError::Repository(err) => Some(err),
_ => None,
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//! 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` (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 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,
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> {
// 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())
.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.
///
/// ## Parameters
/// - `memory_dir` — the base directory for memory storage
/// - `name` — the memory name (will be slugified internally)
///
/// 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")
.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
})
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Domain layer for CMS — pure entities, value objects, repository traits,
//! and service interfaces.
//!
//! 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 (re-exported from core)
//! - `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 commands;
pub mod conversation;
pub mod edit_log;
pub mod error;
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 error::{RepositoryError, ServiceError};
pub use memory::Memory;
pub use repository::AppConfigRepository;
pub use repository::ConversationRepository;
pub use repository::EditLogRepository;
pub use repository::MemoryRepository;
pub use repository::RewindBlobRepository;
pub use repository::SettingsRepository;
pub use service::ConversationService;
pub use service::MemoryService;
pub use service::SettingsService;
pub use commands::{NewMemory, SettingsPatch};
pub use settings::InternetMode;
pub use settings::Settings;
pub use settings::SettingsFlags;
+126
View File
@@ -0,0 +1,126 @@
//! 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.
//!
//! ## 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;
use super::app_config::AppConfig;
use super::conversation::Conversation;
use super::edit_log::{EditLog, EditLogEntry};
use super::error::RepositoryError;
use super::memory::Memory;
use super::settings::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 the given base directory.
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError>;
/// Persist `Settings` to the given base directory.
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError>;
}
/// 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 `AppConfig` from the given base directory.
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError>;
/// Persist `AppConfig` to the given base directory.
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError>;
}
/// 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 the given session directory.
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
/// Persist a `Conversation` to the given session directory.
fn save(
&self,
session_dir: &Path,
conversation: &Conversation,
) -> Result<(), RepositoryError>;
}
/// 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 (filenames without extension) in the memory directory.
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError>;
/// Load a single `Memory` by name from the memory directory.
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError>;
/// Save (create or overwrite) a `Memory` in the memory directory.
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError>;
/// Delete a `Memory` by name from the memory directory.
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError>;
}
/// 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 binary blob under `blob_key` for this session.
fn store_blob(
&self,
session_dir: &Path,
blob_key: &str,
data: &[u8],
mime_type: Option<&str>,
) -> Result<(), RepositoryError>;
/// Retrieve a blob's raw bytes by key, or `None` if not found.
fn retrieve_blob(
&self,
session_dir: &Path,
blob_key: &str,
) -> Result<Option<Vec<u8>>, RepositoryError>;
/// List all blob keys for this session, ordered oldest-first.
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError>;
}
/// 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 initialise) the edit log for a session directory.
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
/// Append one entry to the log and persist immediately (write-through).
fn append(
&self,
session_dir: &Path,
log: &mut EditLog,
entry: EditLogEntry,
) -> Result<(), RepositoryError>;
/// Return a cloned copy of all in-memory entries for inspection.
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
}
+64
View File
@@ -0,0 +1,64 @@
//! Service trait definitions — use-case boundaries for CMS operations.
//!
//! 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 super::conversation::{ChatMessage, Conversation};
use super::error::ServiceError;
use super::memory::Memory;
use super::settings::Settings;
/// Use-cases for application settings.
pub trait SettingsService {
/// Load the current `Settings` from the default store location.
fn load_settings(&self) -> Result<Settings, ServiceError>;
/// Persist updated `Settings` to the default store location.
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
/// Update (or insert) a provider configuration entry.
fn update_provider(
&self,
name: &str,
config: &super::app_config::ProviderConfig,
) -> Result<(), ServiceError>;
}
/// Use-cases for conversation (session message) management.
pub trait ConversationService {
/// Load a `Conversation` for the given session ID.
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError>;
/// Persist a `Conversation` to its session storage.
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
/// Append a single `ChatMessage` to the conversation and persist.
fn add_message(
&self,
conv: &mut Conversation,
msg: ChatMessage,
) -> Result<(), ServiceError>;
}
/// Use-cases for long-term memory management.
pub trait MemoryService {
/// List all memory slugs (filenames without extension).
fn list_memories(&self) -> Result<Vec<String>, ServiceError>;
/// Save (create or overwrite) a `Memory`.
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError>;
/// Delete a `Memory` by its slug/name.
fn delete_memory(&self, name: &str) -> Result<(), ServiceError>;
}
+120
View File
@@ -0,0 +1,120 @@
//! 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` (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,
}
/// 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,
pub session_archive_enabled: bool,
pub lsp_auto_provision: bool,
}
impl Default for SettingsFlags {
/// Returns the default flags with all features enabled.
fn default() -> Self {
Self {
review_enabled: true,
session_archive_enabled: true,
lsp_auto_provision: true,
}
}
}
/// Returns the default hive-mind node timeout (600 seconds).
fn default_hive_mind_node_timeout_ms() -> u64 {
600_000
}
/// Top-level application settings model.
///
/// 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,
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>,
#[serde(default = "default_hive_mind_node_timeout_ms")]
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,
}
}
}
+88
View File
@@ -0,0 +1,88 @@
//! In-memory conversation state: message history plus the system prompt and
//! model parameters used to drive the LLM.
//!
//! # Flow
//!
//! [`Conversation::new`] → [`push`](Conversation::push) to add messages →
//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM
//! API (system prompt prepended).
//!
//! # Components
//!
//! - `Conversation` — message vector + session metadata + generation params
//! - `push` / `rebuild_system` — mutation helpers
//! - `to_api_messages` — formats messages for API consumption
use serde::{Deserialize, Serialize};
use super::message::{ChatMessage, Role};
/// A single conversation's message history and generation settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
/// Ordered list of chat messages (user, assistant, tool, system).
pub messages: Vec<ChatMessage>,
/// System prompt prepended at request time (see `to_api_messages`).
pub system_prompt: String,
/// Foreign key referencing the owning session.
pub session_id: String,
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
pub model: String,
/// Optional cap on output tokens.
pub max_tokens: Option<u32>,
/// Optional temperature (0.0 – 2.0).
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 {
Conversation {
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.
///
/// Why: the system prompt is re-injected fresh at request time via
/// `to_api_messages`, so stale `System` messages in `self.messages`
/// would be redundant/conflicting if left in place.
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.
///
/// Return: a new `Vec` (clone of history) with a synthesized system
/// message at index 0.
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()
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Chat message types shared across the entity layer.
//!
//! Provides [`Role`] (conversation participant) and [`ChatMessage`] (a single
//! message with optional tool-call metadata). Includes convenience constructors
//! for each role: `user`, `assistant`, `system`, `tool`/`tool_result`.
//!
//! # Flow
//!
//! Messages are constructed via the typed constructors → pushed into
//! [`Conversation`](super::conversation::Conversation) → serialized as JSON
//! to `conversation.json`.
use serde::{Deserialize, Serialize};
/// The conversation participant who authored a message.
///
/// Variants: `User`, `Assistant`, `System`, `Tool`. Serialized as lowercase
/// strings (e.g. `"user"`, `"assistant"`, `"system"`, `"tool"`).
#[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,
}
impl Role {
/// Return the role as a lowercase string.
pub fn as_str(&self) -> &'static str {
match self {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
Role::Tool => "tool",
}
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// A single message in a conversation, compatible with the OpenAI/Anthropic
/// chat-completion API structures.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
/// Who sent this message (user, assistant, system, tool).
pub role: Role,
/// The message text content. `None` for assistant messages that only
/// contain tool calls.
pub content: Option<String>,
/// Tool-call requests attached to an assistant message (OpenAI-style).
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
/// For tool-role messages: the `id` of the `ToolCall` being responded to.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
/// Optional function name for the tool invocation.
#[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 {
ChatMessage {
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 {
ChatMessage {
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 {
ChatMessage {
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 {
ChatMessage {
role: Role::Tool,
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call_id),
name: None,
}
}
/// Alias for `tool`, used throughout the codebase for tool results.
pub fn tool_result(tool_call_id: String, content: String) -> Self {
ChatMessage {
role: Role::Tool,
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call_id),
name: None,
}
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Core domain entities shared across the Zesdex application.
//!
//! Contains pure data structures for conversations, messages, tool calls,
//! usage statistics, provider API types, and store path configuration.
//! All types derive `Serialize`/`Deserialize` for JSON persistence.
//!
//! # Sub-modules
//!
//! - [`conversation`] — Ordered conversation (vector of `ChatMessage`)
//! - [`message`] — `ChatMessage` + `Role` enum
//! - [`provider`] — LLM provider API types: `ChatRequest`, `ChatResponse`,
//! `StreamEvent`, `SseParser`, `ToolDef`, etc.
//! - [`store`] — `Store` paths for data directories
//! - [`tool_call`] — `ToolCall` + `ToolFunction` (function-calling request)
//! - [`tool_result`] — `ToolCallResult` (function-calling response)
//! - [`usage`] — `UsageStats` (token counts, costs)
pub mod conversation;
pub mod message;
pub mod provider;
pub mod store;
pub mod tool_call;
pub mod tool_result;
pub mod usage;
pub use conversation::Conversation;
pub use message::{ChatMessage, Role};
pub use provider::{
ChatRequest, ChatResponse, Choice, Delta, SseParser, StreamEvent, StreamOptions, TokenUsage,
ToolDef, ToolFunctionDef,
};
pub use store::Store;
pub use tool_call::{ToolCall, ToolFunction};
pub use tool_result::ToolCallResult;
pub use usage::UsageStats;
+387
View File
@@ -0,0 +1,387 @@
//! Provider-facing DTOs: chat completion request, response, streaming types,
//! and the SSE stream parser.
//!
//! # Flow
//!
//! 1. **Request** — [`ChatRequest`] is built with model, messages, tools,
//! streaming options and sent to the LLM provider.
//! 2. **Response** — Non-streaming responses arrive as [`ChatResponse`] with
//! [`Choice`]s containing the full [`ChatMessage`](super::message::ChatMessage).
//! 3. **Streaming** — SSE chunks are fed into [`SseParser::feed`] which yields
//! [`StreamEvent`]s: token/text, reasoning, tool-call deltas, usage, done.
//!
//! # Components
//!
//! - `ChatRequest` / `StreamOptions` / `ToolDef` / `ToolFunctionDef` — outbound
//! - `ChatResponse` / `Choice` / `Delta` / `TokenUsage` — non-streaming inbound
//! - `StreamEvent` — one atomic streaming event (Token, Reasoning,
//! ToolCallDelta, Usage, Done, Error)
//! - `SseParser` — incremental SSE frame parser: `feed()` → `Vec<StreamEvent>`
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing;
// ---------------------------------------------------------------------------
// Chat request / response
// ---------------------------------------------------------------------------
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
/// provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
/// Model identifier, e.g. `"anthropic/claude-opus-4-8"`.
pub model: String,
/// Full message history (system + user + assistant + tool turns).
pub messages: Vec<super::message::ChatMessage>,
/// Maximum number of output tokens.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
/// Sampling temperature (0.0 – 2.0).
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// Tool definitions available to the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDef>>,
/// Controls which (if any) function is called by the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
/// Whether to use SSE streaming (`true`) or a single response.
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
/// Nucleus sampling threshold.
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
/// Sequences where the model should stop generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
/// Additional streaming options (e.g. `include_usage`).
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
}
/// Streaming options for the request; `include_usage` asks the provider to
/// emit a final usage chunk in the SSE stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamOptions {
pub include_usage: bool,
}
/// Wire format for a single tool definition sent to the provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef {
/// The tool type discriminator, e.g. `"function"`.
#[serde(rename = "type")]
pub type_: String,
/// The function definition (name, description, JSON schema).
pub function: ToolFunctionDef,
}
/// Name, description, and JSON schema parameters for a tool definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionDef {
/// The function name the model may invoke.
pub name: String,
/// Human-readable description of what the function does.
pub description: String,
/// JSON Schema object describing the expected arguments.
pub parameters: Value,
}
/// Non-streaming chat completion response returned by the provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
/// Unique response identifier from the provider.
pub id: String,
/// Object type, e.g. `"chat.completion"`.
pub object: Option<String>,
/// Model identifier that produced this response.
pub model: String,
/// One or more completion candidates.
pub choices: Vec<Choice>,
/// Token usage statistics (prompt, completion, total).
pub usage: Option<TokenUsage>,
/// Unix-timestamp of response creation.
pub created: Option<i64>,
}
/// One completion candidate within a `ChatResponse.choices` list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
/// Zero-based index of this choice in the candidate list.
pub index: u32,
/// Full message (non-streaming response).
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<super::message::ChatMessage>,
/// Incremental delta (streaming response).
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<Delta>,
/// Why the model stopped: `"stop"`, `"tool_calls"`, `"length"`, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
/// Incremental delta emitted in a streaming SSE chunk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delta {
/// Role being set for the first streaming chunk.
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<super::message::Role>,
/// Incremental text content delta.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Incremental tool-call delta (partial name/arguments).
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
}
/// Token counts and optional cost breakdown for a single completion request.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenUsage {
/// Tokens consumed by the prompt (input).
pub prompt_tokens: u32,
/// Tokens consumed by the completion (output).
pub completion_tokens: u32,
/// Sum of prompt + completion tokens.
pub total_tokens: u32,
/// Estimated cost for prompt tokens (provider-specific).
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_cost: Option<f64>,
/// Estimated cost for completion tokens (provider-specific).
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens_cost: Option<f64>,
}
// ---------------------------------------------------------------------------
// SSE streaming
// ---------------------------------------------------------------------------
/// One atomic event extracted from an LLM streaming response stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamEvent {
/// An incremental text token.
Token(String),
/// An incremental reasoning token (Anthropic `reasoning_content`).
Reasoning(String),
/// An incremental tool-call delta (partial ID, name, or arguments).
ToolCallDelta {
/// Tool-call index (multiple calls in one response).
index: usize,
/// Optional tool-call ID (usually in the first delta for a call).
id: Option<String>,
/// Optional function name (usually in the first delta for a call).
name: Option<String>,
/// Partial JSON arguments delta for this tool call.
arguments_delta: String,
},
/// Final usage chunk with token counts.
Usage {
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
},
/// Stream complete (all tokens have been delivered).
Done,
/// A stream-level error occurred.
Error(String),
}
/// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser {
/// Leftover bytes from the last chunk that did not end with `\n`.
buffer: String,
/// The current `event:` type (set by `event:` lines, cleared on flush).
event_type: Option<String>,
/// Accumulated `data:` lines for the current event frame.
data_lines: Vec<String>,
}
impl SseParser {
/// Create a new parser with an empty buffer.
pub fn new() -> Self {
SseParser {
buffer: String::new(),
event_type: None,
data_lines: Vec::new(),
}
}
/// Feed a raw SSE chunk and produce any completed events.
///
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
/// blank line, call `flush_event` to parse the accumulated data →
/// on `event:` line, store the event type → on `data:` line, append
/// to data accumulator → continue until buffer exhausted.
///
/// Edge case: a chunk may split mid-line; the remainder stays in the
/// buffer for the next `feed()` call.
///
/// Return: all `StreamEvent`s completed by this chunk.
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
self.buffer.push_str(chunk);
let mut events = Vec::new();
while let Some(line_end) = self.buffer.find('\n') {
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
self.buffer = self.buffer[line_end + 1..].to_string();
if line.is_empty() {
events.extend(self.flush_event());
} else if let Some(ty) = line.strip_prefix("event: ") {
self.event_type = Some(ty.trim().to_string());
} else if let Some(data) = line.strip_prefix("data:") {
let data = data.trim_start().to_string();
self.data_lines.push(data);
}
}
events
}
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
///
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
/// emit `Usage` if a usage object is present → else match `event_type`
/// ("message.stop", "message.delta", etc.) → extract content,
/// reasoning, tool-call deltas, or finish-reason from the delta
/// structure (supporting both Anthropic-style top-level delta and
/// OpenAI-style `choices` array).
///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n");
self.data_lines.clear();
let event_type = self.event_type.take().unwrap_or_default();
if data.is_empty() || data == "[DONE]" {
if data == "[DONE]" {
return vec![StreamEvent::Done];
}
return vec![];
}
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[stream] failed to parse chunk: {}", e);
return vec![];
}
};
let mut events = Vec::new();
if let Some(usage) = value.get("usage") {
if !usage.is_null() {
let prompt_tokens = usage
.get("prompt_tokens")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
0
});
let completion_tokens = usage
.get("completion_tokens")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] completion_tokens missing in usage chunk");
0
});
let total_tokens = usage
.get("total_tokens")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] total_tokens missing in usage chunk");
prompt_tokens + completion_tokens
});
events.push(StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
});
}
}
let mut other_events = match event_type.as_str() {
"message.stop" => vec![StreamEvent::Done],
"message.delta" | "" => {
let mut d_events = Vec::new();
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
if let Some(choices) = delta.as_array() {
if let Some(choice) = choices.first() {
if let Some(d) = choice.get("delta") {
// Content token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
// Reasoning token
if let Some(reasoning) =
d.get("reasoning_content").and_then(|r| r.as_str())
{
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
}
// Tool calls — iterate ALL entries, not just first()
if let Some(tool_calls) =
d.get("tool_calls").and_then(|tc| tc.as_array())
{
for tc in tool_calls {
let index =
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|| {
tracing::warn!(
"[stream] tool call delta missing index, \
defaulting to 0"
);
0
},
);
let index = usize::try_from(index).unwrap_or(0);
let id = tc
.get("id")
.and_then(|i| i.as_str())
.map(std::string::ToString::to_string);
let name = tc
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.map(std::string::ToString::to_string);
let args_delta = tc
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
d_events.push(StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta: args_delta,
});
}
}
// Finish reason
if let Some(reason) =
choice.get("finish_reason").and_then(|r| r.as_str())
{
if reason == "stop" || reason == "tool_calls" {
d_events.push(StreamEvent::Done);
}
}
}
}
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
}
d_events
}
_ => vec![],
};
events.append(&mut other_events);
events
}
}
impl Default for SseParser {
fn default() -> Self {
Self::new()
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Filesystem layout for zesdex's persistent and scratch data directories.
//!
//! # Flow
//!
//! [`Store::new`] resolves all paths from OS data dir / temp dir →
//! [`ensure_dirs`](Store::ensure_dirs) creates them on startup.
//!
//! # Components
//!
//! - `Store` — resolved path bundle (base, memory, scratch, images, downloads)
//! - `new` — path computation (no I/O)
//! - `ensure_dirs` — creates all directories if missing
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tracing;
/// Resolved paths for all data directories zesdex reads from and writes to.
///
/// Why: centralizing path computation here means every consumer agrees on
/// where memory, scratch, session images, and downloads live.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Store {
/// Top-level data directory, e.g. `~/.local/share/zesdex`.
pub base_dir: PathBuf,
/// Temporary scratch root, usually under the OS temp dir.
pub scratch_root: PathBuf,
/// Directory for persistent memory files (`.md` summaries).
pub memory_dir: PathBuf,
/// Directory for per-session image snapshots.
pub session_images_dir: PathBuf,
/// Directory for downloaded files.
pub download_dir: PathBuf,
}
impl Store {
/// Compute the standard set of zesdex data directory paths.
///
/// Flow: OS data dir (or `.local/share` fallback) + "zesdex" → base dir;
/// scratch root comes from the OS temp dir instead, since it's disposable.
///
/// Why: paths are computed, not created — call `ensure_dirs` before use.
pub fn new() -> Self {
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
{
PathBuf::from(data_dir).join("zesdex")
} else {
PathBuf::from(".local/share/zesdex")
};
let scratch = std::env::temp_dir().join("zesdex-scratch");
Store {
memory_dir: base.join("memory"),
scratch_root: scratch,
session_images_dir: base.join("session-images"),
download_dir: base.join("downloads"),
base_dir: base,
}
}
/// Create all store directories (base, memory, scratch, session images,
/// downloads) if missing.
///
/// Return: `Err` on the first directory that fails to create.
pub fn ensure_dirs(&self) -> std::io::Result<()> {
tracing::debug!(base = %self.base_dir.display(), "ensuring store directories exist");
std::fs::create_dir_all(&self.base_dir)?;
std::fs::create_dir_all(&self.memory_dir)?;
std::fs::create_dir_all(&self.scratch_root)?;
std::fs::create_dir_all(&self.session_images_dir)?;
std::fs::create_dir_all(&self.download_dir)?;
Ok(())
}
}
impl Default for Store {
fn default() -> Self {
Self::new()
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Tool-call DTOs embedded in assistant chat messages.
//!
//! # Flow
//!
//! Provider response/stream carries `tool_calls` on an assistant message →
//! deserialized into [`ToolCall`]/[`ToolFunction`] → harness resolves the
//! function name against `all_tools()` and runs it after sanitizing arguments
//! via [`sanitize_tool_arguments`] (which handles string-encoded JSON,
//! control characters, and truncation).
//!
//! # Components
//!
//! - `ToolCall` — a single tool-invocation request (id + type + function)
//! - `ToolFunction` — function name + raw arguments Value
//! - `sanitize_tool_arguments` — normalizes argument shape, repairs truncation
//! - `repair_json` — closes unclosed strings/braces/brackets in truncated JSON
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing;
/// A single tool-call request emitted by the model in an assistant message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
/// Unique identifier for this tool call (referenced by `ToolCallResult`).
pub id: String,
/// Discriminator, e.g. `"function"`.
#[serde(rename = "type")]
pub type_: String,
/// The function to invoke (name + arguments).
pub function: ToolFunction,
}
/// The function name and raw arguments payload for a `ToolCall`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
/// The function/tool name to dispatch against.
pub name: String,
/// Arguments as a JSON Value (may be a string-encoded object before
/// `sanitize_tool_arguments` normalises it).
pub arguments: Value,
}
/// Normalize tool-call arguments into a JSON object/value.
///
/// Flow: some providers send `arguments` as a JSON-encoded string rather
/// than a nested object; if `args` is a string, attempt to parse it as
/// JSON. Objects and other value types pass through unchanged.
///
/// Security: on parse failure we wrap the raw string in `{ "_raw": "..." }`
/// instead of passing it through as a raw string, so tools that expect a
/// JSON object (via `args.get("key")`) get `None` rather than unexpectedly
/// receiving a plain string value.
///
/// Attempt to fix truncated JSON by closing open strings, braces and brackets.
pub fn sanitize_tool_arguments(args: &Value) -> Value {
match args {
Value::String(s) => {
// Attempt 1: direct parse.
if let Ok(v) = serde_json::from_str::<Value>(s) {
return v;
}
// Attempt 2: strip control chars (0x00-0x1F except \t, \n)
let cleaned: String = s
.chars()
.filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r')
.collect();
if cleaned.len() != s.len() {
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
tracing::warn!(
"tool argument contained control characters — stripped \
and reparsed successfully",
);
return v;
}
}
// Attempt 3: repair truncated JSON and retry.
let input = if cleaned.len() == s.len() {
s
} else {
&cleaned
};
let repaired = repair_json(input);
match serde_json::from_str::<Value>(&repaired) {
Ok(v) => {
tracing::warn!("tool argument string was truncated — repaired successfully",);
v
}
Err(e2) => {
tracing::error!(
"tool argument is a JSON string but failed to parse. \
Wrapping in object. Error: {}. Raw (first 200): {}",
e2,
s.chars().take(200).collect::<String>(),
);
serde_json::json!({"_raw": s, "_parse_error": e2.to_string()})
}
}
}
obj @ Value::Object(_) => obj.clone(),
other => other.clone(),
}
}
/// Repair truncated JSON by closing open strings, braces and brackets.
///
/// Flow: single-pass character scan tracking string/escape state with a
/// LIFO stack for `{`/`[` → append missing `"`, `]`, `}` in the right
/// (reverse nesting) order.
pub fn repair_json(s: &str) -> String {
let mut stack: Vec<char> = Vec::new();
let mut in_string = false;
let mut prev_was_backslash = false;
let mut ends_with_unclosed_escape = false;
for c in s.chars() {
if prev_was_backslash {
prev_was_backslash = false;
ends_with_unclosed_escape = false;
continue;
}
if c == '\\' && in_string {
prev_was_backslash = true;
ends_with_unclosed_escape = true;
continue;
}
ends_with_unclosed_escape = false;
if c == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
match c {
'{' | '[' => stack.push(c),
'}' | ']' => {
stack.pop();
}
_ => {}
}
}
let mut result = s.to_string();
if ends_with_unclosed_escape {
result.pop();
}
if in_string {
result.push('"');
}
for &opener in stack.iter().rev() {
match opener {
'{' => result.push('}'),
'[' => result.push(']'),
_ => {}
}
}
result
}
+47
View File
@@ -0,0 +1,47 @@
//! Record of one completed tool invocation, kept for transcript/history.
//!
//! # Flow
//!
//! Tool harness completes execution → creates [`ToolCallResult`] with output,
//! error flag, and wall-clock duration → appended to conversation history as
//! a `Tool`-role [`ChatMessage`](super::message::ChatMessage).
//!
//! # Components
//!
//! - `ToolCallResult` — tool name + output + error flag + duration
//! - `new` — convenience constructor
use serde::{Deserialize, Serialize};
/// Record of a completed tool invocation, kept for transcript/history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult {
/// The `id` of the `ToolCall` this result responds to.
pub tool_call_id: String,
/// The name of the tool that was invoked.
pub tool_name: String,
/// The text output produced by the tool (or error message).
pub output: String,
/// Whether the tool exited with an error.
pub is_error: bool,
/// Wall-clock execution duration in milliseconds.
pub duration_ms: u64,
}
impl ToolCallResult {
/// Create a new tool call result.
pub fn new(
tool_call_id: String,
tool_name: String,
output: String,
is_error: bool,
duration_ms: u64,
) -> Self {
ToolCallResult {
tool_call_id,
tool_name,
output,
is_error,
duration_ms,
}
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Token usage accounting shared by streaming and non-streaming responses.
//!
//! # Flow
//!
//! Accumulated across all LLM API calls in a session. Each response updates
//! the running totals; `last_*` fields capture the most recent call's values
//! for interpolation display. Persisted alongside other session metadata.
//!
//! # Components
//!
//! - `UsageStats` — cumulative token/latency counters
use serde::{Deserialize, Serialize};
/// Cumulative token/latency counters for a session, persisted alongside it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct UsageStats {
/// Total tokens consumed as input (prompt).
pub tokens_in: u64,
/// Total tokens generated as output (completion).
pub tokens_out: u64,
/// Most recent call's input tokens (for live interpolation display).
#[serde(default)]
pub last_tokens_in: u64,
/// Most recent call's output tokens (for live interpolation display).
#[serde(default)]
pub last_tokens_out: u64,
/// Total number of LLM API calls made this session.
pub api_calls: u64,
/// Tokens consumed by auto-review subagent calls.
pub review_tokens: u64,
/// Total wall-clock time spent on LLM API calls (milliseconds).
pub total_ms: u64,
}
impl UsageStats {
/// Create a new `UsageStats` with all counters zeroed.
pub fn new() -> Self {
Self::default()
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Shared domain error types for the entire domain layer.
//!
//! Provides [`DomainError`] — a unified repository-level error enum used
//! by both the `auth` and `cms` modules (type-aliased as `RepositoryError`
//! in each module). This avoids a dependency on `thiserror` while still
//! giving callers distinct error variants to match on.
//!
//! # Flow
//!
//! Infrastructure adapters convert their native errors (I/O, serde, etc.)
//! into `DomainError` via `From` impls. Domain service layers wrap
//! `DomainError` in their own `ServiceError` enum via `From`.
//!
//! # Components
//!
//! - `DomainError` — 6 variants: `NotFound`, `Conflict`, `Io`, `Serde`,
//! `InvalidId`, `Other`
//! - `From<std::io::Error>` — converts I/O errors
//! - `From<serde_json::Error>` — converts serialisation errors
use std::fmt;
/// Unified repository-level error for domain operations.
///
/// Covers the common failure modes across all persistence adapters:
/// missing entities, conflicts, I/O failures, serialization errors,
/// invalid identifiers, and a catch-all `Other` variant.
#[derive(Debug)]
pub enum DomainError {
/// The requested entity was not found.
NotFound(String),
/// An operation failed due to a conflict (e.g. duplicate key).
Conflict(String),
/// An I/O error occurred during persistence.
Io(std::io::Error),
/// A serialization / deserialization error occurred.
Serde(String),
/// An identifier was rejected as invalid (e.g. path traversal).
InvalidId(String),
/// A generic / uncategorised error.
Other(String),
}
impl fmt::Display for DomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DomainError::NotFound(msg) => write!(f, "not found: {msg}"),
DomainError::Conflict(msg) => write!(f, "conflict: {msg}"),
DomainError::Io(err) => write!(f, "I/O error: {err}"),
DomainError::Serde(msg) => write!(f, "serialization error: {msg}"),
DomainError::InvalidId(msg) => write!(f, "invalid id: {msg}"),
DomainError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for DomainError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
DomainError::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for DomainError {
fn from(err: std::io::Error) -> Self {
DomainError::Io(err)
}
}
impl From<serde_json::Error> for DomainError {
fn from(err: serde_json::Error) -> Self {
DomainError::Serde(err.to_string())
}
}
+52
View File
@@ -0,0 +1,52 @@
//! # Zesdex Domain Layer
//!
//! Pure domain entities, value objects, repository traits, and service traits
//! for the Zesdex application. This crate has **zero framework dependencies**
//! — it depends only on serialization (`serde`), timestamping (`chrono`),
//! identity (`uuid`), and a few other narrowly-scoped utilities.
//!
//! ## Architecture
//!
//! ```text
//! apps/domain
//! ├── core/ Shared domain entities (Conversation, Message, Provider,
//! │ Store, ToolCall, ToolResult, Usage)
//! ├── auth/ Authentication domain (Session, SessionId, SessionLock,
//! │ OAuth, commands, errors, repository/service traits)
//! ├── cms/ CMS domain (AppConfig, Conversation, EditLog, Memory,
//! │ Settings, commands, errors, repository/service traits)
//! └── error.rs Unified DomainError type
//! ```
//!
//! ## Key Design Principle
//!
//! All types are pure Rust structs and enums with `serde` derives. No I/O,
//! no framework imports, no side effects. All persistence is expressed
//! through repository traits that infrastructure adapters implement.
pub mod auth;
pub mod cms;
pub mod core;
pub mod error;
// Re-export all public items from each module for ergonomic imports.
// Consumers can do `use zesdex_domain::*` for common types.
pub use auth::{
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService,
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session,
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService,
};
pub use cms::{
AppConfig, AppConfigRepository, Conversation as CmsConversation,
ConversationRepository, ConversationService, EditLog, EditLogEntry,
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService,
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError,
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch,
SettingsRepository, SettingsService,
};
pub use core::{
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role,
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall,
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
};
pub use error::DomainError;
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "zesdex-gateway"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Gateway binary — assembles domain + application + infrastructure
# + selected interface(s) into a running application process.
# This is the main entry point that wires everything together.
[[bin]]
name = "zesdex"
path = "src/main.rs"
[[bin]]
name = "seed"
path = "src/bin/seed.rs"
[[bin]]
name = "migrate"
path = "src/bin/migrate.rs"
[dependencies]
zesdex-domain = { path = "../domain" }
zesdex-application = { path = "../application" }
zesdex-infrastructure = { path = "../infrastructure" }
zesdex-tui = { path = "../interfaces/tui" }
zesdex-api = { path = "../interfaces/api" }
zesdex-daemon = { path = "../interfaces/daemon" }
zesdex-ws = { path = "../interfaces/ws" }
zesdex-grpc = { path = "../interfaces/grpc" }
zesdex-web = { path = "../interfaces/web" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
dirs.workspace = true
rusqlite.workspace = true
axum.workspace = true
clap = { version = "4", features = ["derive"] }
+108
View File
@@ -0,0 +1,108 @@
//! Database migration binary.
//!
//! Scans all session directories and initializes or upgrades the SQLite
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
use std::path::Path;
fn main() -> anyhow::Result<()> {
let store = zesdex_domain::core::Store::new();
let sessions_dir = store.base_dir.join("sessions");
if !sessions_dir.exists() {
eprintln!("No sessions directory found, nothing to migrate");
return Ok(());
}
let mut migrated = 0u32;
let mut failed = 0u32;
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
match migrate_session_msglog(&path) {
Ok(_) => {
migrated += 1;
eprintln!("Migrated session: {:?}", path.file_name());
}
Err(e) => {
failed += 1;
eprintln!("Failed to migrate session {:?}: {e}", path.file_name());
}
}
}
eprintln!("Migration complete: {migrated} succeeded, {failed} failed");
if failed > 0 {
anyhow::bail!("{failed} session(s) failed to migrate");
}
Ok(())
}
fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
let msglog_path = session_dir.join("messages.sqlite");
if let Some(parent) = msglog_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = rusqlite::Connection::open(&msglog_path)?;
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_name TEXT,
tool_arguments TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
title TEXT,
model TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
token_count INTEGER DEFAULT 0,
summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
CREATE TABLE IF NOT EXISTS blobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
blob_key TEXT NOT NULL,
data BLOB NOT NULL,
mime_type TEXT,
created_at INTEGER NOT NULL,
UNIQUE(session_id, blob_key)
);",
)?;
let version: i32 = conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap_or(0);
if version < 1 {
conn.pragma_update(None, "user_version", 1)?;
}
if version < 2 {
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_messages_session_role ON messages(session_id, role);",
)?;
conn.pragma_update(None, "user_version", 2)?;
}
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More