diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index cc7ab0c..b0a2efd 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -61,6 +61,9 @@ fn start_one( .with_args(exec.args.clone()) .with_env_keys(events::env_keys(&exec.env)), ); + if is_managed { + let _ = app.state.touch(&agent.name); + } let log = process::agent_log_path(app, &agent.name); app.log.success(&format!( "{} started (pid {pid}); log: {}", @@ -99,6 +102,7 @@ fn start_one( .with_exit_code(code) .with_duration(started.elapsed().as_secs()), ); + let _ = app.state.touch(&agent.name); Ok(code) } } @@ -231,6 +235,7 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result { .with_exit_code(code) .with_duration(started.elapsed().as_secs()), ); + let _ = app.state.touch(&agent.name); Ok(code) } diff --git a/src/state.rs b/src/state.rs index a1cce61..0543af2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -pub const STATE_VERSION: u32 = 1; +pub const STATE_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateFile { @@ -14,6 +14,13 @@ pub struct StateFile { 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, } impl Default for StateFile { @@ -21,6 +28,8 @@ impl Default for StateFile { Self { version: STATE_VERSION, installed: BTreeMap::new(), + sessions_count: 0, + last_used: None, } } } @@ -76,12 +85,18 @@ impl StateStore { } let text = std::fs::read_to_string(&self.path) .with_context(|| format!("cannot read state file {}", self.path.display()))?; - let sf: StateFile = serde_json::from_str(&text).map_err(|e| { + 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) } @@ -130,6 +145,16 @@ impl StateStore { } 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(()) + } } #[cfg(test)] @@ -167,6 +192,60 @@ mod tests { 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, 2); + 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": 2"#), "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();