1use crate::style::Style;
34use serde_json::Value;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Health {
39 Healthy,
41 Unhealthy,
45 Undisclosed,
48 Unreachable,
50}
51
52impl Health {
53 #[must_use]
55 pub fn is_ok(self) -> bool {
56 matches!(self, Health::Healthy | Health::Undisclosed)
57 }
58
59 #[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
75fn ms(us: u64) -> String {
82 format!("{:.1} ms", us as f64 / 1000.0)
83}
84
85fn 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 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
115fn 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 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
167fn 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
189fn 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
206fn 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#[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
242pub 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
395 fn shortening_a_hash_keeps_its_codec() {
396 assert_eq!(short_hash("1e-a7c634769ddf0011223344"), "1e-a7c634769ddf");
397 assert_eq!(short_hash("70-0123456789abcdef0123"), "70-0123456789ab");
400 assert_eq!(short_hash("nodash"), "nodash");
401 }
402
403 #[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 #[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 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}