use std::fs;
use std::io;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::session::ClaudeSession;
pub const WORKER_STALE_SECS: u64 = 90;
pub const SNAPSHOT_STALE_SECS: u64 = 120;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetWorker {
pub worker_id: String,
pub sessions: Vec<serde_json::Value>,
pub updated_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetSnapshot {
pub updated_ms: u64,
pub local_worker_id: String,
pub workers: Vec<FleetWorker>,
}
impl FleetWorker {
pub fn is_live(&self, now_ms: u64) -> bool {
now_ms.saturating_sub(self.updated_ms) <= WORKER_STALE_SECS * 1000
}
}
impl FleetSnapshot {
pub fn live_workers(&self) -> Vec<&FleetWorker> {
let now = now_ms();
self.workers.iter().filter(|w| w.is_live(now)).collect()
}
}
fn relay_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home).join(".claudectl").join("relay")
}
pub fn snapshot_path() -> PathBuf {
relay_dir().join("fleet.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_snapshot(snapshot: &FleetSnapshot) -> io::Result<()> {
write_snapshot_to(&relay_dir(), snapshot)
}
pub fn write_snapshot_to(dir: &std::path::Path, snapshot: &FleetSnapshot) -> io::Result<()> {
fs::create_dir_all(dir)?;
let body = serde_json::to_string(snapshot).map_err(io::Error::other)?;
let tmp_path = dir.join(".fleet.json.tmp");
fs::write(&tmp_path, body)?;
fs::rename(&tmp_path, dir.join("fleet.json"))
}
pub fn read_snapshot() -> Option<FleetSnapshot> {
read_snapshot_from(&snapshot_path())
}
pub fn read_snapshot_from(path: &std::path::Path) -> Option<FleetSnapshot> {
let body = fs::read_to_string(path).ok()?;
let snapshot: FleetSnapshot = serde_json::from_str(&body).ok()?;
let age_ms = now_ms().saturating_sub(snapshot.updated_ms);
if age_ms > SNAPSHOT_STALE_SECS * 1000 {
return None;
}
Some(snapshot)
}
pub fn remote_sessions() -> Vec<ClaudeSession> {
read_snapshot().map(sessions_from).unwrap_or_default()
}
pub fn sessions_from(snapshot: FleetSnapshot) -> Vec<ClaudeSession> {
let mut out = Vec::new();
for worker in snapshot.live_workers() {
for value in &worker.sessions {
if let Some(session) = ClaudeSession::from_remote_json(&worker.worker_id, value) {
out.push(session);
}
}
}
out
}
#[derive(Default)]
pub struct LocalSessionCollector {
sessions: Vec<ClaudeSession>,
}
impl LocalSessionCollector {
pub fn new() -> Self {
Self::default()
}
pub fn collect(&mut self) -> &[ClaudeSession] {
let discovered = crate::discovery::scan_sessions();
let mut existing: std::collections::HashMap<u32, ClaudeSession> =
self.sessions.drain(..).map(|s| (s.pid, s)).collect();
let mut sessions: Vec<ClaudeSession> = discovered
.into_iter()
.map(|new| match existing.remove(&new.pid) {
Some(mut prev) => {
prev.elapsed = new.elapsed;
prev.started_at = new.started_at;
prev
}
None => new,
})
.collect();
crate::process::fetch_and_enrich(&mut sessions);
for session in &mut sessions {
if session.jsonl_path.is_none() {
crate::discovery::resolve_jsonl_paths(std::slice::from_mut(session));
}
}
for session in &mut sessions {
crate::monitor::update_tokens(session);
}
self.sessions = sessions;
&self.sessions
}
pub fn sessions(&self) -> &[ClaudeSession] {
&self.sessions
}
pub fn as_json(&self) -> Vec<serde_json::Value> {
self.sessions
.iter()
.map(ClaudeSession::to_json_value)
.collect()
}
}
pub fn collect_local_sessions() -> Vec<serde_json::Value> {
let mut collector = LocalSessionCollector::new();
collector.collect();
collector.as_json()
}
pub fn build_snapshot(local_worker_id: &str, workers: Vec<FleetWorker>) -> FleetSnapshot {
FleetSnapshot {
updated_ms: now_ms(),
local_worker_id: local_worker_id.to_string(),
workers,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("claudectl-fleet-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
fn sample_session_json() -> serde_json::Value {
serde_json::json!({
"pid": 4242,
"project": "claudectl",
"status": "Processing",
"cost_usd": 2.5,
"elapsed_secs": 300,
"tokens_in": 1000,
"tokens_out": 200,
})
}
#[test]
fn snapshot_round_trips_through_disk() {
let dir = temp_dir("roundtrip");
let snapshot = build_snapshot(
"laptop",
vec![FleetWorker {
worker_id: "mini".into(),
sessions: vec![sample_session_json()],
updated_ms: now_ms(),
}],
);
write_snapshot_to(&dir, &snapshot).unwrap();
let read = read_snapshot_from(&dir.join("fleet.json")).unwrap();
assert_eq!(read.local_worker_id, "laptop");
assert_eq!(read.workers.len(), 1);
assert_eq!(read.workers[0].worker_id, "mini");
let sessions = sessions_from(read);
assert_eq!(sessions.len(), 1);
assert!(sessions[0].is_remote());
assert_eq!(sessions[0].project_name, "[mini] claudectl");
}
#[test]
fn real_session_json_survives_the_round_trip() {
let mut session = ClaudeSession::from_raw(crate::session::RawSession {
pid: 777,
session_id: "abc123".into(),
cwd: "/Users/dev/myproject".into(),
started_at: now_ms().saturating_sub(60_000),
});
session.status = crate::session::SessionStatus::NeedsInput;
session.cost_usd = 1.5;
session.total_input_tokens = 12_000;
session.total_output_tokens = 3_000;
session.usage_metrics_available = true;
let value = session.to_json_value();
let restored = ClaudeSession::from_remote_json("mini", &value)
.expect("to_json_value output must parse back via from_remote_json");
assert_eq!(restored.pid, 777);
assert_eq!(restored.status, crate::session::SessionStatus::NeedsInput);
assert_eq!(restored.total_input_tokens, 12_000);
assert_eq!(restored.worker_origin.as_deref(), Some("mini"));
assert!(restored.project_name.starts_with("[mini] "));
}
#[test]
fn stale_snapshot_is_ignored() {
let dir = temp_dir("stale");
let mut snapshot = build_snapshot("laptop", vec![]);
snapshot.updated_ms = now_ms().saturating_sub((SNAPSHOT_STALE_SECS + 10) * 1000);
write_snapshot_to(&dir, &snapshot).unwrap();
assert!(read_snapshot_from(&dir.join("fleet.json")).is_none());
}
#[test]
fn stale_worker_is_dropped_from_a_live_snapshot() {
let snapshot = build_snapshot(
"laptop",
vec![
FleetWorker {
worker_id: "live".into(),
sessions: vec![sample_session_json()],
updated_ms: now_ms(),
},
FleetWorker {
worker_id: "gone".into(),
sessions: vec![sample_session_json()],
updated_ms: now_ms().saturating_sub((WORKER_STALE_SECS + 10) * 1000),
},
],
);
let sessions = sessions_from(snapshot);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].worker_origin.as_deref(), Some("live"));
}
#[test]
fn live_workers_and_sessions_from_agree() {
let snapshot = build_snapshot(
"laptop",
vec![
FleetWorker {
worker_id: "live".into(),
sessions: vec![sample_session_json(), sample_session_json()],
updated_ms: now_ms(),
},
FleetWorker {
worker_id: "gone".into(),
sessions: vec![sample_session_json()],
updated_ms: now_ms().saturating_sub((WORKER_STALE_SECS + 10) * 1000),
},
],
);
let live = snapshot.live_workers();
assert_eq!(live.len(), 1);
let counted: usize = live.iter().map(|w| w.sessions.len()).sum();
assert_eq!(counted, sessions_from(snapshot).len());
}
#[test]
fn collector_keeps_session_state_across_collections() {
let mut collector = LocalSessionCollector::new();
collector.sessions = vec![{
let mut s = ClaudeSession::from_raw(crate::session::RawSession {
pid: std::process::id(),
session_id: "seeded".into(),
cwd: "/tmp/seeded".into(),
started_at: now_ms(),
});
s.jsonl_offset = 4096;
s.total_input_tokens = 9_000;
s
}];
let pid = collector.sessions[0].pid;
let mut existing: std::collections::HashMap<u32, ClaudeSession> =
collector.sessions.drain(..).map(|s| (s.pid, s)).collect();
let rediscovered = ClaudeSession::from_raw(crate::session::RawSession {
pid,
session_id: "seeded".into(),
cwd: "/tmp/seeded".into(),
started_at: now_ms(),
});
let merged = match existing.remove(&rediscovered.pid) {
Some(mut prev) => {
prev.elapsed = rediscovered.elapsed;
prev
}
None => rediscovered,
};
assert_eq!(
merged.jsonl_offset, 4096,
"a surviving PID must keep its JSONL offset"
);
assert_eq!(merged.total_input_tokens, 9_000);
}
#[test]
fn missing_snapshot_reads_as_empty() {
let dir = temp_dir("missing");
assert!(read_snapshot_from(&dir.join("fleet.json")).is_none());
}
}