357 lines
11 KiB
Rust
357 lines
11 KiB
Rust
//! 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<String, InstalledEntry>,
|
|
/// 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<String>,
|
|
/// Personal annotations per agent: favorite, note, tags (issue #39).
|
|
#[serde(default)]
|
|
pub annotations: BTreeMap<String, Annotation>,
|
|
/// Config file checksums used by 'am audit' (axe 8, issue #63).
|
|
#[serde(default)]
|
|
pub config_checksums: BTreeMap<String, ConfigChecksum>,
|
|
/// Scheduled commands (axe 6, issue #56).
|
|
#[serde(default)]
|
|
pub schedules: Vec<ScheduleEntry>,
|
|
}
|
|
|
|
/// One scheduled command (issue #56). Stored in state.json; the OS-level
|
|
/// task (crontab / Task Scheduler) points back at 'am schedule-run <id>'.
|
|
#[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<String>,
|
|
/// 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<String>,
|
|
/// Exit code of the last run (None = never ran / running).
|
|
#[serde(default)]
|
|
pub last_exit: Option<i32>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// Personal tags, filterable with 'am search --tag'.
|
|
#[serde(default)]
|
|
pub tags: Vec<String>,
|
|
}
|
|
|
|
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<String>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
/// PID of the background process (when running).
|
|
#[serde(default)]
|
|
pub pid: Option<u32>,
|
|
/// RFC 3339 timestamp of the last background start.
|
|
#[serde(default)]
|
|
pub started_at: Option<String>,
|
|
}
|
|
|
|
#[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<StateFile> {
|
|
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<Option<InstalledEntry>> {
|
|
Ok(self.load()?.installed.get(name).cloned())
|
|
}
|
|
|
|
pub fn all(&self) -> Result<BTreeMap<String, InstalledEntry>> {
|
|
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<bool> {
|
|
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<u32>, started_at: Option<String>) -> 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<BTreeMap<String, Annotation>> {
|
|
Ok(self.load()?.annotations)
|
|
}
|
|
|
|
/// The annotation block of one agent (defaults to empty).
|
|
pub fn annotation(&self, name: &str) -> Result<Annotation> {
|
|
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<bool> {
|
|
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}");
|
|
}
|
|
}
|