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:
@@ -0,0 +1,112 @@
|
||||
//! JSON file–backed `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 file–backed `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 file–backed `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 file–backed `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 file–backed `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;
|
||||
Reference in New Issue
Block a user