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 #[serde(default)]
47 pub host: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FleetSnapshot {
53 pub updated_ms: u64,
55 pub local_worker_id: String,
57 pub workers: Vec<FleetWorker>,
61}
62
63impl FleetWorker {
64 pub fn is_live(&self, now_ms: u64) -> bool {
66 now_ms.saturating_sub(self.updated_ms) <= WORKER_STALE_SECS * 1000
67 }
68}
69
70impl FleetSnapshot {
71 pub fn live_workers(&self) -> Vec<&FleetWorker> {
74 let now = now_ms();
75 self.workers.iter().filter(|w| w.is_live(now)).collect()
76 }
77}
78
79fn relay_dir() -> PathBuf {
80 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
81 PathBuf::from(home).join(".claudectl").join("relay")
82}
83
84pub fn snapshot_path() -> PathBuf {
86 relay_dir().join("fleet.json")
87}
88
89fn now_ms() -> u64 {
90 std::time::SystemTime::now()
91 .duration_since(std::time::UNIX_EPOCH)
92 .unwrap_or_default()
93 .as_millis() as u64
94}
95
96pub fn write_snapshot(snapshot: &FleetSnapshot) -> io::Result<()> {
98 write_snapshot_to(&relay_dir(), snapshot)
99}
100
101pub fn write_snapshot_to(dir: &std::path::Path, snapshot: &FleetSnapshot) -> io::Result<()> {
103 fs::create_dir_all(dir)?;
104 let body = serde_json::to_string(snapshot).map_err(io::Error::other)?;
105 let tmp_path = dir.join(".fleet.json.tmp");
106 fs::write(&tmp_path, body)?;
107 fs::rename(&tmp_path, dir.join("fleet.json"))
108}
109
110pub fn read_snapshot() -> Option<FleetSnapshot> {
113 read_snapshot_from(&snapshot_path())
114}
115
116pub fn read_snapshot_from(path: &std::path::Path) -> Option<FleetSnapshot> {
118 let body = fs::read_to_string(path).ok()?;
119 let snapshot: FleetSnapshot = serde_json::from_str(&body).ok()?;
120 let age_ms = now_ms().saturating_sub(snapshot.updated_ms);
121 if age_ms > SNAPSHOT_STALE_SECS * 1000 {
122 return None;
123 }
124 Some(snapshot)
125}
126
127pub fn remote_sessions() -> Vec<ClaudeSession> {
130 read_snapshot().map(sessions_from).unwrap_or_default()
131}
132
133pub fn sessions_from(snapshot: FleetSnapshot) -> Vec<ClaudeSession> {
135 let mut out = Vec::new();
136 for worker in snapshot.live_workers() {
137 for value in &worker.sessions {
138 if let Some(mut session) = ClaudeSession::from_remote_json(&worker.worker_id, value) {
139 session.remote_host = worker.host.clone();
140 out.push(session);
141 }
142 }
143 }
144 out
145}
146
147#[derive(Default)]
160pub struct LocalSessionCollector {
161 sessions: Vec<ClaudeSession>,
162}
163
164impl LocalSessionCollector {
165 pub fn new() -> Self {
166 Self::default()
167 }
168
169 pub fn collect(&mut self) -> &[ClaudeSession] {
172 let discovered = crate::discovery::scan_sessions();
173
174 let mut existing: std::collections::HashMap<u32, ClaudeSession> =
177 self.sessions.drain(..).map(|s| (s.pid, s)).collect();
178 let mut sessions: Vec<ClaudeSession> = discovered
179 .into_iter()
180 .map(|new| match existing.remove(&new.pid) {
181 Some(mut prev) => {
182 prev.elapsed = new.elapsed;
183 prev.started_at = new.started_at;
184 prev
185 }
186 None => new,
187 })
188 .collect();
189
190 crate::process::fetch_and_enrich(&mut sessions);
191 for session in &mut sessions {
192 if session.jsonl_path.is_none() {
194 crate::discovery::resolve_jsonl_paths(std::slice::from_mut(session));
195 }
196 }
197 for session in &mut sessions {
198 crate::monitor::update_tokens(session);
199 }
200 let panes = crate::terminals::tmux_pane_targets();
201 for session in &mut sessions {
202 session.tmux_target = pane_for(&panes, &session.tty);
203 }
204
205 self.sessions = sessions;
206 &self.sessions
207 }
208
209 pub fn sessions(&self) -> &[ClaudeSession] {
210 &self.sessions
211 }
212
213 pub fn as_json(&self) -> Vec<serde_json::Value> {
216 let user = local_login();
217 self.sessions
218 .iter()
219 .map(|s| {
220 let mut value = s.to_json_value();
221 if let (Some(user), Some(obj)) = (&user, value.as_object_mut()) {
222 obj.insert("os_user".into(), serde_json::json!(user));
223 }
224 value
225 })
226 .collect()
227 }
228}
229
230fn pane_for(panes: &std::collections::HashMap<String, String>, tty: &str) -> Option<String> {
233 let tty = tty.trim().trim_start_matches("/dev/");
234 if tty.is_empty() || tty == "??" {
235 return None;
236 }
237 panes.get(tty).cloned().or_else(|| {
238 panes
239 .iter()
240 .find(|(pane_tty, _)| pane_tty.ends_with(tty))
241 .map(|(_, target)| target.clone())
242 })
243}
244
245fn local_login() -> Option<String> {
248 std::env::var("USER")
249 .ok()
250 .filter(|u| !u.is_empty())
251 .or_else(|| {
252 std::env::var("HOME").ok().and_then(|h| {
253 std::path::Path::new(&h)
254 .file_name()
255 .map(|n| n.to_string_lossy().into_owned())
256 })
257 })
258}
259
260pub fn collect_local_sessions() -> Vec<serde_json::Value> {
263 let mut collector = LocalSessionCollector::new();
264 collector.collect();
265 collector.as_json()
266}
267
268pub fn build_snapshot(local_worker_id: &str, workers: Vec<FleetWorker>) -> FleetSnapshot {
271 FleetSnapshot {
272 updated_ms: now_ms(),
273 local_worker_id: local_worker_id.to_string(),
274 workers,
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn temp_dir(tag: &str) -> PathBuf {
283 let dir =
284 std::env::temp_dir().join(format!("claudectl-fleet-{tag}-{}", std::process::id()));
285 let _ = fs::remove_dir_all(&dir);
286 dir
287 }
288
289 fn sample_session_json() -> serde_json::Value {
290 serde_json::json!({
291 "pid": 4242,
292 "project": "claudectl",
293 "status": "Processing",
294 "cost_usd": 2.5,
295 "elapsed_secs": 300,
296 "tokens_in": 1000,
297 "tokens_out": 200,
298 })
299 }
300
301 #[test]
302 fn snapshot_round_trips_through_disk() {
303 let dir = temp_dir("roundtrip");
304 let snapshot = build_snapshot(
305 "laptop",
306 vec![FleetWorker {
307 worker_id: "mini".into(),
308 sessions: vec![sample_session_json()],
309 updated_ms: now_ms(),
310 host: None,
311 }],
312 );
313 write_snapshot_to(&dir, &snapshot).unwrap();
314
315 let read = read_snapshot_from(&dir.join("fleet.json")).unwrap();
316 assert_eq!(read.local_worker_id, "laptop");
317 assert_eq!(read.workers.len(), 1);
318 assert_eq!(read.workers[0].worker_id, "mini");
319
320 let sessions = sessions_from(read);
321 assert_eq!(sessions.len(), 1);
322 assert!(sessions[0].is_remote());
323 assert_eq!(sessions[0].project_name, "[mini] claudectl");
324 }
325
326 #[test]
331 fn real_session_json_survives_the_round_trip() {
332 let mut session = ClaudeSession::from_raw(crate::session::RawSession {
333 pid: 777,
334 session_id: "abc123".into(),
335 cwd: "/Users/dev/myproject".into(),
336 started_at: now_ms().saturating_sub(60_000),
337 });
338 session.status = crate::session::SessionStatus::NeedsInput;
339 session.cost_usd = 1.5;
340 session.total_input_tokens = 12_000;
341 session.total_output_tokens = 3_000;
342 session.usage_metrics_available = true;
343
344 let value = session.to_json_value();
345 let restored = ClaudeSession::from_remote_json("mini", &value)
346 .expect("to_json_value output must parse back via from_remote_json");
347
348 assert_eq!(restored.pid, 777);
349 assert_eq!(restored.status, crate::session::SessionStatus::NeedsInput);
350 assert_eq!(restored.total_input_tokens, 12_000);
351 assert_eq!(restored.worker_origin.as_deref(), Some("mini"));
352 assert!(restored.project_name.starts_with("[mini] "));
353 }
354
355 #[test]
356 fn stale_snapshot_is_ignored() {
357 let dir = temp_dir("stale");
358 let mut snapshot = build_snapshot("laptop", vec![]);
359 snapshot.updated_ms = now_ms().saturating_sub((SNAPSHOT_STALE_SECS + 10) * 1000);
360 write_snapshot_to(&dir, &snapshot).unwrap();
361 assert!(read_snapshot_from(&dir.join("fleet.json")).is_none());
362 }
363
364 #[test]
365 fn stale_worker_is_dropped_from_a_live_snapshot() {
366 let snapshot = build_snapshot(
367 "laptop",
368 vec![
369 FleetWorker {
370 worker_id: "live".into(),
371 sessions: vec![sample_session_json()],
372 updated_ms: now_ms(),
373 host: None,
374 },
375 FleetWorker {
376 worker_id: "gone".into(),
377 sessions: vec![sample_session_json()],
378 updated_ms: now_ms().saturating_sub((WORKER_STALE_SECS + 10) * 1000),
379 host: None,
380 },
381 ],
382 );
383 let sessions = sessions_from(snapshot);
384 assert_eq!(sessions.len(), 1);
385 assert_eq!(sessions[0].worker_origin.as_deref(), Some("live"));
386 }
387
388 #[test]
392 fn live_workers_and_sessions_from_agree() {
393 let snapshot = build_snapshot(
394 "laptop",
395 vec![
396 FleetWorker {
397 worker_id: "live".into(),
398 sessions: vec![sample_session_json(), sample_session_json()],
399 updated_ms: now_ms(),
400 host: None,
401 },
402 FleetWorker {
403 worker_id: "gone".into(),
404 sessions: vec![sample_session_json()],
405 updated_ms: now_ms().saturating_sub((WORKER_STALE_SECS + 10) * 1000),
406 host: None,
407 },
408 ],
409 );
410 let live = snapshot.live_workers();
411 assert_eq!(live.len(), 1);
412 let counted: usize = live.iter().map(|w| w.sessions.len()).sum();
413 assert_eq!(counted, sessions_from(snapshot).len());
414 }
415
416 #[test]
420 fn collector_keeps_session_state_across_collections() {
421 let mut collector = LocalSessionCollector::new();
422
423 collector.sessions = vec![{
425 let mut s = ClaudeSession::from_raw(crate::session::RawSession {
426 pid: std::process::id(),
427 session_id: "seeded".into(),
428 cwd: "/tmp/seeded".into(),
429 started_at: now_ms(),
430 });
431 s.jsonl_offset = 4096;
432 s.total_input_tokens = 9_000;
433 s
434 }];
435
436 let pid = collector.sessions[0].pid;
437 let mut existing: std::collections::HashMap<u32, ClaudeSession> =
438 collector.sessions.drain(..).map(|s| (s.pid, s)).collect();
439
440 let rediscovered = ClaudeSession::from_raw(crate::session::RawSession {
442 pid,
443 session_id: "seeded".into(),
444 cwd: "/tmp/seeded".into(),
445 started_at: now_ms(),
446 });
447 let merged = match existing.remove(&rediscovered.pid) {
448 Some(mut prev) => {
449 prev.elapsed = rediscovered.elapsed;
450 prev
451 }
452 None => rediscovered,
453 };
454
455 assert_eq!(
456 merged.jsonl_offset, 4096,
457 "a surviving PID must keep its JSONL offset"
458 );
459 assert_eq!(merged.total_input_tokens, 9_000);
460 }
461
462 #[test]
463 fn worker_host_reaches_its_sessions() {
464 let snapshot = build_snapshot(
465 "laptop",
466 vec![FleetWorker {
467 worker_id: "mini".into(),
468 sessions: vec![sample_session_json()],
469 updated_ms: now_ms(),
470 host: Some("100.109.212.89".into()),
471 }],
472 );
473 let sessions = sessions_from(snapshot);
474 assert_eq!(sessions[0].remote_host.as_deref(), Some("100.109.212.89"));
475 }
476
477 #[test]
478 fn snapshot_without_host_field_still_parses() {
479 let raw = format!(
480 r#"{{"updated_ms":{},"local_worker_id":"laptop","workers":[{{"worker_id":"mini","sessions":[],"updated_ms":1}}]}}"#,
481 now_ms()
482 );
483 let snapshot: FleetSnapshot = serde_json::from_str(&raw).unwrap();
484 assert!(snapshot.workers[0].host.is_none());
485 }
486
487 #[test]
488 fn pane_for_matches_exact_and_short_ttys() {
489 let panes: std::collections::HashMap<String, String> =
490 [("ttys002".to_string(), "claude-staffai:0.0".to_string())].into();
491 assert_eq!(
492 pane_for(&panes, "ttys002").as_deref(),
493 Some("claude-staffai:0.0")
494 );
495 assert_eq!(
496 pane_for(&panes, "s002").as_deref(),
497 Some("claude-staffai:0.0")
498 );
499 assert_eq!(pane_for(&panes, "??"), None);
500 assert_eq!(pane_for(&panes, ""), None);
501 assert_eq!(pane_for(&panes, "ttys009"), None);
502 }
503
504 #[test]
505 fn missing_snapshot_reads_as_empty() {
506 let dir = temp_dir("missing");
507 assert!(read_snapshot_from(&dir.join("fleet.json")).is_none());
508 }
509}