use anyhow::Result;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashMap},
fs,
io::ErrorKind,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use wcore::{
AgentConfig, AgentId, ConversationMeta, DaemonConfig, EventLine, McpServerConfig,
model::HistoryEntry,
storage::{SessionHandle, SessionSnapshot, SessionSummary, Skill, Storage},
};
mod agents;
mod config;
mod mcp;
mod scaffold;
mod sessions;
mod skills;
pub use scaffold::default_crab;
const SETTINGS_HEADER: &str = "\
# Managed by crabtalk daemon. Edits while the daemon is running are
# overwritten on the next write. Edits while the daemon is stopped are
# picked up on next reload.
#
# Source of truth for runtime-added MCPs and agents. Immutable
# install-time configuration (providers, task pool) lives in
# config.toml.
";
pub struct FsStorage {
pub(super) config_dir: PathBuf,
pub(super) sessions_root: PathBuf,
pub(super) skill_roots: Vec<PathBuf>,
pub(super) session_counters: Mutex<HashMap<String, u64>>,
}
impl FsStorage {
pub fn new(config_dir: PathBuf, sessions_root: PathBuf, skill_roots: Vec<PathBuf>) -> Self {
Self {
config_dir,
sessions_root,
skill_roots,
session_counters: Mutex::new(HashMap::new()),
}
}
pub(super) fn settings_path(&self) -> PathBuf {
self.config_dir.join(wcore::paths::SETTINGS_FILE)
}
pub(super) fn read_settings(&self) -> Result<SettingsFile> {
let path = self.settings_path();
match fs::read_to_string(&path) {
Ok(content) => Ok(toml::from_str(&content)?),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(SettingsFile::default()),
Err(e) => Err(e.into()),
}
}
pub(super) fn write_settings(&self, file: &SettingsFile) -> Result<()> {
let path = self.settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(file)?;
let mut content = String::with_capacity(SETTINGS_HEADER.len() + body.len());
content.push_str(SETTINGS_HEADER);
content.push_str(&body);
atomic_write(&path, content.as_bytes())
}
}
pub(super) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut tmp_os = path.to_path_buf().into_os_string();
tmp_os.push(format!(".tmp.{}.{}", std::process::id(), nanos));
let tmp_path = PathBuf::from(tmp_os);
fs::write(&tmp_path, data)?;
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(e.into());
}
Ok(())
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(super) struct SettingsFile {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(super) mcps: BTreeMap<String, McpServerConfig>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(super) agents: BTreeMap<String, AgentConfig>,
}
impl Storage for FsStorage {
fn list_skills(&self) -> Result<Vec<Skill>> {
skills::list_skills(self)
}
fn load_skill(&self, name: &str) -> Result<Option<Skill>> {
skills::load_skill(self, name)
}
fn create_session(&self, agent: &str, created_by: &str) -> Result<SessionHandle> {
sessions::create_session(self, agent, created_by)
}
fn find_latest_session(&self, agent: &str, created_by: &str) -> Result<Option<SessionHandle>> {
sessions::find_latest_session(self, agent, created_by)
}
fn load_session(&self, handle: &SessionHandle) -> Result<Option<SessionSnapshot>> {
sessions::load_session(self, handle)
}
fn list_sessions(&self) -> Result<Vec<SessionSummary>> {
sessions::list_sessions(self)
}
fn append_session_messages(
&self,
handle: &SessionHandle,
entries: &[HistoryEntry],
) -> Result<()> {
sessions::append_session_messages(self, handle, entries)
}
fn append_session_events(&self, handle: &SessionHandle, events: &[EventLine]) -> Result<()> {
sessions::append_session_events(self, handle, events)
}
fn append_session_compact(&self, handle: &SessionHandle, archive_name: &str) -> Result<()> {
sessions::append_session_compact(self, handle, archive_name)
}
fn update_session_meta(&self, handle: &SessionHandle, meta: &ConversationMeta) -> Result<()> {
sessions::update_session_meta(self, handle, meta)
}
fn delete_session(&self, handle: &SessionHandle) -> Result<bool> {
sessions::delete_session(self, handle)
}
fn list_agents(&self) -> Result<Vec<AgentConfig>> {
agents::list_agents(self)
}
fn load_agent(&self, id: &AgentId) -> Result<Option<AgentConfig>> {
agents::load_agent(self, id)
}
fn load_agent_by_name(&self, name: &str) -> Result<Option<AgentConfig>> {
agents::load_agent_by_name(self, name)
}
fn upsert_agent(&self, config: &AgentConfig, prompt: &str) -> Result<()> {
agents::upsert_agent(self, config, prompt)
}
fn delete_agent(&self, id: &AgentId) -> Result<bool> {
agents::delete_agent(self, id)
}
fn rename_agent(&self, id: &AgentId, new_name: &str) -> Result<bool> {
agents::rename_agent(self, id, new_name)
}
fn load_config(&self) -> Result<DaemonConfig> {
config::load_config(self)
}
fn save_config(&self, config: &DaemonConfig) -> Result<()> {
config::save_config(self, config)
}
fn scaffold(&self, default_model: &str) -> Result<()> {
scaffold::scaffold(self, default_model)
}
fn list_mcps(&self) -> Result<BTreeMap<String, McpServerConfig>> {
mcp::list_mcps(self)
}
fn load_mcp(&self, name: &str) -> Result<Option<McpServerConfig>> {
mcp::load_mcp(self, name)
}
fn upsert_mcp(&self, config: &McpServerConfig) -> Result<()> {
mcp::upsert_mcp(self, config)
}
fn delete_mcp(&self, name: &str) -> Result<bool> {
mcp::delete_mcp(self, name)
}
}