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
@@ -0,0 +1,112 @@
//! JSON filebacked `AppConfigRepository` with Claude credential auto-detection.
use std::path::Path;
use serde::{Deserialize, Serialize};
use zesdex_domain::cms::{AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError};
use crate::utils::write_json_atomic;
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonAppConfigRepository;
impl JsonAppConfigRepository {
pub fn new() -> Self {
Self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeEnv {
#[serde(alias = "ANTHROPIC_BASE_URL")]
anthropic_base_url: Option<String>,
#[serde(alias = "ANTHROPIC_API_KEY")]
anthropic_api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSettings {
env: Option<ClaudeEnv>,
}
fn claude_credentials_from_file() -> Option<(String, String)> {
let path = dirs::home_dir()?.join(".claude").join("settings.json");
let content = std::fs::read_to_string(&path).ok()?;
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
let env = settings.env?;
let base_url = env.anthropic_base_url?;
let key = env.anthropic_api_key?;
Some((base_url, key))
}
fn claude_credentials_from_env() -> Option<(String, String)> {
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, key))
}
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
Some(ProviderConfig {
api_base: base_url,
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: None,
default_api_key: Some(key),
})
}
impl AppConfigRepository for JsonAppConfigRepository {
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
let path = base_dir.join("app_config.json");
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
AppConfig::default()
}
Err(e) => return Err(RepositoryError::Io(e)),
};
let defaults = AppConfig::default();
for (name, provider) in defaults.providers {
cfg.providers.entry(name).or_insert(provider);
}
if let Some(claude_provider) = detect_claude_settings_provider() {
cfg.providers
.entry("claude".to_string())
.or_insert(claude_provider);
let claude_models: [(&str, &str); 3] = [
("claude-opus-4-8", "claude-opus-4-8"),
("claude-sonnet-5", "claude-sonnet-5"),
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
];
for (role_name, model_name) in &claude_models {
cfg.model_roles
.entry(role_name.to_string())
.or_insert(ModelRole {
provider: "claude".to_string(),
model: model_name.to_string(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
if cfg.default_provider == defaults.default_provider {
cfg.default_provider = "claude".to_string();
cfg.default_model = "claude-opus-4-8".to_string();
}
}
Ok(cfg)
}
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
std::fs::create_dir_all(base_dir)?;
let path = base_dir.join("app_config.json");
write_json_atomic(&path, config, None)?;
Ok(())
}
}
@@ -0,0 +1,34 @@
//! JSON filebacked `ConversationRepository`.
//! Stores `Conversation` at `<session_dir>/conversation.json`.
use std::path::Path;
use zesdex_domain::cms::{Conversation, ConversationRepository, RepositoryError};
use crate::utils::write_json_atomic;
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonConversationRepository;
impl JsonConversationRepository {
pub fn new() -> Self {
Self
}
}
impl ConversationRepository for JsonConversationRepository {
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError> {
let path = session_dir.join("conversation.json");
let data = std::fs::read_to_string(&path)?;
let conv: Conversation = serde_json::from_str(&data)?;
Ok(conv)
}
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> {
std::fs::create_dir_all(session_dir)?;
let path = session_dir.join("conversation.json");
write_json_atomic(&path, conversation, None)?;
Ok(())
}
}
@@ -0,0 +1,87 @@
//! JSONL filebacked `EditLogRepository`.
//! Stores `EditLog` as an append-only newline-delimited JSON file.
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use zesdex_domain::cms::{EditLog, EditLogEntry, EditLogRepository, RepositoryError};
/// Maximum number of edit entries held in memory at once.
const MAX_MEMORY_ENTRIES: usize = 10_000;
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
#[derive(Debug, Clone, Default)]
pub struct JsonlEditLogRepository;
impl JsonlEditLogRepository {
pub fn new() -> Self {
Self
}
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let reader = BufReader::new(file);
let mut entries: Vec<EditLogEntry> = Vec::new();
for line in reader.lines() {
let Ok(line) = line else {
continue;
};
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
if entries.len() >= MAX_MEMORY_ENTRIES {
entries.remove(0);
}
entries.push(entry);
}
}
entries
}
}
impl EditLogRepository for JsonlEditLogRepository {
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
let path = session_dir.join("edits.jsonl");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let entries = Self::load_from_disk(&path);
if !path.exists() {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
}
Ok(EditLog { entries })
}
fn append(
&self,
session_dir: &Path,
log: &mut EditLog,
entry: EditLogEntry,
) -> Result<(), RepositoryError> {
let path = session_dir.join("edits.jsonl");
let line = serde_json::to_string(&entry)? + "\n";
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(line.as_bytes())?;
file.sync_all()?;
}
log.entries.push(entry);
if log.entries.len() > MAX_MEMORY_ENTRIES {
log.entries.remove(0);
}
Ok(())
}
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
log.entries.clone()
}
}
@@ -0,0 +1,183 @@
//! Markdown filebacked `MemoryRepository`.
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError};
/// File-based `MemoryRepository` that stores memories as `.md` files with
/// YAML-ish frontmatter.
#[derive(Debug, Clone, Default)]
pub struct MarkdownMemoryRepository;
impl MarkdownMemoryRepository {
pub fn new() -> Self {
Self
}
fn build_frontmatter(memory: &Memory) -> String {
let outcome_line = memory
.outcome
.as_ref()
.map(|o| format!("outcome: {o}\n"))
.unwrap_or_default();
let scope_line = memory
.scope
.as_ref()
.map(|s| format!("scope: {s}\n"))
.unwrap_or_default();
let before_line = memory
.before_snippet
.as_ref()
.map(|s| format!("before: {s}\n"))
.unwrap_or_default();
let after_line = memory
.after_snippet
.as_ref()
.map(|s| format!("after: {s}\n"))
.unwrap_or_default();
let prov_line = if memory.provenances.is_empty() {
String::new()
} else {
format!("provenances: {}\n", memory.provenances.join(", "))
};
format!(
"name: {name}\ndescription: {desc}\nkind: {kind}\n\
created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\
{outcome}{scope}{before}{after}{prov}",
name = memory.name,
desc = memory.description,
kind = memory.kind,
created = memory.created_at,
updated = memory.updated_at,
lifecycle = memory.lifecycle,
outcome = outcome_line,
scope = scope_line,
before = before_line,
after = after_line,
prov = prov_line,
)
}
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
front
.lines()
.filter_map(|l| {
let mut it = l.splitn(2, ':');
Some((
it.next()?.trim().to_string(),
it.next()?.trim().to_string(),
))
})
.collect()
}
fn parse(content: &str) -> std::io::Result<Memory> {
let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
if parts.len() < 2 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"missing frontmatter",
));
}
let front = Self::parse_frontmatter(parts[0]);
let body = parts.get(1).unwrap_or(&"").trim().to_string();
Ok(Memory {
name: front.get("name").cloned().unwrap_or_default(),
description: front.get("description").cloned().unwrap_or_default(),
content: body,
kind: front
.get("kind")
.cloned()
.unwrap_or_else(|| "reference".to_string()),
created_at: front
.get("created_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
updated_at: front
.get("updated_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
lifecycle: front
.get("lifecycle")
.cloned()
.unwrap_or_else(|| "new".to_string()),
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
provenances: front
.get("provenances")
.cloned()
.map(|s| s.split(", ").map(String::from).collect())
.unwrap_or_default(),
})
}
}
impl MemoryRepository for MarkdownMemoryRepository {
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let Ok(entries) = std::fs::read_dir(memory_dir) else {
return Ok(Vec::new());
};
let slugs: Vec<String> = entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name == "MEMORY.md" {
return None;
}
name.strip_suffix(".md")
.map(std::string::ToString::to_string)
})
.collect();
Ok(slugs)
}
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
let path = Memory::path(memory_dir, name);
let content = std::fs::read_to_string(&path)?;
let memory = Self::parse(&content)
.map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?;
Ok(memory)
}
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, &memory.name);
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)?;
let frontmatter = Self::build_frontmatter(memory);
let content = format!("---\n{frontmatter}---\n\n{}", memory.content);
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
if let Some(p) = path.parent() {
if let Ok(d) = std::fs::File::open(p) {
let _ = d.sync_all();
}
}
Ok(())
}
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, name);
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
}
@@ -0,0 +1,16 @@
//! File-based repository implementations for CMS domain entities.
//!
//! ## Repositories
//! - `JsonSettingsRepository` — reads/writes `settings.json`
//! - `JsonAppConfigRepository` — reads/writes `app_config.json`
//! - `JsonConversationRepository` — reads/writes `conversation.json`
//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files
//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl`
//! - `FileRewindBlobRepository` — stores blobs as files
pub mod app_config_repo;
pub mod conversation_repo;
pub mod edit_log_repo;
pub mod memory_repo;
pub mod rewind_blob_repo;
pub mod settings_repo;
@@ -0,0 +1,112 @@
//! Filesystem-backed `RewindBlobRepository`.
//! Blob bytes are stored at `<session_dir>/blobs/<hex(key)>.bin`.
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use zesdex_domain::cms::{RepositoryError, RewindBlobRepository};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobIndexEntry {
key: String,
mime_type: Option<String>,
created_at: i64,
}
/// Concrete filesystem rewind-blob repository.
#[derive(Debug, Clone, Default)]
pub struct FileRewindBlobRepository;
impl FileRewindBlobRepository {
pub fn new() -> Self {
Self
}
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
session_dir.join("blobs")
}
fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
}
fn index_path(session_dir: &Path) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join("index.jsonl")
}
}
impl RewindBlobRepository for FileRewindBlobRepository {
fn store_blob(
&self,
session_dir: &Path,
blob_key: &str,
data: &[u8],
mime_type: Option<&str>,
) -> Result<(), RepositoryError> {
let blobs_dir = Self::blobs_dir(session_dir);
std::fs::create_dir_all(&blobs_dir)?;
let path = Self::blob_file_path(session_dir, blob_key);
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, &path)?;
let entry = BlobIndexEntry {
key: blob_key.to_string(),
mime_type: mime_type.map(String::from),
created_at: chrono::Utc::now().timestamp_millis(),
};
let index_path = Self::index_path(session_dir);
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)?;
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
f.sync_all()?;
Ok(())
}
fn retrieve_blob(
&self,
session_dir: &Path,
blob_key: &str,
) -> Result<Option<Vec<u8>>, RepositoryError> {
let path = Self::blob_file_path(session_dir, blob_key);
if !path.exists() {
return Ok(None);
}
let data = std::fs::read(&path)?;
Ok(Some(data))
}
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let index_path = Self::index_path(session_dir);
let Ok(content) = std::fs::read_to_string(&index_path) else {
return Ok(Vec::new());
};
let mut first_seen_order: Vec<String> = Vec::new();
let mut latest_by_key: std::collections::HashMap<String, BlobIndexEntry> =
std::collections::HashMap::new();
for line in content.lines() {
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
continue;
};
if !latest_by_key.contains_key(&entry.key) {
first_seen_order.push(entry.key.clone());
}
latest_by_key.insert(entry.key.clone(), entry);
}
let mut entries: Vec<BlobIndexEntry> = first_seen_order
.into_iter()
.filter_map(|k| latest_by_key.get(&k).cloned())
.collect();
entries.sort_by_key(|e| e.created_at);
Ok(entries.into_iter().map(|e| e.key).collect())
}
}
@@ -0,0 +1,44 @@
//! JSON filebacked `SettingsRepository`.
//! Path: `<base_dir>/settings.json`
use std::path::Path;
use zesdex_domain::cms::{RepositoryError, Settings, SettingsRepository};
use crate::utils::write_json_atomic;
/// Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonSettingsRepository;
impl JsonSettingsRepository {
pub fn new() -> Self {
Self
}
}
impl SettingsRepository for JsonSettingsRepository {
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError> {
let path = base_dir.join("settings.json");
match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(settings) => Ok(settings),
Err(e) => {
tracing::warn!("settings.json at '{:?}' failed to parse ({e}); falling back to defaults", path);
Ok(Settings::default())
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(Settings::default())
}
Err(e) => Err(RepositoryError::Io(e)),
}
}
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> {
std::fs::create_dir_all(base_dir)?;
let path = base_dir.join("settings.json");
write_json_atomic(&path, settings, None)?;
Ok(())
}
}
@@ -0,0 +1,8 @@
//! Filesystem-backed repository implementations for IAM entities.
//!
//! Implements domain repository traits using JSON file persistence for
//! sessions, OAuth tokens, and PID-file session locks.
pub mod oauth_repo;
pub mod session_lock_repo;
pub mod session_repo;
@@ -0,0 +1,39 @@
//! Filesystem-backed `OAuthRepository` implementation.
//!
//! Tokens are stored as a single JSON file with write-then-rename + fsync
//! for crash safety, and restrictive owner-only mode `0o600` on Unix.
use std::path::Path;
use zesdex_domain::auth::{OAuthRepository, OAuthToken, RepositoryError};
use crate::utils::write_json_atomic;
/// Concrete filesystem OAuth token repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemOAuthRepository;
impl FileSystemOAuthRepository {
pub fn new() -> Self {
FileSystemOAuthRepository
}
}
impl OAuthRepository for FileSystemOAuthRepository {
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_json_atomic(path, token, Some(0o600))?;
Ok(())
}
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError> {
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(path)?;
let token: OAuthToken = serde_json::from_str(&data)?;
Ok(Some(token))
}
}
@@ -0,0 +1,87 @@
//! Filesystem-backed `SessionLockRepository` implementation using a PID file
//! (`<session_dir>/.lock`) with atomic `O_CREAT|O_EXCL` acquisition.
use std::convert::TryInto;
use std::io::Write;
use std::path::Path;
use zesdex_domain::auth::{RepositoryError, SessionLockRepository};
/// Concrete filesystem session-lock repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemSessionLockRepository;
impl FileSystemSessionLockRepository {
pub fn new() -> Self {
FileSystemSessionLockRepository
}
}
impl SessionLockRepository for FileSystemSessionLockRepository {
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError> {
let path = session_dir.join(".lock");
let pid = std::process::id();
match std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
{
Ok(mut file) => {
write!(file, "{pid}")?;
file.sync_all()?;
return Ok(true);
}
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(RepositoryError::Io(e)),
}
let content = std::fs::read_to_string(&path).unwrap_or_default();
if let Ok(existing_pid) = content.trim().parse::<u32>() {
if self.is_alive(existing_pid) {
return Ok(false);
}
}
let tmp = path.with_extension("lock.tmp");
{
let mut tmp_file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
write!(tmp_file, "{pid}")?;
tmp_file.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
if let Some(parent) = path.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
Ok(true)
}
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
let path = session_dir.join(".lock");
let _ = std::fs::remove_file(path);
Ok(())
}
fn is_alive(&self, pid: u32) -> bool {
let pid_signed: i32 = match pid.try_into() {
Ok(p) => p,
Err(_) => return false,
};
if unsafe { libc::kill(pid_signed, 0) != 0 } {
return false;
}
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
}
}
@@ -0,0 +1,78 @@
//! Filesystem-backed `SessionRepository` implementation.
//!
//! Each session is stored as `<base_dir>/sessions/<id>/session.json`.
//! Writes use a write-then-rename + fsync pattern for crash safety.
use std::path::Path;
use zesdex_domain::auth::{RepositoryError, Session, SessionId, SessionRepository};
use crate::utils::write_json_atomic;
/// Concrete filesystem session repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemSessionRepository;
impl FileSystemSessionRepository {
pub fn new() -> Self {
FileSystemSessionRepository
}
}
impl SessionRepository for FileSystemSessionRepository {
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError> {
let sessions_dir = base_dir.join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => return Err(RepositoryError::Io(e)),
};
let mut sessions = Vec::new();
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if let Ok(sid) = SessionId::new(&name) {
if let Ok(session) = self.load_session(base_dir, &sid) {
sessions.push(session);
}
}
}
Ok(sessions)
}
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError> {
let path = base_dir
.join("sessions")
.join(id.as_str())
.join("session.json");
if !path.exists() {
return Err(RepositoryError::NotFound(format!(
"session not found: {}",
id.as_str()
)));
}
let data = std::fs::read_to_string(&path)?;
let session: Session = serde_json::from_str(&data)?;
Ok(session)
}
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError> {
let dir = session.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
write_json_atomic(&path, session, None)?;
Ok(())
}
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError> {
let dir = base_dir.join("sessions").join(id.as_str());
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
}
@@ -0,0 +1,20 @@
//! Persistence adapters — concrete file-based repository implementations
//! for both IAM and CMS domain repository traits.
pub mod cms;
pub mod iam;
pub mod sqlite;
pub use iam::{
oauth_repo::FileSystemOAuthRepository,
session_lock_repo::FileSystemSessionLockRepository,
session_repo::FileSystemSessionRepository,
};
pub use cms::{
app_config_repo::JsonAppConfigRepository,
conversation_repo::JsonConversationRepository,
edit_log_repo::JsonlEditLogRepository,
memory_repo::MarkdownMemoryRepository,
rewind_blob_repo::FileRewindBlobRepository,
settings_repo::JsonSettingsRepository,
};
@@ -0,0 +1,88 @@
//! SQLite database connection initialisation and schema migrations.
use std::sync::{Arc, Mutex};
/// A shared SQLite connection wrapped for thread-safe access.
#[derive(Clone)]
pub struct DbConn {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl DbConn {
/// Execute a closure with a reference to the underlying connection.
pub fn with<F, T>(&self, f: F) -> anyhow::Result<T>
where
F: FnOnce(&rusqlite::Connection) -> anyhow::Result<T>,
{
let conn = self
.conn
.lock()
.map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?;
f(&conn)
}
}
const SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
workspace_roots TEXT NOT NULL DEFAULT '[]',
message_count INTEGER NOT NULL DEFAULT 0,
token_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
summary TEXT
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
session_id TEXT PRIMARY KEY,
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
name TEXT PRIMARY KEY,
data TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS edit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
entry TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_edit_logs_session
ON edit_logs (session_id);
"#;
/// Initialise a shared SQLite connection at the given path.
pub fn init_db(db_path: &str) -> anyhow::Result<DbConn> {
let conn = rusqlite::Connection::open(db_path)
.map_err(|e| anyhow::anyhow!("failed to open SQLite database at '{db_path}': {e}"))?;
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
Ok(DbConn {
conn: Arc::new(Mutex::new(conn)),
})
}
/// Run embedded SQL schema migrations.
pub fn run_migrations(db: &DbConn) -> anyhow::Result<()> {
db.with(|conn| {
conn.execute_batch(SCHEMA_SQL)
.map_err(|e| anyhow::anyhow!("failed to execute database schema migrations: {e}"))
})?;
Ok(())
}
@@ -0,0 +1,3 @@
//! SQLite database connection management and schema migrations.
pub mod database;