1use std::fs;
18use std::io;
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22
23use crate::session::ClaudeSession;
24
25pub const WORKER_STALE_SECS: u64 = 90;
29
30pub const SNAPSHOT_STALE_SECS: u64 = 120;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct FleetWorker {
37 pub worker_id: String,
39 pub sessions: Vec<serde_json::Value>,
41 pub updated_ms: u64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FleetSnapshot {
48 pub updated_ms: u64,
50 pub local_worker_id: String,
52 pub workers: Vec<FleetWorker>,
56}
57
58impl FleetWorker {
59 pub fn is_live(&self, now_ms: u64) -> bool {
61 now_ms.saturating_sub(self.updated_ms) <= WORKER_STALE_SECS * 1000
62 }
63}
64
65impl FleetSnapshot {
66 pub fn live_workers(&self) -> Vec<&FleetWorker> {
69 let now = now_ms();
70 self.workers.iter().filter(|w| w.is_live(now)).collect()
71 }
72}
73
74fn relay_dir() -> PathBuf {
75 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
76 PathBuf::from(home).join(".claudectl").join("relay")
77}
78
79pub fn snapshot_path() -> PathBuf {
81 relay_dir().join("fleet.json")
82}
83
84fn now_ms() -> u64 {
85 std::time::SystemTime::now()
86 .duration_since(std::time::UNIX_EPOCH)
87 .unwrap_or_default()
88 .as_millis() as u64
89}
90
91pub fn write_snapshot(snapshot: &FleetSnapshot) -> io::Result<()> {
93 write_snapshot_to(&relay_dir(), snapshot)
94}
95
96pub fn write_snapshot_to(dir: &std::path::Path, snapshot: &FleetSnapshot) -> io::Result<()> {
98 fs::create_dir_all(dir)?;
99 let body = serde_json::to_string(snapshot).map_err(io::Error::other)?;
100 let tmp_path = dir.join(".fleet.json.tmp");
101 fs::write(&tmp_path, body)?;
102 fs::rename(&tmp_path, dir.join("fleet.json"))
103}
104
105pub fn read_snapshot() -> Option<FleetSnapshot> {
108 read_snapshot_from(&snapshot_path())
109}
110
111pub fn read_snapshot_from(path: &std::path::Path) -> Option<FleetSnapshot> {
113 let body = fs::read_to_string(path).ok()?;
114 let snapshot: FleetSnapshot = serde_json::from_str(&body).ok()?;
115 let age_ms = now_ms().saturating_sub(snapshot.updated_ms);
116 if age_ms > SNAPSHOT_STALE_SECS * 1000 {
117 return None;
118 }
119 Some(snapshot)
120}
121
122pub fn remote_sessions() -> Vec<ClaudeSession> {
125 read_snapshot().map(sessions_from).unwrap_or_default()
126}
127
128pub fn sessions_from(snapshot: FleetSnapshot) -> Vec<ClaudeSession> {
130 let mut out = Vec::new();
131 for worker in snapshot.live_workers() {
132 for value in &worker.sessions {
133 if let Some(session) = ClaudeSession::from_remote_json(&worker.worker_id, value) {
134 out.push(session);
135 }
136 }
137 }
138 out
139}
140
141#[derive(Default)]
154pub struct LocalSessionCollector {
155 sessions: Vec<ClaudeSession>,
156}
157
158impl LocalSessionCollector {
159 pub fn new() -> Self {
160 Self::default()
161 }
162
163 pub fn collect(&mut self) -> &[ClaudeSession] {
166 let discovered = crate::discovery::scan_sessions();
167
168 let mut existing: std::collections::HashMap<u32, ClaudeSession> =
171 self.sessions.drain(..).map(|s| (s.pid, s)).collect();
172 let mut sessions: Vec<ClaudeSession> = discovered
173 .into_iter()
174 .map(|new| match existing.remove(&new.pid) {
175 Some(mut prev) => {
176 prev.elapsed = new.elapsed;
177 prev.started_at = new.started_at;
178 prev
179 }
180 None => new,
181 })
182 .collect();
183
184 crate::process::fetch_and_enrich(&mut sessions);
185 for session in &mut sessions {
186 if session.jsonl_path.is_none() {
188 crate::discovery::resolve_jsonl_paths(std::slice::from_mut(session));
189 }
190 }
191 for session in &mut sessions {
192 crate::monitor::update_tokens(session);
193 }
194
195 self.sessions = sessions;
196 &self.sessions
197 }
198
199 pub fn sessions(&self) -> &[ClaudeSession] {
200 &self.sessions
201 }
202
203 pub fn as_json(&self) -> Vec<serde_json::Value> {
206 self.sessions
207 .iter()
208 .map(ClaudeSession::to_json_value)
209 .collect()
210 }
211}
212
213pub fn collect_local_sessions() -> Vec<serde_json::Value> {
216 let mut collector = LocalSessionCollector::new();
217 collector.collect();
218 collector.as_json()
219}
220
221pub fn build_snapshot(local_worker_id: &str, workers: Vec<FleetWorker>) -> FleetSnapshot {
224 FleetSnapshot {
225 updated_ms: now_ms(),
226 local_worker_id: local_worker_id.to_string(),
227 workers,
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn temp_dir(tag: &str) -> PathBuf {
236 let dir =
237 std::env::temp_dir().join(format!("claudectl-fleet-{tag}-{}", std::process::id()));
238 let _ = fs::remove_dir_all(&dir);
239 dir
240 }
241
242 fn sample_session_json() -> serde_json::Value {
243 serde_json::json!({
244 "pid": 4242,
245 "project": "claudectl",
246 "status": "Processing",
247 "cost_usd": 2.5,
248 "elapsed_secs": 300,
249 "tokens_in": 1000,
250 "tokens_out": 200,
251 })
252 }
253
254 #[test]
255 fn snapshot_round_trips_through_disk() {
256 let dir = temp_dir("roundtrip");
257 let snapshot = build_snapshot(
258 "laptop",
259 vec![FleetWorker {
260 worker_id: "mini".into(),
261 sessions: vec![sample_session_json()],
262 updated_ms: now_ms(),
263 }],
264 );
265 write_snapshot_to(&dir, &snapshot).unwrap();
266
267 let read = read_snapshot_from(&dir.join("fleet.json")).unwrap();
268 assert_eq!(read.local_worker_id, "laptop");
269 assert_eq!(read.workers.len(), 1);
270 assert_eq!(read.workers[0].worker_id, "mini");
271
272 let sessions = sessions_from(read);
273 assert_eq!(sessions.len(), 1);
274 assert!(sessions[0].is_remote());
275 assert_eq!(sessions[0].project_name, "[mini] claudectl");
276 }
277
278 #[test]
283 fn real_session_json_survives_the_round_trip() {
284 let mut session = ClaudeSession::from_raw(crate::session::RawSession {
285 pid: 777,
286 session_id: "abc123".into(),
287 cwd: "/Users/dev/myproject".into(),
288 started_at: now_ms().saturating_sub(60_000),
289 });
290 session.status = crate::session::SessionStatus::NeedsInput;
291 session.cost_usd = 1.5;
292 session.total_input_tokens = 12_000;
293 session.total_output_tokens = 3_000;
294 session.usage_metrics_available = true;
295
296 let value = session.to_json_value();
297 let restored = ClaudeSession::from_remote_json("mini", &value)
298 .expect("to_json_value output must parse back via from_remote_json");
299
300 assert_eq!(restored.pid, 777);
301 assert_eq!(restored.status, crate::session::SessionStatus::NeedsInput);
302 assert_eq!(restored.total_input_tokens, 12_000);
303 assert_eq!(restored.worker_origin.as_deref(), Some("mini"));
304 assert!(restored.project_name.starts_with("[mini] "));
305 }
306
307 #[test]
308 fn stale_snapshot_is_ignored() {
309 let dir = temp_dir("stale");
310 let mut snapshot = build_snapshot("laptop", vec![]);
311 snapshot.updated_ms = now_ms().saturating_sub((SNAPSHOT_STALE_SECS + 10) * 1000);
312 write_snapshot_to(&dir, &snapshot).unwrap();
313 assert!(read_snapshot_from(&dir.join("fleet.json")).is_none());
314 }
315
316 #[test]
317 fn stale_worker_is_dropped_from_a_live_snapshot() {
318 let snapshot = build_snapshot(
319 "laptop",
320 vec![
321 FleetWorker {
322 worker_id: "live".into(),
323 sessions: vec![sample_session_json()],
324 updated_ms: now_ms(),
325 },
326 FleetWorker {
327 worker_id: "gone".into(),
328 sessions: vec![sample_session_json()],
329 updated_ms: now_ms().saturating_sub((WORKER_STALE_SECS + 10) * 1000),
330 },
331 ],
332 );
333 let sessions = sessions_from(snapshot);
334 assert_eq!(sessions.len(), 1);
335 assert_eq!(sessions[0].worker_origin.as_deref(), Some("live"));
336 }
337
338 #[test]
342 fn live_workers_and_sessions_from_agree() {
343 let snapshot = build_snapshot(
344 "laptop",
345 vec![
346 FleetWorker {
347 worker_id: "live".into(),
348 sessions: vec![sample_session_json(), sample_session_json()],
349 updated_ms: now_ms(),
350 },
351 FleetWorker {
352 worker_id: "gone".into(),
353 sessions: vec![sample_session_json()],
354 updated_ms: now_ms().saturating_sub((WORKER_STALE_SECS + 10) * 1000),
355 },
356 ],
357 );
358 let live = snapshot.live_workers();
359 assert_eq!(live.len(), 1);
360 let counted: usize = live.iter().map(|w| w.sessions.len()).sum();
361 assert_eq!(counted, sessions_from(snapshot).len());
362 }
363
364 #[test]
368 fn collector_keeps_session_state_across_collections() {
369 let mut collector = LocalSessionCollector::new();
370
371 collector.sessions = vec![{
373 let mut s = ClaudeSession::from_raw(crate::session::RawSession {
374 pid: std::process::id(),
375 session_id: "seeded".into(),
376 cwd: "/tmp/seeded".into(),
377 started_at: now_ms(),
378 });
379 s.jsonl_offset = 4096;
380 s.total_input_tokens = 9_000;
381 s
382 }];
383
384 let pid = collector.sessions[0].pid;
385 let mut existing: std::collections::HashMap<u32, ClaudeSession> =
386 collector.sessions.drain(..).map(|s| (s.pid, s)).collect();
387
388 let rediscovered = ClaudeSession::from_raw(crate::session::RawSession {
390 pid,
391 session_id: "seeded".into(),
392 cwd: "/tmp/seeded".into(),
393 started_at: now_ms(),
394 });
395 let merged = match existing.remove(&rediscovered.pid) {
396 Some(mut prev) => {
397 prev.elapsed = rediscovered.elapsed;
398 prev
399 }
400 None => rediscovered,
401 };
402
403 assert_eq!(
404 merged.jsonl_offset, 4096,
405 "a surviving PID must keep its JSONL offset"
406 );
407 assert_eq!(merged.total_input_tokens, 9_000);
408 }
409
410 #[test]
411 fn missing_snapshot_reads_as_empty() {
412 let dir = temp_dir("missing");
413 assert!(read_snapshot_from(&dir.join("fleet.json")).is_none());
414 }
415}