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}
44
45/// What `relay serve` publishes for local readers.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FleetSnapshot {
48    /// When this snapshot was written (epoch ms).
49    pub updated_ms: u64,
50    /// This machine's relay identity.
51    pub local_worker_id: String,
52    /// Remote peers only. Local sessions are deliberately excluded: a reader on
53    /// this machine already discovers those itself, and including them here
54    /// would list every local session twice.
55    pub workers: Vec<FleetWorker>,
56}
57
58impl FleetWorker {
59    /// Whether this peer has reported recently enough to still be shown.
60    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    /// Peers still reporting. The one place staleness is decided, so a reader
67    /// can't count one set of workers and display another.
68    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
79/// Where the fleet snapshot lives.
80pub 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
91/// Write the snapshot atomically (temp file + rename).
92pub fn write_snapshot(snapshot: &FleetSnapshot) -> io::Result<()> {
93    write_snapshot_to(&relay_dir(), snapshot)
94}
95
96/// Write into an explicit directory. Exists so tests don't touch `$HOME`.
97pub 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
105/// Read the snapshot, or `None` when it's absent, unparseable, or stale enough
106/// that `relay serve` has clearly stopped writing it.
107pub fn read_snapshot() -> Option<FleetSnapshot> {
108    read_snapshot_from(&snapshot_path())
109}
110
111/// Read from an explicit path. Exists so tests don't touch `$HOME`.
112pub 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
122/// The remote sessions a local reader should display, with stale workers
123/// dropped and each entry tagged with its originating worker.
124pub fn remote_sessions() -> Vec<ClaudeSession> {
125    read_snapshot().map(sessions_from).unwrap_or_default()
126}
127
128/// Map a snapshot's live workers into displayable sessions.
129pub 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/// Collects this machine's sessions for advertising to peers, keeping the
142/// per-session parse state that makes repeated collection cheap.
143///
144/// Deliberately a narrower path than `App::refresh`: it discovers, enriches and
145/// reads token usage, but fires no hooks, sends no notifications and records no
146/// history. `relay serve` runs alongside the TUI, and those side effects would
147/// otherwise happen twice.
148///
149/// It is stateful for the same reason `App` is: sessions carry their JSONL read
150/// offset, accumulated tokens and CPU history. Rebuilding them from scratch each
151/// time would re-parse every transcript from byte zero on every collection —
152/// seconds of work on a long session, on the relay's own loop thread.
153#[derive(Default)]
154pub struct LocalSessionCollector {
155    sessions: Vec<ClaudeSession>,
156}
157
158impl LocalSessionCollector {
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    /// Refresh in place and return the current sessions. Surviving PIDs keep
164    /// their previous session object, so JSONL reads stay incremental.
165    pub fn collect(&mut self) -> &[ClaudeSession] {
166        let discovered = crate::discovery::scan_sessions();
167
168        // Same merge as `App::refresh`: reuse the existing session for a PID we
169        // already know, refreshing only the fields that move on their own.
170        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            // Only sessions without a path need resolving; the rest kept theirs.
187            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    /// The sessions in the JSON shape peers parse with
204    /// [`ClaudeSession::from_remote_json`].
205    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
213/// One-shot collection, for callers that run once and exit (`relay fleet`).
214/// Long-running callers should hold a [`LocalSessionCollector`] instead.
215pub fn collect_local_sessions() -> Vec<serde_json::Value> {
216    let mut collector = LocalSessionCollector::new();
217    collector.collect();
218    collector.as_json()
219}
220
221/// Build a snapshot from the local identity and the per-peer state the relay
222/// has accumulated.
223pub 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    /// The shape guard: whatever `to_json_value` emits must survive a heartbeat
279    /// and come back out of `from_remote_json`. A field renamed on one side
280    /// without the other silently empties the fleet view, so assert the
281    /// round trip on a real session rather than a hand-written literal.
282    #[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    /// Counts and rows must come from the same set. A reader that counted
339    /// `workers` but rendered `live_workers` (or the reverse) reported a
340    /// different number of sessions than it printed.
341    #[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    /// The collector must hand the same session object back for a surviving
365    /// PID; a fresh one would restart its JSONL read at byte zero, which is
366    /// what makes repeated collection expensive.
367    #[test]
368    fn collector_keeps_session_state_across_collections() {
369        let mut collector = LocalSessionCollector::new();
370
371        // Seed a session with parse state, as though it had already been read.
372        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        // Exercise the merge directly: a rediscovered PID must reuse the object.
389        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}