state : v2 — sessions_count et last_used (issue #4) #19

Merged
bruno merged 1 commits from feat/state-v2 into main 2026-08-16 22:16:56 -04:00
2 changed files with 86 additions and 2 deletions
Showing only changes of commit c6518bcfd5 - Show all commits
+5
View File
@@ -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<i32> {
.with_exit_code(code)
.with_duration(started.elapsed().as_secs()),
);
let _ = app.state.touch(&agent.name);
Ok(code)
}
+81 -2
View File
@@ -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<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>,
}
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();