state : v2 avec sessions_count et last_used (issue #4) - migration additive persistee au premier contact, touch() sur start/run, fixture v1 testee
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user