use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
pub const FILE_NAME: &str = "agent-liveness.json";
const WRITE_INTERVAL: Duration = Duration::from_secs(60);
pub fn read(openlatch_dir: &Path) -> BTreeMap<String, DateTime<Utc>> {
std::fs::read_to_string(openlatch_dir.join(FILE_NAME))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
#[derive(Default)]
pub struct LivenessRecorder {
dir: Option<PathBuf>,
last_write: Mutex<HashMap<String, Instant>>,
}
impl LivenessRecorder {
pub fn new(openlatch_dir: PathBuf) -> Self {
Self {
dir: Some(openlatch_dir),
last_write: Mutex::default(),
}
}
pub fn record(&self, source: &str) {
let Some(dir) = self.dir.as_deref() else {
return;
};
if !self.due(source, Instant::now()) {
return;
}
let dir = dir.to_path_buf();
let source = source.to_string();
let now = Utc::now();
tokio::task::spawn_blocking(move || {
if let Err(e) = write(&dir, &source, now) {
tracing::debug!(error = %e, "agent liveness: cannot record");
}
});
}
fn due(&self, source: &str, now: Instant) -> bool {
let Ok(mut last) = self.last_write.lock() else {
return false;
};
match last.get(source) {
Some(at) if now.saturating_duration_since(*at) < WRITE_INTERVAL => false,
_ => {
last.insert(source.to_string(), now);
true
}
}
}
}
static WRITE_LOCK: Mutex<()> = Mutex::new(());
fn write(openlatch_dir: &Path, source: &str, at: DateTime<Utc>) -> std::io::Result<()> {
let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut all = read(openlatch_dir);
all.insert(source.to_string(), at);
let body = serde_json::to_string_pretty(&all).map_err(std::io::Error::other)?;
crate::core::fs_secure::write_owner_only(&openlatch_dir.join(FILE_NAME), &body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_keeps_other_sources_and_read_round_trips() {
let dir = tempfile::tempdir().unwrap();
assert!(read(dir.path()).is_empty(), "absent file = no evidence");
let t1 = "2026-09-30T10:00:00Z".parse().unwrap();
let t2 = "2026-09-30T11:00:00Z".parse().unwrap();
write(dir.path(), "claude-code", t1).unwrap();
write(dir.path(), "cursor", t2).unwrap();
let all = read(dir.path());
assert_eq!(all.get("claude-code"), Some(&t1));
assert_eq!(all.get("cursor"), Some(&t2));
}
#[test]
fn a_corrupt_file_reads_as_no_evidence() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(FILE_NAME), "{not json").unwrap();
assert!(read(dir.path()).is_empty());
}
#[test]
fn writes_are_throttled_per_source() {
let r = LivenessRecorder::new(PathBuf::from("/unused"));
let t0 = Instant::now();
assert!(r.due("cursor", t0));
assert!(!r.due("cursor", t0 + Duration::from_secs(59)));
assert!(r.due("claude-code", t0), "another source is not throttled");
assert!(r.due("cursor", t0 + WRITE_INTERVAL));
}
#[cfg(unix)]
#[test]
fn the_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "cursor", Utc::now()).unwrap();
let mode = std::fs::metadata(dir.path().join(FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}