state : v2 — sessions_count et last_used (issue #4) #19
@@ -61,6 +61,9 @@ fn start_one(
|
|||||||
.with_args(exec.args.clone())
|
.with_args(exec.args.clone())
|
||||||
.with_env_keys(events::env_keys(&exec.env)),
|
.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);
|
let log = process::agent_log_path(app, &agent.name);
|
||||||
app.log.success(&format!(
|
app.log.success(&format!(
|
||||||
"{} started (pid {pid}); log: {}",
|
"{} started (pid {pid}); log: {}",
|
||||||
@@ -99,6 +102,7 @@ fn start_one(
|
|||||||
.with_exit_code(code)
|
.with_exit_code(code)
|
||||||
.with_duration(started.elapsed().as_secs()),
|
.with_duration(started.elapsed().as_secs()),
|
||||||
);
|
);
|
||||||
|
let _ = app.state.touch(&agent.name);
|
||||||
Ok(code)
|
Ok(code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +235,7 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
|||||||
.with_exit_code(code)
|
.with_exit_code(code)
|
||||||
.with_duration(started.elapsed().as_secs()),
|
.with_duration(started.elapsed().as_secs()),
|
||||||
);
|
);
|
||||||
|
let _ = app.state.touch(&agent.name);
|
||||||
Ok(code)
|
Ok(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+81
-2
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub const STATE_VERSION: u32 = 1;
|
pub const STATE_VERSION: u32 = 2;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StateFile {
|
pub struct StateFile {
|
||||||
@@ -14,6 +14,13 @@ pub struct StateFile {
|
|||||||
pub version: u32,
|
pub version: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub installed: BTreeMap<String, InstalledEntry>,
|
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 {
|
impl Default for StateFile {
|
||||||
@@ -21,6 +28,8 @@ impl Default for StateFile {
|
|||||||
Self {
|
Self {
|
||||||
version: STATE_VERSION,
|
version: STATE_VERSION,
|
||||||
installed: BTreeMap::new(),
|
installed: BTreeMap::new(),
|
||||||
|
sessions_count: 0,
|
||||||
|
last_used: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,12 +85,18 @@ impl StateStore {
|
|||||||
}
|
}
|
||||||
let text = std::fs::read_to_string(&self.path)
|
let text = std::fs::read_to_string(&self.path)
|
||||||
.with_context(|| format!("cannot read state file {}", self.path.display()))?;
|
.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!(
|
anyhow!(
|
||||||
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
|
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
|
||||||
self.path.display()
|
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)
|
Ok(sf)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +145,16 @@ impl StateStore {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
@@ -167,6 +192,60 @@ mod tests {
|
|||||||
assert!(st.all().unwrap().is_empty());
|
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]
|
#[test]
|
||||||
fn corrupt_file_is_reported() {
|
fn corrupt_file_is_reported() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user