use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const STALE_SECS: u64 = crate::fleet::SNAPSHOT_STALE_SECS;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Heartbeat {
pub updated_ms: u64,
pub pid: u32,
}
fn headless_dir_in(home: &Path) -> PathBuf {
home.join(".claudectl").join("headless")
}
fn home() -> PathBuf {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
}
pub fn path() -> PathBuf {
path_in(&home())
}
pub fn path_in(home: &Path) -> PathBuf {
headless_dir_in(home).join("heartbeat.json")
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub fn write() -> io::Result<()> {
write_in(&home())
}
pub fn write_in(home: &Path) -> io::Result<()> {
let dir = headless_dir_in(home);
fs::create_dir_all(&dir)?;
let beat = Heartbeat {
updated_ms: now_ms(),
pid: std::process::id(),
};
let body = serde_json::to_string(&beat).map_err(io::Error::other)?;
let tmp = dir.join(".heartbeat.json.tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, dir.join("heartbeat.json"))
}
pub fn read() -> Option<Heartbeat> {
read_from(&path())
}
pub fn read_from(path: &Path) -> Option<Heartbeat> {
let body = fs::read_to_string(path).ok()?;
let beat: Heartbeat = serde_json::from_str(&body).ok()?;
if now_ms().saturating_sub(beat.updated_ms) > STALE_SECS * 1000 {
return None;
}
Some(beat)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_heartbeat_reads_back() {
let tmp = tempfile::tempdir().unwrap();
write_in(tmp.path()).unwrap();
let beat = read_from(&path_in(tmp.path())).expect("fresh heartbeat should read back");
assert_eq!(beat.pid, std::process::id());
}
#[test]
fn a_missing_heartbeat_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(read_from(&path_in(tmp.path())).is_none());
}
#[test]
fn a_stale_heartbeat_is_none() {
let tmp = tempfile::tempdir().unwrap();
let dir = headless_dir_in(tmp.path());
fs::create_dir_all(&dir).unwrap();
let old = Heartbeat {
updated_ms: now_ms().saturating_sub((STALE_SECS + 10) * 1000),
pid: 1234,
};
fs::write(path_in(tmp.path()), serde_json::to_string(&old).unwrap()).unwrap();
assert!(
read_from(&path_in(tmp.path())).is_none(),
"a heartbeat older than STALE_SECS means the loop stopped"
);
}
#[test]
fn unparseable_is_none_not_a_panic() {
let tmp = tempfile::tempdir().unwrap();
let dir = headless_dir_in(tmp.path());
fs::create_dir_all(&dir).unwrap();
fs::write(path_in(tmp.path()), "{ truncated").unwrap();
assert!(read_from(&path_in(tmp.path())).is_none());
}
#[test]
fn writing_leaves_no_temp_file() {
let tmp = tempfile::tempdir().unwrap();
write_in(tmp.path()).unwrap();
let leftover = headless_dir_in(tmp.path()).join(".heartbeat.json.tmp");
assert!(
!leftover.exists(),
"temp file should have been renamed away"
);
}
#[test]
fn staleness_matches_the_fleet_snapshot() {
assert_eq!(STALE_SECS, crate::fleet::SNAPSHOT_STALE_SECS);
}
}