Skip to main content

choir_cli/
node.rs

1//! `choir node` — the operator half of the command line.
2//!
3//! Everything an *agent* does to a node was already here; everything an
4//! *operator* does to one lived in `choirctl`, a zsh script with a
5//! machine's habits baked into it. This module is where that half comes
6//! back, in the language the rest of the binary is written in.
7//!
8//! # Why this is not a wrapper
9//!
10//! `choirctl status` answers its question by spawning about
11//! twenty-eight processes: `curl` twice, `launchctl`, `ps`, `git`,
12//! `date`, `awk` three times, `head` five times, and `python3` twice to
13//! parse JSON that a shell cannot. Each is a fork, an exec, a dynamic
14//! link and an interpreter start, and two of them boot Python to read
15//! four numbers out of a document this binary already deserializes.
16//!
17//! Here the same report is two HTTP requests and a fold over the
18//! response. `curl` stays — the workspace has no HTTP client crate, on
19//! purpose, and that is one process per request rather than one per
20//! *field*. Nothing else forks.
21//!
22//! # Examples
23//!
24//! ```
25//! use choir_cli::node::Health;
26//!
27//! // A node that answers 503 on `/healthz` is reporting its own
28//! // durability failure, and that is never softened into a warning.
29//! assert!(!Health::Unhealthy.is_ok());
30//! assert_eq!(Health::Unhealthy.exit_code(), 1);
31//! ```
32
33use crate::style::Style;
34use serde_json::Value;
35
36/// What `/healthz` said.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Health {
39    /// The node answered 200: its durable append path is working.
40    Healthy,
41    /// The node answered 503. It is up, and it is telling us its own
42    /// durability check has failed — the state that exits the daemon 75
43    /// for a supervisor.
44    Unhealthy,
45    /// The node is up but would not say, because this credential may
46    /// not ask.
47    Undisclosed,
48    /// Nothing answered.
49    Unreachable,
50}
51
52impl Health {
53    /// Whether this is a node an operator can stop worrying about.
54    #[must_use]
55    pub fn is_ok(self) -> bool {
56        matches!(self, Health::Healthy | Health::Undisclosed)
57    }
58
59    /// The process exit code this health implies.
60    #[must_use]
61    pub fn exit_code(self) -> i32 {
62        i32::from(!self.is_ok())
63    }
64
65    fn paint(self, style: Style) -> String {
66        match self {
67            Health::Healthy => style.green("healthy"),
68            Health::Unhealthy => style.red("UNHEALTHY — durable append is failing"),
69            Health::Undisclosed => style.dim("not disclosed to this credential"),
70            Health::Unreachable => style.red("did not answer"),
71        }
72    }
73}
74
75/// Microseconds as milliseconds, to one decimal.
76///
77/// The node reports microseconds because that is the resolution it
78/// measures at; the gate is stated in milliseconds and so is every
79/// conversation about it. Converting at the edge rather than in the
80/// node keeps the wire honest and the report readable.
81fn ms(us: u64) -> String {
82    format!("{:.1} ms", us as f64 / 1000.0)
83}
84
85/// The `build` line: which commit is actually serving.
86///
87/// This is the question "did my rebuild reach the running process"
88/// actually asks, and it is not answerable from the checkout — a
89/// supervisor can restart from a cached job definition and keep running
90/// the old binary while every file on disk says otherwise.
91fn build_line(view: &Value, style: Style) -> String {
92    let build = &view["build"];
93    let Some(commit) = build["commit"].as_str() else {
94        return style.dim("not disclosed");
95    };
96    if commit == "unknown" || commit.is_empty() {
97        return format!(
98            "{} — rebuild to make this checkable",
99            style.red("UNSTAMPED")
100        );
101    }
102    let short: String = commit.chars().take(12).collect();
103    let source = build["source"].as_str().unwrap_or("?");
104    // `dirty` is only a claim when the stamp came from git; a stamp from
105    // anywhere else cannot have looked at a working tree, and printing
106    // "clean" on its word would be inventing an assurance.
107    let tree = match (build["dirty_trusted"].as_bool(), build["dirty"].as_bool()) {
108        (Some(true), Some(true)) => style.red("dirty"),
109        (Some(true), Some(false)) => "clean".to_string(),
110        _ => style.dim("tree unknown"),
111    };
112    format!("{short}  {source}, {tree}")
113}
114
115/// The `lag` line: is the sequencer meeting the Phase-0 gate on real
116/// traffic, rather than in the test suite.
117///
118/// Both percentiles, never one. `decision` is the gate as written and
119/// `durable` is what a submitter actually waits out; reporting only the
120/// first is how a node with a slow disk looks fast.
121///
122/// The section is `Disclosure::NodeWide`, so a per-repo credential is
123/// handed a view with no `sequencer_lag` at all. That is reported as
124/// withheld and never as an idle node — the two look identical in the
125/// JSON and mean opposite things to an operator.
126fn lag_line(view: &Value, style: Style) -> String {
127    let lag = &view["sequencer_lag"];
128    if lag.is_null() {
129        return style.dim("not disclosed to this credential");
130    }
131    let Some(ops) = lag["observed_ops"].as_u64() else {
132        return style.dim("not reported by this node");
133    };
134    if ops == 0 {
135        return style.dim("no ops measured since start");
136    }
137    let gate = lag["gate_us"].as_u64().unwrap_or(0);
138    let decision = lag["decision"]["p99_us"].as_u64().unwrap_or(0);
139    let durable = lag["durable"]["p99_us"].as_u64().unwrap_or(0);
140    let breaches = lag["durable"]["breaches"].as_u64().unwrap_or(0)
141        + lag["decision"]["breaches"].as_u64().unwrap_or(0);
142    let mut line = format!(
143        "{ops} ops · decision p99 {} · durable p99 {} · gate {}",
144        ms(decision),
145        ms(durable),
146        ms(gate)
147    );
148    if breaches > 0 {
149        line.push_str(&format!(
150            " · {}",
151            style.red(&format!("{breaches} BREACHES"))
152        ));
153    }
154    // A lag log that cannot be written is a measurement this node is
155    // silently not keeping. Worth a line: the absence of breaches in a
156    // log nobody could write is not evidence of none.
157    if lag["log_write_failures"].as_u64().unwrap_or(0) > 0 {
158        let why = lag["log_error"].as_str().unwrap_or("unknown");
159        line.push_str(&format!(
160            " · {}",
161            style.red(&format!("lag log unwritable: {why}"))
162        ));
163    }
164    line
165}
166
167/// How much this node is holding, as far as this credential may see.
168///
169/// Qualified rather than absolute on purpose: `refs`, `reviews` and
170/// `workspaces` are all `Disclosure::PerRepo`, so these are counts of
171/// the visible subset. Printing them as totals would make a narrowly
172/// scoped credential look at an almost-empty node.
173fn holds_line(view: &Value) -> String {
174    let count = |name: &str| -> String {
175        match &view[name] {
176            Value::Object(map) => map.len().to_string(),
177            Value::Array(rows) => rows.len().to_string(),
178            _ => "—".to_string(),
179        }
180    };
181    format!(
182        "{} refs · {} reviews · {} workspaces  (visible to this credential)",
183        count("refs"),
184        count("reviews"),
185        count("workspaces")
186    )
187}
188
189/// Shortens a `ContentHash` hex without destroying what it says.
190///
191/// The wire form is `<codec byte>-<digest>` (invariant 2: a hash always
192/// carries its codec, so a hash-function change stays additive and a git
193/// oid never pretends to be BLAKE3). Truncating the whole string eats
194/// into the digest by however many characters the prefix took, which
195/// prints a different number of real bytes for a BLAKE3 hash than for a
196/// git oid. Keep the prefix whole and shorten only the digest.
197fn short_hash(hex: &str) -> String {
198    match hex.split_once('-') {
199        Some((codec, digest)) => {
200            format!("{codec}-{}", digest.chars().take(12).collect::<String>())
201        }
202        None => hex.chars().take(12).collect(),
203    }
204}
205
206/// The sequencer's own position.
207fn seq_line(view: &Value, style: Style) -> String {
208    let log = &view["log"];
209    let next = log["next_seq"].as_u64();
210    let head = log["head"].as_str().map(short_hash);
211    match (next, head) {
212        (Some(next), Some(head)) => format!("seq {next} · head {head}"),
213        (Some(next), None) => format!("seq {next} · {}", style.dim("empty log")),
214        _ => style.dim("not disclosed"),
215    }
216}
217
218/// The whole report, as `choir node status` prints it.
219///
220/// Takes the two responses rather than fetching them, so the rendering
221/// is a pure function of what the node said and can be tested without a
222/// node.
223#[must_use]
224pub fn status_report(api: &str, health: Health, view: &Value, style: Style) -> String {
225    let rows: Vec<(&str, String)> = vec![
226        ("node", api.to_string()),
227        ("health", health.paint(style)),
228        ("build", build_line(view, style)),
229        ("position", seq_line(view, style)),
230        ("lag", lag_line(view, style)),
231        ("holds", holds_line(view)),
232    ];
233    let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
234    let mut out = String::new();
235    for (key, value) in rows {
236        let key = format!("{key:width$}");
237        out.push_str(&format!("  {}  {value}\n", style.dim(&key)));
238    }
239    out
240}
241
242/// `choir node status [<api>]`.
243///
244/// Two requests: `/healthz`, which performs the node's own durability
245/// check, and `/api/view`, which carries everything else. They are
246/// separate because they answer different questions and one can be
247/// withheld without the other — a credential that may not read the view
248/// can still be told the node is alive.
249///
250/// # Errors
251///
252/// Returns a description when the node cannot be reached or its view
253/// does not parse.
254pub fn status(api: &str, auth: Option<&std::path::Path>) -> Result<(Health, Value), String> {
255    let client = crate::mcp::HttpClient::new(api, auth, None)?;
256    let health = match client.get("/healthz") {
257        Ok((200, _)) => Health::Healthy,
258        Ok((503, _)) => Health::Unhealthy,
259        Ok((401 | 403, _)) => Health::Undisclosed,
260        Ok(_) => Health::Unreachable,
261        Err(error) => return Err(error),
262    };
263    let (code, body) = client.get("/api/view")?;
264    if !(200..300).contains(&code) {
265        return Err(format!("GET /api/view returned {code}: {body}"));
266    }
267    let view: Value =
268        serde_json::from_str(&body).map_err(|error| format!("the view did not parse: {error}"))?;
269    Ok((health, view))
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn plain() -> Style {
277        Style::plain()
278    }
279
280    /// A view with everything present, as a node-wide credential sees it.
281    fn full_view() -> Value {
282        serde_json::json!({
283            "log": { "next_seq": 41, "head": "1e-a7c634769ddf0011223344556677" },
284            "build": { "commit": "3b95afe0c6d0aaaabbbbccccddddeeeeffff0000",
285                       "source": "git", "dirty": false, "dirty_trusted": true },
286            "sequencer_lag": {
287                "gate_us": 100_000, "observed_ops": 412,
288                "decision": { "p50_us": 512, "p99_us": 604, "breaches": 0 },
289                "durable":  { "p50_us": 4096, "p99_us": 8400, "breaches": 0 },
290                "log_write_failures": 0,
291            },
292            "refs": { "a": 1, "b": 2 },
293            "reviews": {},
294            "workspaces": { "w": 1 },
295        })
296    }
297
298    /// The trap this command exists to avoid.
299    ///
300    /// `sequencer_lag` is `Disclosure::NodeWide`, so a per-repo
301    /// credential is handed a view with the section absent. "Absent"
302    /// and "idle" are the same JSON and opposite facts, and a status
303    /// line that renders the first as the second tells an operator
304    /// their sequencer is quiet when they simply may not look.
305    #[test]
306    fn a_withheld_lag_section_says_withheld_not_idle() {
307        let mut view = full_view();
308        view.as_object_mut()
309            .expect("object")
310            .remove("sequencer_lag");
311        let out = status_report("http://n", Health::Healthy, &view, plain());
312        assert!(out.contains("not disclosed"), "{out}");
313        assert!(
314            !out.contains("no ops"),
315            "withheld must not read as idle:\n{out}"
316        );
317    }
318
319    /// A node that is genuinely idle says so, and differently.
320    #[test]
321    fn an_idle_node_is_distinguishable_from_a_withheld_one() {
322        let mut view = full_view();
323        view["sequencer_lag"]["observed_ops"] = serde_json::json!(0);
324        let out = status_report("http://n", Health::Healthy, &view, plain());
325        assert!(out.contains("no ops measured since start"), "{out}");
326        assert!(!out.contains("not disclosed"), "{out}");
327    }
328
329    /// Both percentiles, always.
330    ///
331    /// `decision` is the Phase-0 gate as written; `durable` is what a
332    /// submitter actually waits out. A node with a slow disk looks fast
333    /// if only the first is printed.
334    #[test]
335    fn both_latency_forms_are_reported_with_the_gate() {
336        let out = status_report("http://n", Health::Healthy, &full_view(), plain());
337        assert!(out.contains("decision p99 0.6 ms"), "{out}");
338        assert!(out.contains("durable p99 8.4 ms"), "{out}");
339        assert!(
340            out.contains("gate 100.0 ms"),
341            "the gate is the comparison:\n{out}"
342        );
343    }
344
345    /// Breaches are never quiet.
346    #[test]
347    fn gate_breaches_are_named() {
348        let mut view = full_view();
349        view["sequencer_lag"]["durable"]["breaches"] = serde_json::json!(3);
350        let out = status_report("http://n", Health::Healthy, &view, plain());
351        assert!(out.contains("3 BREACHES"), "{out}");
352    }
353
354    /// A lag log that cannot be written is a measurement not being kept,
355    /// and the absence of breaches in it is not evidence of none.
356    #[test]
357    fn an_unwritable_lag_log_is_reported() {
358        let mut view = full_view();
359        view["sequencer_lag"]["log_write_failures"] = serde_json::json!(2);
360        view["sequencer_lag"]["log_error"] = serde_json::json!("permission denied");
361        let out = status_report("http://n", Health::Healthy, &view, plain());
362        assert!(
363            out.contains("lag log unwritable: permission denied"),
364            "{out}"
365        );
366    }
367
368    /// An unstamped binary is called out rather than printed as a commit
369    /// called "unknown".
370    #[test]
371    fn an_unstamped_build_says_so() {
372        let mut view = full_view();
373        view["build"]["commit"] = serde_json::json!("unknown");
374        let out = status_report("http://n", Health::Healthy, &view, plain());
375        assert!(out.contains("UNSTAMPED"), "{out}");
376    }
377
378    /// `dirty` is only a claim when the stamp came from git.
379    ///
380    /// A stamp from anywhere else never looked at a working tree, so
381    /// printing "clean" on its word invents an assurance nobody made.
382    #[test]
383    fn a_dirty_flag_from_an_untrusted_stamp_is_not_believed() {
384        let mut view = full_view();
385        view["build"]["source"] = serde_json::json!("env");
386        view["build"]["dirty_trusted"] = serde_json::json!(false);
387        let out = status_report("http://n", Health::Healthy, &view, plain());
388        assert!(out.contains("tree unknown"), "{out}");
389        assert!(!out.contains("clean"), "{out}");
390    }
391
392    /// Shortening a hash keeps its codec, because the codec is what says
393    /// which hash function produced the digest (invariant 2).
394    #[test]
395    fn shortening_a_hash_keeps_its_codec() {
396        assert_eq!(short_hash("1e-a7c634769ddf0011223344"), "1e-a7c634769ddf");
397        // A git oid carries a different codec and must stay
398        // distinguishable from a BLAKE3 digest at a glance.
399        assert_eq!(short_hash("70-0123456789abcdef0123"), "70-0123456789ab");
400        assert_eq!(short_hash("nodash"), "nodash");
401    }
402
403    /// Counts are stated as what this credential can see.
404    ///
405    /// `refs`, `reviews` and `workspaces` are `Disclosure::PerRepo`, so
406    /// an unqualified total would make a narrowly scoped credential look
407    /// at an almost-empty node.
408    #[test]
409    fn counts_are_qualified_by_what_the_credential_sees() {
410        let out = status_report("http://n", Health::Healthy, &full_view(), plain());
411        assert!(out.contains("2 refs"), "{out}");
412        assert!(out.contains("visible to this credential"), "{out}");
413    }
414
415    /// 503 on `/healthz` is the node reporting its own durability
416    /// failure — the state that exits the daemon 75 for a supervisor.
417    /// It is the one answer this command must not soften.
418    #[test]
419    fn an_unhealthy_node_fails_the_command() {
420        assert_eq!(Health::Unhealthy.exit_code(), 1);
421        assert_eq!(Health::Unreachable.exit_code(), 1);
422        assert_eq!(Health::Healthy.exit_code(), 0);
423        // Undisclosed is a node that is up and a credential that may not
424        // ask. Not knowing is not the same as being broken.
425        assert_eq!(Health::Undisclosed.exit_code(), 0);
426        let out = status_report("http://n", Health::Unhealthy, &full_view(), plain());
427        assert!(out.contains("durable append is failing"), "{out}");
428    }
429}