//! The local installation database: a JSON file recording every agent //! installed by agent-manager (name, version, paths, PID, dates). use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub const STATE_VERSION: u32 = 3; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateFile { #[serde(default = "state_version")] pub version: u32, #[serde(default)] pub installed: BTreeMap, /// Total number of sessions started (incremented by the session /// registry; additive since state v2). #[serde(default)] pub sessions_count: u64, /// RFC 3339 timestamp of the last start/run of any agent. #[serde(default)] pub last_used: Option, /// Personal annotations per agent: favorite, note, tags (issue #39). #[serde(default)] pub annotations: BTreeMap, /// Config file checksums used by 'am audit' (axe 8, issue #63). #[serde(default)] pub config_checksums: BTreeMap, /// Scheduled commands (axe 6, issue #56). #[serde(default)] pub schedules: Vec, } /// One scheduled command (issue #56). Stored in state.json; the OS-level /// task (crontab / Task Scheduler) points back at 'am schedule-run '. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ScheduleEntry { pub id: String, /// "daily" (the only cadence supported today). pub cadence: String, /// Command arguments to run, e.g. ["update", "--all"]. pub command: Vec, /// HH:MM local time (default "08:00"). #[serde(default)] pub at: String, /// RFC 3339 creation timestamp. pub created_at: String, /// RFC 3339 of the last run (None = never). #[serde(default)] pub last_run: Option, /// Exit code of the last run (None = never ran / running). #[serde(default)] pub last_exit: Option, } /// One recorded config checksum: the baseline 'am audit' compares against /// to detect manual modifications (issue #63). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ConfigChecksum { /// SHA-256 of the file content (hex). pub sha256: String, /// File modification time (seconds since epoch) at recording time. pub mtime_secs: u64, /// RFC 3339 timestamp of the recording. pub recorded_at: String, } /// Personal annotations attached to an agent by the user (issue #39). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Annotation { /// Marked as a favorite (star in list/status). #[serde(default)] pub favorite: bool, /// Free-form note shown in 'am info'. #[serde(default)] pub note: Option, /// Personal tags, filterable with 'am search --tag'. #[serde(default)] pub tags: Vec, } impl Default for StateFile { fn default() -> Self { Self { version: STATE_VERSION, installed: BTreeMap::new(), sessions_count: 0, last_used: None, annotations: BTreeMap::new(), config_checksums: BTreeMap::new(), schedules: Vec::new(), } } } fn state_version() -> u32 { STATE_VERSION } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstalledEntry { pub name: String, #[serde(default)] pub version: Option, /// Installer method that was used (npm, pip, binary, git, ...). pub method: String, /// Root directory of this agent's installation. pub install_dir: String, /// Executable files/shims that belong to this installation. #[serde(default)] pub bins: Vec, /// Run command (as configured at install time). pub run: String, /// RFC 3339 timestamp of the installation. pub installed_at: String, /// RFC 3339 timestamp of the last update (if any). #[serde(default)] pub updated_at: Option, /// PID of the background process (when running). #[serde(default)] pub pid: Option, /// RFC 3339 timestamp of the last background start. #[serde(default)] pub started_at: Option, } #[derive(Debug, Clone)] pub struct StateStore { path: PathBuf, } impl StateStore { pub fn new(path: PathBuf) -> Self { Self { path } } pub fn path(&self) -> &Path { &self.path } pub fn load(&self) -> Result { if !self.path.exists() { return Ok(StateFile::default()); } let text = std::fs::read_to_string(&self.path) .with_context(|| format!("cannot read state file {}", self.path.display()))?; let mut sf: StateFile = serde_json::from_str(&text).map_err(|e| { anyhow!( "state file {} is corrupt ({e}); run 'am doctor --fix' to repair it", self.path.display() ) })?; // Additive migration: older files (v1) load unchanged and are // rewritten as v2 on first contact. if sf.version < STATE_VERSION { sf.version = STATE_VERSION; self.save(&sf)?; } Ok(sf) } pub fn save(&self, sf: &StateFile) -> Result<()> { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let tmp = self.path.with_extension("json.tmp"); let text = serde_json::to_string_pretty(sf)?; std::fs::write(&tmp, text)?; std::fs::rename(&tmp, &self.path) .with_context(|| format!("cannot write state file {}", self.path.display()))?; Ok(()) } pub fn get(&self, name: &str) -> Result> { Ok(self.load()?.installed.get(name).cloned()) } pub fn all(&self) -> Result> { Ok(self.load()?.installed) } pub fn set(&self, entry: &InstalledEntry) -> Result<()> { let mut sf = self.load()?; sf.installed.insert(entry.name.clone(), entry.clone()); self.save(&sf) } pub fn remove(&self, name: &str) -> Result { let mut sf = self.load()?; let removed = sf.installed.remove(name).is_some(); if removed { self.save(&sf)?; } Ok(removed) } /// Update the PID / start timestamp of a background agent. pub fn update_pid(&self, name: &str, pid: Option, started_at: Option) -> Result<()> { let mut sf = self.load()?; if let Some(e) = sf.installed.get_mut(name) { e.pid = pid; e.started_at = started_at; self.save(&sf)?; } Ok(()) } /// Record the last use of an agent (start or run) — additive v2 data. pub fn touch(&self, name: &str) -> Result<()> { let mut sf = self.load()?; if sf.installed.contains_key(name) { sf.last_used = Some(crate::installers::now_rfc3339()); self.save(&sf)?; } Ok(()) } /// Increment the total session counter (one per session start). pub fn bump_sessions(&self) -> Result<()> { let mut sf = self.load()?; sf.sessions_count = sf.sessions_count.saturating_add(1); self.save(&sf) } /// Every personal annotation, keyed by agent name (issue #39). pub fn annotations(&self) -> Result> { Ok(self.load()?.annotations) } /// The annotation block of one agent (defaults to empty). pub fn annotation(&self, name: &str) -> Result { Ok(self .load()? .annotations .get(name) .cloned() .unwrap_or_default()) } /// Replace the annotation block of one agent. pub fn set_annotation(&self, name: &str, ann: &Annotation) -> Result<()> { let mut sf = self.load()?; sf.annotations.insert(name.to_string(), ann.clone()); self.save(&sf) } /// Remove the annotation block of one agent (returns true when present). pub fn remove_annotation(&self, name: &str) -> Result { let mut sf = self.load()?; let removed = sf.annotations.remove(name).is_some(); if removed { self.save(&sf)?; } Ok(removed) } } #[cfg(test)] mod tests { use super::*; fn store(dir: &Path) -> StateStore { StateStore::new(dir.join("state.json")) } #[test] fn roundtrip() { let dir = tempfile::tempdir().unwrap(); let st = store(dir.path()); assert!(st.all().unwrap().is_empty()); let entry = InstalledEntry { name: "claude-code".to_string(), version: Some("1.2.3".to_string()), method: "npm".to_string(), install_dir: "/tmp/cc".to_string(), bins: vec!["/tmp/cc/bin/claude".to_string()], run: "claude".to_string(), installed_at: "2026-01-01T00:00:00Z".to_string(), updated_at: None, pid: Some(42), started_at: None, }; st.set(&entry).unwrap(); let got = st.get("claude-code").unwrap().unwrap(); assert_eq!(got.version.as_deref(), Some("1.2.3")); assert_eq!(got.pid, Some(42)); st.update_pid("claude-code", None, None).unwrap(); assert_eq!(st.get("claude-code").unwrap().unwrap().pid, None); assert!(st.remove("claude-code").unwrap()); assert!(st.all().unwrap().is_empty()); } #[test] fn v1_file_is_migrated_additively() { let dir = tempfile::tempdir().unwrap(); let st = store(dir.path()); std::fs::write( st.path(), r#"{ "version": 1, "installed": { "claude-code": { "name": "claude-code", "method": "npm", "install_dir": "/tmp/cc", "bins": [], "run": "claude", "installed_at": "2026-01-01T00:00:00Z" } } }"#, ) .unwrap(); let sf = st.load().unwrap(); assert_eq!(sf.version, 3); assert_eq!(sf.sessions_count, 0); assert_eq!(sf.last_used, None); assert!(sf.installed.contains_key("claude-code")); // The migration is persisted on first contact. let raw = std::fs::read_to_string(st.path()).unwrap(); assert!(raw.contains(r#""version": 3"#), "migrated file rewritten: {raw}"); } #[test] fn touch_records_last_used() { let dir = tempfile::tempdir().unwrap(); let st = store(dir.path()); st.touch("claude-code").unwrap(); // no entry: no-op assert_eq!(st.load().unwrap().last_used, None); let entry = InstalledEntry { name: "claude-code".to_string(), version: None, method: "npm".to_string(), install_dir: "/tmp/cc".to_string(), bins: vec![], run: "claude".to_string(), installed_at: "2026-01-01T00:00:00Z".to_string(), updated_at: None, pid: None, started_at: None, }; st.set(&entry).unwrap(); st.touch("claude-code").unwrap(); assert!(st.load().unwrap().last_used.is_some()); } #[test] fn corrupt_file_is_reported() { let dir = tempfile::tempdir().unwrap(); let st = store(dir.path()); std::fs::write(st.path(), "{ not json").unwrap(); let err = st.load().unwrap_err().to_string(); assert!(err.contains("corrupt"), "unexpected error: {err}"); } }