Skip to main content

claudectl_core/
fleet.rs

1//! Fleet snapshot — the cluster-wide session view, shared on disk.
2//!
3//! `claudectl relay serve` owns the write side: on every tick it records the
4//! sessions each connected peer advertised in its heartbeat, plus the sessions
5//! running on this machine. Readers (the TUI, `claudectl --json`) pick the file
6//! up on their own refresh.
7//!
8//! A file rather than an HTTP call, because the relay and the TUI are separate
9//! processes on the same machine and the peers panel already reads peer state
10//! off disk the same way. That keeps the fleet view working with no port or
11//! token to configure first; the coordinator's HTTP API stays available for
12//! dashboards outside the machine.
13//!
14//! Writes go through a temp file + `rename` so a reader mid-tick never sees a
15//! half-formed snapshot.
16
17use std::fs;
18use std::io;
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22
23use crate::session::ClaudeSession;
24
25/// Workers whose last heartbeat is older than this are dropped on read. Three
26/// times the default 30s heartbeat interval, matching the mesh's own expiry so
27/// a peer doesn't linger in the fleet view after it stops reporting.
28pub const WORKER_STALE_SECS: u64 = 90;
29
30/// A snapshot older than this means `relay serve` is no longer running, so the
31/// whole file is ignored rather than showing a frozen fleet.
32pub const SNAPSHOT_STALE_SECS: u64 = 120;
33
34/// One peer's contribution to the fleet view.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct FleetWorker {
37    /// The peer's relay identity, used as the display prefix.
38    pub worker_id: String,
39    /// Sessions as serialized by [`ClaudeSession::to_json_value`] on that peer.
40    pub sessions: Vec<serde_json::Value>,
41    /// When that peer's heartbeat last arrived (epoch ms).
42    pub updated_ms: u64,
43    /// The host this machine reaches that peer at, from the relay connection —
44    /// what the TUI sshes to when attaching to one of its sessions. Defaulted
45    /// so a `fleet.json` written before this field existed still parses.
46    #[serde(default)]
47    pub host: Option<String>,
48}
49
50/// What `relay serve` publishes for local readers.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FleetSnapshot {
53    /// When this snapshot was written (epoch ms).
54    pub updated_ms: u64,
55    /// This machine's relay identity.
56    pub local_worker_id: String,
57    /// Remote peers only. Local sessions are deliberately excluded: a reader on
58    /// this machine already discovers those itself, and including them here
59    /// would list every local session twice.
60    pub workers: Vec<FleetWorker>,
61}
62
63impl FleetWorker {
64    /// Whether this peer has reported recently enough to still be shown.
65    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    /// Peers still reporting. The one place staleness is decided, so a reader
72    /// can't count one set of workers and display another.
73    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
84/// Where the fleet snapshot lives.
85pub 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
96/// Write the snapshot atomically (temp file + rename).
97pub fn write_snapshot(snapshot: &FleetSnapshot) -> io::Result<()> {
98    write_snapshot_to(&relay_dir(), snapshot)
99}
100
101/// Write into an explicit directory. Exists so tests don't touch `$HOME`.
102pub 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
110/// Read the snapshot, or `None` when it's absent, unparseable, or stale enough
111/// that `relay serve` has clearly stopped writing it.
112pub fn read_snapshot() -> Option<FleetSnapshot> {
113    read_snapshot_from(&snapshot_path())
114}
115
116/// Read from an explicit path. Exists so tests don't touch `$HOME`.
117pub 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
127/// The remote sessions a local reader should display, with stale workers
128/// dropped and each entry tagged with its originating worker.
129pub fn remote_sessions() -> Vec<ClaudeSession> {
130    read_snapshot().map(sessions_from).unwrap_or_default()
131}
132
133/// Map a snapshot's live workers into displayable sessions.
134pub 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/// Collects this machine's sessions for advertising to peers, keeping the
148/// per-session parse state that makes repeated collection cheap.
149///
150/// Deliberately a narrower path than `App::refresh`: it discovers, enriches and
151/// reads token usage, but fires no hooks, sends no notifications and records no
152/// history. `relay serve` runs alongside the TUI, and those side effects would
153/// otherwise happen twice.
154///
155/// It is stateful for the same reason `App` is: sessions carry their JSONL read
156/// offset, accumulated tokens and CPU history. Rebuilding them from scratch each
157/// time would re-parse every transcript from byte zero on every collection —
158/// seconds of work on a long session, on the relay's own loop thread.
159#[derive(Default)]
160pub struct LocalSessionCollector {
161    sessions: Vec<ClaudeSession>,
162}
163
164impl LocalSessionCollector {
165    pub fn new() -> Self {
166        Self::default()
167    }
168
169    /// Refresh in place and return the current sessions. Surviving PIDs keep
170    /// their previous session object, so JSONL reads stay incremental.
171    pub fn collect(&mut self) -> &[ClaudeSession] {
172        let discovered = crate::discovery::scan_sessions();
173
174        // Same merge as `App::refresh`: reuse the existing session for a PID we
175        // already know, refreshing only the fields that move on their own.
176        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            // Only sessions without a path need resolving; the rest kept theirs.
193            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    /// The sessions in the JSON shape peers parse with
214    /// [`ClaudeSession::from_remote_json`].
215    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
230/// The tmux target for a session's TTY. `ps` may report `s002` or `ttys002`
231/// for the same terminal, so an exact key miss falls back to a suffix match.
232fn 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
245/// This machine's login name, which a peer needs to ssh in as. launchd sets
246/// `USER` for a user agent; `HOME`'s last component is the fallback.
247fn 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
260/// One-shot collection, for callers that run once and exit (`relay fleet`).
261/// Long-running callers should hold a [`LocalSessionCollector`] instead.
262pub fn collect_local_sessions() -> Vec<serde_json::Value> {
263    let mut collector = LocalSessionCollector::new();
264    collector.collect();
265    collector.as_json()
266}
267
268/// Build a snapshot from the local identity and the per-peer state the relay
269/// has accumulated.
270pub 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    /// The shape guard: whatever `to_json_value` emits must survive a heartbeat
327    /// and come back out of `from_remote_json`. A field renamed on one side
328    /// without the other silently empties the fleet view, so assert the
329    /// round trip on a real session rather than a hand-written literal.
330    #[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    /// Counts and rows must come from the same set. A reader that counted
389    /// `workers` but rendered `live_workers` (or the reverse) reported a
390    /// different number of sessions than it printed.
391    #[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    /// The collector must hand the same session object back for a surviving
417    /// PID; a fresh one would restart its JSONL read at byte zero, which is
418    /// what makes repeated collection expensive.
419    #[test]
420    fn collector_keeps_session_state_across_collections() {
421        let mut collector = LocalSessionCollector::new();
422
423        // Seed a session with parse state, as though it had already been read.
424        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        // Exercise the merge directly: a rediscovered PID must reuse the object.
441        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}