Skip to main content

wire/
dash.rs

1//! dash — a read-only observability snapshot across every wire identity on
2//! this machine.
3//!
4//! `collect()` walks the local session store (via [`crate::session::list_sessions`])
5//! and enriches each session with its daemon liveness, relay binding, pinned
6//! peers, and sync recency. It powers `wire dash` (the terminal pane) and the
7//! Mission Control reporter.
8//!
9//! Hard invariants (a naive aggregate over ~270 sessions dies without these):
10//! - **Read-only, no spawn, no kill.** Never starts or stops a daemon.
11//! - **No per-session network I/O.** Reads on-disk state only. Relay `/healthz`
12//!   is probed once per *distinct* relay, and only when explicitly asked
13//!   ([`CollectOpts::probe_relays`]).
14//! - **Explicit paths, never the session-scoped config helpers.**
15//!   [`crate::config::read_trust`] / `read_relay_state` resolve against the
16//!   *current* session's home (WIRE_HOME / session-key context); using them in
17//!   a cross-session walk would read the same session 270 times. Every read
18//!   here is rooted at the session's own `home_dir`.
19
20use serde::Serialize;
21use std::collections::BTreeSet;
22use std::path::Path;
23use std::time::Duration;
24
25/// Liveness of a session's sync daemon, derived from its `daemon.pid` file +
26/// the pid-alive check [`crate::session::list_sessions`] already computes.
27#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28#[serde(tag = "state", rename_all = "snake_case")]
29pub enum DaemonState {
30    /// `daemon.pid` present and the recorded pid is a live process.
31    Running { pid: u32 },
32    /// `daemon.pid` present but the process is gone (a true husk).
33    StalePid { pid: u32 },
34    /// No `daemon.pid` file.
35    None,
36}
37
38impl DaemonState {
39    pub fn is_running(&self) -> bool {
40        matches!(self, DaemonState::Running { .. })
41    }
42}
43
44/// One pinned peer of a session, read from its `trust.json`.
45#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
46pub struct PeerRow {
47    pub handle: String,
48    pub did: String,
49    pub tier: String,
50}
51
52/// A single wire identity on this box + its live-ish state.
53#[derive(Debug, Clone, Serialize)]
54pub struct SessionSnapshot {
55    /// Session key/name (the `by-key/<hash>` dir name, or a named session).
56    pub key: String,
57    pub handle: Option<String>,
58    pub did: Option<String>,
59    /// Short DID fingerprint (the trailing hex segment of the DID).
60    pub fingerprint: Option<String>,
61    pub nickname: Option<String>,
62    pub emoji: Option<String>,
63    /// Primary persona color as `#rrggbb`, for UI/JSON consumers.
64    pub primary_hex: Option<String>,
65    /// Primary persona color as an ANSI-256 index, for terminal glyph coloring.
66    pub ansi256_primary: Option<u8>,
67    pub daemon: DaemonState,
68    pub daemon_version: Option<String>,
69    pub relay_url: Option<String>,
70    pub slot_id: Option<String>,
71    /// Seconds since this session's daemon last synced (mtime of
72    /// `state/wire/last_sync.json`). `None` if it never synced.
73    pub last_sync_age_s: Option<u64>,
74    pub peers: Vec<PeerRow>,
75    pub cwd: Option<String>,
76    /// A running daemon with no real pinned peers — the throwaway
77    /// Claude-session daemon pattern, a candidate for retire. Deliberately NOT
78    /// a "usage" claim: a live daemon heartbeat-syncs regardless of use, so
79    /// peers (not sync-age) are the honest signal. A retired identity is never
80    /// `likely_idle` (it's already handled).
81    pub likely_idle: bool,
82    /// This identity has been retired (`wire retire`) — a `.retired` marker is
83    /// present; the supervisor won't keep a daemon for it.
84    pub retired: bool,
85}
86
87/// `/healthz` result for one distinct relay URL.
88#[derive(Debug, Clone, Serialize)]
89pub struct RelayHealth {
90    pub url: String,
91    pub ok: bool,
92    pub status: Option<u16>,
93    /// True when not probed (probe is opt-in); `ok`/`status` are then unknown.
94    pub unprobed: bool,
95}
96
97/// The whole-machine snapshot — the golden surface `--json` emits and the
98/// Mission Control reporter consumes.
99#[derive(Debug, Clone, Serialize)]
100pub struct DashReport {
101    pub schema: &'static str,
102    pub sessions: Vec<SessionSnapshot>,
103    pub relays: Vec<RelayHealth>,
104}
105
106#[derive(Debug, Clone, Default)]
107pub struct CollectOpts {
108    /// Probe each distinct relay's `/healthz` (one blocking GET per relay,
109    /// 2s timeout). Off by default: `dash` is a local pane; network is opt-in.
110    pub probe_relays: bool,
111}
112
113pub const SCHEMA: &str = "wire-dash-v1";
114
115/// Cap on how much of an on-disk state file we read. Normal trust.json /
116/// relay.json / daemon.pid are well under 1 KB; this bounds the blast radius
117/// of a single corrupt/hostile file across the ~270-session fan-out.
118const MAX_STATE_FILE: u64 = 256 * 1024;
119
120/// Read at most [`MAX_STATE_FILE`] bytes of a file. `None` on any error.
121fn read_capped(path: &Path) -> Option<Vec<u8>> {
122    use std::io::Read;
123    let f = std::fs::File::open(path).ok()?;
124    let mut buf = Vec::new();
125    f.take(MAX_STATE_FILE).read_to_end(&mut buf).ok()?;
126    Some(buf)
127}
128
129/// Extract the short fingerprint from a DID (`did:wire:terra-plain-e6511a52`
130/// → `e6511a52`). The nickname may contain hyphens, so take the final segment.
131/// `None` for a DID with no usable trailing segment.
132pub fn fingerprint_from_did(did: &str) -> Option<String> {
133    let tail = did.rsplit(':').next()?; // "terra-plain-e6511a52"
134    let fp = tail.rsplit('-').next().unwrap_or("");
135    if fp.is_empty() {
136        return None;
137    }
138    Some(fp.chars().take(16).collect())
139}
140
141/// Read a session's pinned peers from `<home>/config/wire/trust.json`,
142/// excluding the session's own identity (`trust.json` lists self as an agent).
143/// Self is matched on DID **or** handle: a corrupt self-entry missing its `did`
144/// must still not surface the session as its own peer.
145pub fn read_peers(home: &Path, own_did: Option<&str>, own_handle: Option<&str>) -> Vec<PeerRow> {
146    let path = home.join("config").join("wire").join("trust.json");
147    let Some(bytes) = read_capped(&path) else {
148        return Vec::new();
149    };
150    let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
151        return Vec::new();
152    };
153    let Some(agents) = v.get("agents").and_then(|a| a.as_object()) else {
154        return Vec::new();
155    };
156    let mut out = Vec::new();
157    for (handle, rec) in agents {
158        let did = rec.get("did").and_then(|d| d.as_str()).unwrap_or("");
159        // Skip the self entry — trust.json always lists the owning identity.
160        // Match on either DID or handle so a `did`-less self entry is still
161        // excluded (else the session lists itself as a peer).
162        if own_did == Some(did) || own_handle == Some(handle.as_str()) {
163            continue;
164        }
165        out.push(PeerRow {
166            handle: handle.clone(),
167            did: did.to_string(),
168            tier: rec
169                .get("tier")
170                .and_then(|t| t.as_str())
171                .unwrap_or("UNTRUSTED")
172                .to_string(),
173        });
174    }
175    out.sort_by(|a, b| a.handle.cmp(&b.handle));
176    out
177}
178
179/// Read `<home>/config/wire/relay.json` → `(relay_url, slot_id)`.
180pub fn read_relay_binding(home: &Path) -> (Option<String>, Option<String>) {
181    let path = home.join("config").join("wire").join("relay.json");
182    let Some(bytes) = read_capped(&path) else {
183        return (None, None);
184    };
185    let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
186        return (None, None);
187    };
188    let sf = v.get("self");
189    let url = sf
190        .and_then(|s| s.get("relay_url"))
191        .and_then(|u| u.as_str())
192        .map(|s| s.to_string());
193    let slot = sf
194        .and_then(|s| s.get("slot_id"))
195        .and_then(|u| u.as_str())
196        .map(|s| s.to_string());
197    (url, slot)
198}
199
200/// Seconds since `<home>/state/wire/last_sync.json` was last written.
201pub fn last_sync_age_s(home: &Path) -> Option<u64> {
202    let path = home.join("state").join("wire").join("last_sync.json");
203    let mtime = std::fs::metadata(&path).ok()?.modified().ok()?;
204    mtime.elapsed().ok().map(|d| d.as_secs())
205}
206
207/// Read the daemon `version` from `<home>/state/wire/daemon.pid`.
208fn read_daemon_version(home: &Path) -> Option<String> {
209    let path = home.join("state").join("wire").join("daemon.pid");
210    let bytes = read_capped(&path)?;
211    let v = serde_json::from_slice::<serde_json::Value>(&bytes).ok()?;
212    v.get("version")
213        .and_then(|s| s.as_str())
214        .map(|s| s.to_string())
215}
216
217/// Build the snapshot for one already-enumerated session. Pure w.r.t. the
218/// network; only reads files under `si.home_dir`.
219fn snapshot_one(si: &crate::session::SessionInfo) -> SessionSnapshot {
220    let home = &si.home_dir;
221    let pid = crate::session::session_daemon_pid(home);
222    let daemon = match (pid, si.daemon_running) {
223        (Some(pid), true) => DaemonState::Running { pid },
224        (Some(pid), false) => DaemonState::StalePid { pid },
225        (None, _) => DaemonState::None,
226    };
227    let peers = read_peers(home, si.did.as_deref(), si.handle.as_deref());
228    let (relay_url, slot_id) = read_relay_binding(home);
229    let (nickname, emoji, primary_hex, ansi256_primary) = match &si.character {
230        Some(c) => (
231            Some(c.nickname.clone()),
232            Some(c.emoji.clone()),
233            Some(c.palette.primary_hex.clone()),
234            Some(c.palette.ansi256_primary),
235        ),
236        None => (None, None, None, None),
237    };
238    let retired = crate::retire::is_retired(home);
239    let likely_idle = daemon.is_running() && peers.is_empty() && !retired;
240    SessionSnapshot {
241        key: si.name.clone(),
242        handle: si.handle.clone(),
243        fingerprint: si.did.as_deref().and_then(fingerprint_from_did),
244        did: si.did.clone(),
245        nickname,
246        emoji,
247        primary_hex,
248        ansi256_primary,
249        daemon,
250        daemon_version: read_daemon_version(home),
251        relay_url,
252        slot_id,
253        last_sync_age_s: last_sync_age_s(home),
254        peers,
255        cwd: si.cwd.clone(),
256        likely_idle,
257        retired,
258    }
259}
260
261fn probe_relay(url: &str) -> RelayHealth {
262    let base = url.trim_end_matches('/');
263    // Defensive: relay_url comes from an on-disk session file. Only probe
264    // http(s), and never follow redirects (a hostile relay could otherwise
265    // bounce the blind probe at an internal address).
266    if !(base.starts_with("http://") || base.starts_with("https://")) {
267        return RelayHealth {
268            url: url.to_string(),
269            ok: false,
270            status: None,
271            unprobed: false,
272        };
273    }
274    let build = reqwest::blocking::Client::builder()
275        .timeout(Duration::from_secs(2))
276        .redirect(reqwest::redirect::Policy::none())
277        .build();
278    let Ok(client) = build else {
279        return RelayHealth {
280            url: url.to_string(),
281            ok: false,
282            status: None,
283            unprobed: false,
284        };
285    };
286    match client.get(format!("{base}/healthz")).send() {
287        Ok(r) => RelayHealth {
288            url: url.to_string(),
289            ok: r.status().is_success(),
290            status: Some(r.status().as_u16()),
291            unprobed: false,
292        },
293        Err(_) => RelayHealth {
294            url: url.to_string(),
295            ok: false,
296            status: None,
297            unprobed: false,
298        },
299    }
300}
301
302/// Snapshot every wire identity on this box. Read-only; never spawns/kills a
303/// daemon; does network I/O only when `opts.probe_relays` is set.
304pub fn collect(opts: &CollectOpts) -> anyhow::Result<DashReport> {
305    let sessions = crate::session::list_sessions()?;
306    let mut snaps = Vec::with_capacity(sessions.len());
307    let mut relay_urls: BTreeSet<String> = BTreeSet::new();
308    for si in &sessions {
309        let snap = snapshot_one(si);
310        if let Some(u) = &snap.relay_url {
311            relay_urls.insert(u.clone());
312        }
313        snaps.push(snap);
314    }
315    // Sort: paired sessions first (most peers), then by daemon liveness,
316    // then name — so the wires that matter float to the top and the idle
317    // throwaways sink.
318    snaps.sort_by(|a, b| {
319        b.peers
320            .len()
321            .cmp(&a.peers.len())
322            .then(b.daemon.is_running().cmp(&a.daemon.is_running()))
323            .then(a.key.cmp(&b.key))
324    });
325    let relays = relay_urls
326        .into_iter()
327        .map(|u| {
328            if opts.probe_relays {
329                probe_relay(&u)
330            } else {
331                RelayHealth {
332                    url: u,
333                    ok: false,
334                    status: None,
335                    unprobed: true,
336                }
337            }
338        })
339        .collect();
340    Ok(DashReport {
341        schema: SCHEMA,
342        sessions: snaps,
343        relays,
344    })
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::fs;
351
352    #[test]
353    fn fingerprint_extracts_trailing_hex() {
354        assert_eq!(
355            fingerprint_from_did("did:wire:terra-plain-e6511a52").as_deref(),
356            Some("e6511a52")
357        );
358        // Multi-hyphen nickname must not confuse the parse.
359        assert_eq!(
360            fingerprint_from_did("did:wire:a-b-c-deadbeef").as_deref(),
361            Some("deadbeef")
362        );
363        assert_eq!(fingerprint_from_did("garbage").as_deref(), Some("garbage"));
364    }
365
366    #[test]
367    fn read_peers_excludes_self_and_reads_tier() {
368        let dir = tempfile::tempdir().unwrap();
369        let cw = dir.path().join("config").join("wire");
370        fs::create_dir_all(&cw).unwrap();
371        fs::write(
372            cw.join("trust.json"),
373            r#"{"agents":{
374                "terra-plain":{"did":"did:wire:terra-plain-e6511a52","tier":"ATTESTED"},
375                "raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"}
376            },"version":1}"#,
377        )
378        .unwrap();
379        let peers = read_peers(
380            dir.path(),
381            Some("did:wire:terra-plain-e6511a52"),
382            Some("terra-plain"),
383        );
384        assert_eq!(peers.len(), 1, "self must be excluded");
385        assert_eq!(peers[0].handle, "raven-kettle");
386        assert_eq!(peers[0].tier, "VERIFIED");
387    }
388
389    #[test]
390    fn read_peers_excludes_self_by_handle_when_did_missing() {
391        // A corrupt self-entry with no `did` must still be excluded via handle.
392        let dir = tempfile::tempdir().unwrap();
393        let cw = dir.path().join("config").join("wire");
394        fs::create_dir_all(&cw).unwrap();
395        fs::write(
396            cw.join("trust.json"),
397            r#"{"agents":{
398                "terra-plain":{"tier":"ATTESTED"},
399                "raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"}
400            },"version":1}"#,
401        )
402        .unwrap();
403        let peers = read_peers(
404            dir.path(),
405            Some("did:wire:terra-plain-e6511a52"),
406            Some("terra-plain"),
407        );
408        assert_eq!(peers.len(), 1, "did-less self entry excluded by handle");
409        assert_eq!(peers[0].handle, "raven-kettle");
410    }
411
412    #[test]
413    fn read_peers_missing_file_is_empty() {
414        let dir = tempfile::tempdir().unwrap();
415        assert!(read_peers(dir.path(), None, None).is_empty());
416    }
417
418    #[test]
419    fn fingerprint_none_for_empty_tail() {
420        assert_eq!(fingerprint_from_did("did:wire:"), None);
421        assert_eq!(fingerprint_from_did(""), None);
422    }
423
424    #[test]
425    fn dash_report_json_shape_is_stable() {
426        // Anti-drift: the wire-dash-v1 golden surface. If a field is renamed
427        // or dropped, this fails — external consumers (Mission Control adapter,
428        // any --json reader) depend on this shape.
429        let report = DashReport {
430            schema: SCHEMA,
431            sessions: vec![SessionSnapshot {
432                key: "k".into(),
433                handle: Some("h".into()),
434                did: Some("did:wire:h-deadbeef".into()),
435                fingerprint: Some("deadbeef".into()),
436                nickname: Some("h".into()),
437                emoji: Some("🦊".into()),
438                primary_hex: Some("#da60a3".into()),
439                ansi256_primary: Some(175),
440                daemon: DaemonState::StalePid { pid: 9 },
441                daemon_version: Some("0.16.0".into()),
442                relay_url: Some("https://wireup.net".into()),
443                slot_id: Some("s".into()),
444                last_sync_age_s: Some(5),
445                peers: vec![],
446                cwd: Some("/tmp/x".into()),
447                likely_idle: false,
448                retired: false,
449            }],
450            relays: vec![],
451        };
452        let v = serde_json::to_value(&report).unwrap();
453        assert_eq!(v["schema"], "wire-dash-v1");
454        let s = &v["sessions"][0];
455        for key in [
456            "key",
457            "handle",
458            "did",
459            "fingerprint",
460            "nickname",
461            "emoji",
462            "primary_hex",
463            "ansi256_primary",
464            "daemon",
465            "daemon_version",
466            "relay_url",
467            "slot_id",
468            "last_sync_age_s",
469            "peers",
470            "cwd",
471            "likely_idle",
472            "retired",
473        ] {
474            assert!(s.get(key).is_some(), "missing golden field: {key}");
475        }
476        // StalePid must serialize as the tagged `stale_pid` a consumer keys on.
477        assert_eq!(s["daemon"]["state"], "stale_pid");
478        assert_eq!(s["daemon"]["pid"], 9);
479    }
480
481    #[test]
482    fn read_relay_binding_parses_self() {
483        let dir = tempfile::tempdir().unwrap();
484        let cw = dir.path().join("config").join("wire");
485        fs::create_dir_all(&cw).unwrap();
486        fs::write(
487            cw.join("relay.json"),
488            r#"{"self":{"relay_url":"https://wireup.net","slot_id":"abc123"},"peers":{}}"#,
489        )
490        .unwrap();
491        let (url, slot) = read_relay_binding(dir.path());
492        assert_eq!(url.as_deref(), Some("https://wireup.net"));
493        assert_eq!(slot.as_deref(), Some("abc123"));
494    }
495
496    #[test]
497    fn daemon_state_serializes_with_tag() {
498        let j = serde_json::to_value(DaemonState::Running { pid: 42 }).unwrap();
499        assert_eq!(j["state"], "running");
500        assert_eq!(j["pid"], 42);
501        let n = serde_json::to_value(DaemonState::None).unwrap();
502        assert_eq!(n["state"], "none");
503    }
504}