Skip to main content

majordomus_cli/capability/builtin/
health.rs

1//! The `health` module: whether what this process serves is healthy, answered by the
2//! engines that already decide it rather than by checks written a second time here.
3//!
4//! The module is `health` and not `system` because the benchmark projection reserves
5//! `system` for the transports' own targets (`system.http.index`, `system.mcp.ping`) and
6//! buckets its coverage by that name. A capability module called `system` would have its
7//! lines tallied as transport targets and silently vanish from the per-transport
8//! denominators; `reserved_namespace_is_not_a_module` holds that shut.
9//!
10//! Every check delegates: the index's own diagnostics say whether the layer read cleanly,
11//! the benchmark projection's coverage says whether every executable capability is timed,
12//! `generate --check` — the same comparison the command runs — says whether the committed
13//! projections are stale, and [`crate::capability::builtin::server::standing_at`] — the
14//! reading `server.status` answers from — says where the shared server of this checkout
15//! stands. A check that computed its own verdict would be a second opinion, and two
16//! opinions drift.
17//!
18//! # Four readings of "ready", and why none of them is this one
19//!
20//! This crate answers "ready" four times, about four different subjects. The full table,
21//! with what asks each one, is in `docs/MCP.md`; in short:
22//!
23//! - [`crate::http::Served::ready`] — is a *surface's* producer's output on disk? Per
24//!   surface, and what the home page renders as `not built`.
25//! - [`Readiness`] (`health.ready`, `GET /api/v1/ready`) — can *this process* answer a
26//!   request? Local initialisation only, never a dependency: a readiness probe that
27//!   contacts another service fails a deployment for something that is not this process.
28//! - [`crate::environment::ServiceAvailability`] — does *anything* accept a connection at
29//!   the address the lease published? One bounded TCP connect, no DNS, because it runs on
30//!   a shell prompt.
31//! - [`crate::capability::builtin::server::ServerStanding`] — is what answers at that
32//!   address *current*: this checkout's server, from the file on disk, at this version?
33//!
34//! The `server` check below is the fourth, and it is the only one of them that a health
35//! report should carry: the first is per surface, the second is the report's own caller,
36//! and the third cannot tell a live server from a socket somebody else holds.
37
38use std::collections::BTreeMap;
39
40use schemars::JsonSchema;
41use serde::{Deserialize, Serialize};
42
43use crate::bench::{BenchmarkProjection, Coverage};
44use crate::capability::handler::{CapabilityError, Context};
45use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
46use crate::capability::module::ModuleDescriptor;
47use crate::capability::CachePolicy;
48use crate::generate;
49use crate::git::GitState;
50use crate::index::State;
51use crate::model::Severity;
52use crate::{capability, module};
53
54use super::server::{self, ServerStanding};
55use super::{get, Empty};
56
57/// The URI under which `system.health` is read as an MCP resource.
58pub const HEALTH_URI: &str = "majordomus://health";
59
60/// Where one dimension of the system stands. Ordered by severity, so the worst check
61/// decides the whole.
62#[derive(
63    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
64)]
65#[serde(rename_all = "snake_case")]
66pub enum HealthStatus {
67    /// The engine that decides this dimension is satisfied.
68    Ok,
69    /// Nothing is broken and something is worth looking at.
70    Warn,
71    /// The engine that decides this dimension is not satisfied.
72    Fail,
73    /// This process cannot decide it, and says so rather than reporting `ok`.
74    Unknown,
75}
76
77impl HealthStatus {
78    /// The word as serialised.
79    ///
80    /// ```
81    /// use majordomus_cli::capability::builtin::health::HealthStatus;
82    /// assert_eq!(HealthStatus::Warn.as_str(), "warn");
83    /// ```
84    pub fn as_str(self) -> &'static str {
85        match self {
86            HealthStatus::Ok => "ok",
87            HealthStatus::Warn => "warn",
88            HealthStatus::Fail => "fail",
89            HealthStatus::Unknown => "unknown",
90        }
91    }
92
93    /// The worse of two: the whole is as good as its worst check, and `unknown` is worse
94    /// than `warn` because an undecided dimension is not a healthy one.
95    ///
96    /// ```
97    /// use majordomus_cli::capability::builtin::health::HealthStatus::*;
98    /// assert_eq!(Ok.worse(Warn), Warn);
99    /// assert_eq!(Fail.worse(Unknown), Unknown);
100    /// ```
101    pub fn worse(self, other: Self) -> Self {
102        if other > self {
103            other
104        } else {
105            self
106        }
107    }
108}
109
110/// The dimensions that describe the process serving a checkout and not the repository: the
111/// shared server, its peer board and its mesh runtime. Only a process holding the checkout's
112/// lease decides the last two, so in any other they are `unknown` by construction, never by
113/// a fault of the repository.
114///
115/// ```
116/// use majordomus_cli::capability::builtin::health::PROCESS_DIMENSIONS;
117/// assert!(PROCESS_DIMENSIONS.contains(&"mesh"));
118/// assert!(!PROCESS_DIMENSIONS.contains(&"layer"));
119/// ```
120pub const PROCESS_DIMENSIONS: [&str; 3] = ["server", "peers", "mesh"];
121
122/// The worst status among the checks that describe the repository
123/// (every one whose id is not in [`PROCESS_DIMENSIONS`]); `ok` when there is none.
124///
125/// ```
126/// use majordomus_cli::capability::builtin::health::{repository_status, HealthCheck, HealthStatus};
127/// let check = |id: &str, status| HealthCheck {
128///     id: id.into(),
129///     title: id.into(),
130///     status,
131///     detail: String::new(),
132///     decided_by: String::new(),
133///     evidence: Vec::new(),
134///     findings: Vec::new(),
135/// };
136/// let checks = [check("layer", HealthStatus::Warn), check("mesh", HealthStatus::Unknown)];
137/// assert_eq!(repository_status(&checks), HealthStatus::Warn);
138/// ```
139pub fn repository_status(checks: &[HealthCheck]) -> HealthStatus {
140    checks
141        .iter()
142        .filter(|c| !PROCESS_DIMENSIONS.contains(&c.id.as_str()))
143        .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status))
144}
145
146/// One dimension of the system, decided by one engine, with what a reader needs to check
147/// it themselves.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149pub struct HealthCheck {
150    /// A stable id, `[a-z][a-z0-9-]*`.
151    pub id: String,
152    /// The short name.
153    pub title: String,
154    /// Where it stands.
155    pub status: HealthStatus,
156    /// One line: what the engine decided and on what.
157    pub detail: String,
158    /// The engine that decided it, named so the reader knows what to fix.
159    pub decided_by: String,
160    /// What reproduces the verdict: a command, or a path.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub evidence: Vec<String>,
163    /// Findings behind the verdict, when the engine produced any: the diagnostic
164    /// messages, the stale files, the missing coverage lines.
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub findings: Vec<String>,
167}
168
169/// The health of what this process serves.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
171pub struct Health {
172    /// The worst check's status.
173    pub status: HealthStatus,
174    /// The worst status among the dimensions that describe the repository at this commit:
175    /// every check but the ones in [`PROCESS_DIMENSIONS`]. A process that is no server —
176    /// the command line, or the reader of a published snapshot (ADR 0141) — cannot decide
177    /// who is attached or what the mesh runtime did, and says `unknown` for them; this is
178    /// the verdict that is the same whichever process read the repository, and it is what a
179    /// publication shows first.
180    pub repository: HealthStatus,
181    /// How many checks stand where, by status word.
182    pub tallies: BTreeMap<String, usize>,
183    /// Every dimension, in a stable order.
184    pub checks: Vec<HealthCheck>,
185    /// When the state a check decided on was read, by check id: the freshness contract
186    /// ([`crate::index::AnswerObservation`]). A check absent here decided live, during this call.
187    /// `layer` and `git` are here because they judge the picture the index took, which is
188    /// as old as the index.
189    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
190    pub observed: BTreeMap<String, crate::index::AnswerObservation>,
191}
192
193/// The answer to "is this process alive": the cheapest true thing this executable can
194/// say about itself. Deliberately not a summary of anything — a probe that grew an
195/// opinion would stop reporting the one fact the platform polls it for.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct Liveness {
198    /// Always `true`. A process that could not answer would not answer at all, which is
199    /// the signal.
200    pub alive: bool,
201    /// This executable's version, so a rolling deployment can tell which build answered.
202    pub version: String,
203    /// The commit this executable was built from, in full, or `unknown` when the build could
204    /// not name one. A version is what a build calls itself and two revisions share it; this
205    /// is what a deployment is checked against. Never omitted: an absent field would read as
206    /// a server too old to say, and `unknown` is a different, truthful answer.
207    pub commit: String,
208    /// Whether that commit's tree carried uncommitted changes when this executable was built;
209    /// `null` when the build did not know.
210    pub dirty: Option<bool>,
211}
212
213/// The answer to "can this process serve traffic": the local initialisation a request
214/// would need, and nothing beyond this process. A readiness check that grew a dependency
215/// probe fails a deployment because an unrelated service is down.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
217pub struct Readiness {
218    /// Whether every part below is in place.
219    pub ready: bool,
220    /// This executable's version.
221    pub version: String,
222    /// The commit this executable was built from, or `unknown`; the same value `health.live`
223    /// answers.
224    pub commit: String,
225    /// Whether the build's tree was dirty; `null` when the build did not know.
226    pub dirty: Option<bool>,
227    /// How many capabilities the registry holds; zero would mean nothing to serve.
228    pub capabilities: usize,
229    /// How many objects the index holds. Read from the index this process built at
230    /// start-up: it is already in memory, and reading it walks nothing.
231    pub objects: usize,
232    /// Whether the layer read cleanly. A degraded layer is still served — the diagnostics
233    /// are the point — so this reports rather than refuses.
234    pub layer: HealthStatus,
235    /// Why this process is serving code that is no longer on disk: the executable it was
236    /// started from has been replaced or removed since. Present, `ready` is false — a server
237    /// answering with yesterday's code says so in the place it says it is ready, to a client
238    /// holding nothing but this answer (I1502).
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub stale: Option<String>,
241    /// Every reason this answer is not a plain yes, as `code: words`. `lease_lost` and
242    /// `stale` make `ready` false: another process holds this checkout's lease, or this one
243    /// serves code that is gone. `layer_degraded` does not: the layer is served with its
244    /// diagnostics, and a client decides whether that is enough.
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub reasons: Vec<String>,
247}
248
249/// Liveness. No filesystem, no index traversal, no network: two fields this process can
250/// answer with while doing anything else.
251fn liveness(_: &Context, _: Empty) -> Result<Liveness, CapabilityError> {
252    Ok(Liveness {
253        alive: true,
254        version: crate::VERSION.into(),
255        commit: crate::COMMIT.into(),
256        dirty: crate::DIRTY,
257    })
258}
259
260/// Readiness. Only what this process needs in order to answer a request, all of it already
261/// resident: the registry it built, the index it built, and how that index read. Nothing
262/// here contacts a provider, a database or another service, and nothing here walks a tree.
263fn readiness(ctx: &Context, _: Empty) -> Result<Readiness, CapabilityError> {
264    let capabilities = ctx.registry.summary().total;
265    let objects = ctx.index.objects.len();
266    let stale = crate::lease::serving_replaced_code();
267    let lost = crate::lease::was_lost();
268    let mut reasons = Vec::new();
269    if lost {
270        reasons.push("lease_lost: another process holds this checkout's lease; this one serves the sessions it had and takes on nobody new".to_string());
271    }
272    if let Some(why) = &stale {
273        reasons.push(format!("stale: {why}"));
274    }
275    if ctx.index.state == State::Degraded {
276        reasons.push(format!(
277            "layer_degraded: the layer was read with {} diagnostic(s); it is served as read",
278            ctx.index.diagnostics.len()
279        ));
280    }
281    Ok(Readiness {
282        ready: capabilities > 0 && stale.is_none() && !lost,
283        reasons,
284        version: crate::VERSION.into(),
285        commit: crate::COMMIT.into(),
286        dirty: crate::DIRTY,
287        capabilities,
288        objects,
289        layer: match ctx.index.state {
290            State::Degraded => HealthStatus::Warn,
291            State::Ok => HealthStatus::Ok,
292        },
293        stale,
294    })
295}
296
297/// Record one dimension, and say on the execution's stream that it was decided.
298///
299/// The report is the same whoever asked for it: a `GET /api/v1/health`, an MCP tool call
300/// and a browser watching an execution all run this function, and the reporting is silent
301/// for the two of them that are not executions. That is the whole of what it took to make
302/// an existing capability observable — no second implementation, no second route, and no
303/// change to what it answers.
304fn record(checks: &mut Vec<HealthCheck>, p: &crate::execution::Progress, check: HealthCheck) {
305    p.step_done(
306        &check.id,
307        check.status != HealthStatus::Fail,
308        Some(check.detail.clone()),
309    );
310    checks.push(check);
311    p.progress(checks.len() as u64, None, "dimension(s) decided");
312}
313
314/// The attached-clients dimension, from the board this process holds (`None` when it holds
315/// none). Live peers only are counted as attached; a peer that is gone with its claims still
316/// shown, or one attached that has announced nothing, is worth a look, because the board then
317/// understates or overstates who is working here.
318fn peers_check(board: Option<&[crate::peers::Peer]>) -> HealthCheck {
319    let decided_by = "the peer board of the process holding this checkout's lease, tallied as `environment.preflight` tallies it";
320    let Some(peers) = board else {
321        return HealthCheck {
322            id: "peers".into(),
323            title: "Attached clients".into(),
324            status: HealthStatus::Unknown,
325            detail: "this process holds no peer board: it does not hold this checkout's lease, so who is attached is the server's to say".into(),
326            decided_by: decided_by.into(),
327            evidence: vec!["majordomus_peers".into(), "majordomus serve status".into()],
328            findings: Vec::new(),
329        };
330    };
331    let t = crate::environment::preflight::PeersObservation::of(peers);
332    let silent = t.attached - t.announced;
333    let mut findings = Vec::new();
334    if t.detached > 0 {
335        findings.push(format!(
336            "{} peer(s) are gone and the board still shows their claims",
337            t.detached
338        ));
339    }
340    if silent > 0 {
341        findings.push(format!(
342            "{silent} attached peer(s) have announced nothing, so the board understates who is here"
343        ));
344    }
345    HealthCheck {
346        id: "peers".into(),
347        title: "Attached clients".into(),
348        status: if findings.is_empty() {
349            HealthStatus::Ok
350        } else {
351            HealthStatus::Warn
352        },
353        detail: format!(
354            "{} attached ({} announced) · {} gone with claims still shown",
355            t.attached, t.announced, t.detached
356        ),
357        decided_by: decided_by.into(),
358        evidence: vec!["majordomus_peers".into()],
359        findings,
360    }
361}
362
363fn health(ctx: &Context, _: Empty) -> Result<Health, CapabilityError> {
364    let index = &ctx.index;
365    let mut checks = Vec::new();
366
367    // --- the layer, as the index read it
368    let errors = index
369        .diagnostics
370        .iter()
371        .filter(|d| d.severity == Severity::Error)
372        .count();
373    let warnings = index
374        .diagnostics
375        .iter()
376        .filter(|d| d.severity == Severity::Warning)
377        .count();
378    record(
379        &mut checks,
380        &ctx.progress,
381        HealthCheck {
382            id: "layer".into(),
383            title: "The layer as it was read".into(),
384            status: match (index.state, warnings) {
385                (State::Degraded, _) => HealthStatus::Fail,
386                (State::Ok, 0) => HealthStatus::Ok,
387                (State::Ok, _) => HealthStatus::Warn,
388            },
389            detail: format!(
390                "{} object(s) indexed; {errors} error(s), {warnings} warning(s)",
391                index.objects.len()
392            ),
393            decided_by: "the index's own diagnostics".into(),
394            evidence: vec!["majordomus capabilities validate".into()],
395            findings: index
396                .diagnostics
397                .iter()
398                .filter(|d| d.severity != Severity::Info)
399                .map(|d| {
400                    format!(
401                        "{}: {} ({})",
402                        d.code,
403                        d.message,
404                        d.path.as_deref().unwrap_or("-")
405                    )
406                })
407                .collect(),
408        },
409    );
410
411    // --- the registry: it exists, therefore it validated; a registry that does not build
412    // stops the process before any transport is bound
413    let summary = ctx.registry.summary();
414    record(
415        &mut checks,
416        &ctx.progress,
417        HealthCheck {
418            id: "registry".into(),
419            title: "The capability registry".into(),
420            status: HealthStatus::Ok,
421            detail: format!(
422                "{} capabilities ({} builtin, {} declarative) across {} modules; fingerprint {}",
423                summary.total,
424                summary.builtin,
425                summary.declarative,
426                summary.modules,
427                &ctx.registry.fingerprint()[..12.min(ctx.registry.fingerprint().len())]
428            ),
429            decided_by:
430                "the registry builder; it refuses to build on a duplicate or a malformed exposure"
431                    .into(),
432            evidence: vec!["majordomus capabilities validate".into()],
433            findings: Vec::new(),
434        },
435    );
436
437    // --- the scope: what a worker reads of this repository and what it never reads
438    let tally = &index.scoped.tally;
439    record(
440        &mut checks,
441        &ctx.progress,
442        HealthCheck {
443            id: "scope".into(),
444            title: "The declared scope".into(),
445            status: HealthStatus::Ok,
446            detail: format!(
447                "{} of {} tracked file(s) in scope, {} out, read from {}",
448                tally.r#in, tally.files, tally.out, index.repository.scope_path
449            ),
450            decided_by: "the scope declaration the index was built under".into(),
451            evidence: vec!["majordomus scope".into()],
452            findings: Vec::new(),
453        },
454    );
455
456    // --- git
457    let (git_status, git_detail) = match &index.repository.git {
458        GitState::Available(info) => {
459            let head = info.head.as_deref().unwrap_or("(unborn)");
460            (
461                if info.working_tree == "clean" {
462                    HealthStatus::Ok
463                } else {
464                    HealthStatus::Warn
465                },
466                format!(
467                    "{} at {}, working tree {}",
468                    info.branch.as_deref().unwrap_or("(detached)"),
469                    &head[..12.min(head.len())],
470                    info.working_tree
471                ),
472            )
473        }
474        GitState::Unavailable { reason } => (HealthStatus::Unknown, reason.clone()),
475    };
476    record(
477        &mut checks,
478        &ctx.progress,
479        HealthCheck {
480            id: "git".into(),
481            title: "Version control".into(),
482            status: git_status,
483            detail: git_detail,
484            decided_by: "git, as the index asked it when it was built".into(),
485            evidence: vec!["git status".into()],
486            findings: Vec::new(),
487        },
488    );
489
490    // --- benchmark coverage, from the projection that defines the targets
491    let projection = BenchmarkProjection::from_context(ctx);
492    let coverage = Coverage::compute(ctx, &projection);
493    let total = coverage.tallies.get("total").cloned().unwrap_or_default();
494    record(
495        &mut checks,
496        &ctx.progress,
497        HealthCheck {
498            id: "benchmark-coverage".into(),
499            title: "Benchmark coverage".into(),
500            status: if total.missing > 0 {
501                HealthStatus::Fail
502            } else if total.waived > 0 {
503                HealthStatus::Warn
504            } else {
505                HealthStatus::Ok
506            },
507            detail: format!(
508                "{} of {} target(s) covered, {} missing, {} waived",
509                total.covered, total.required, total.missing, total.waived
510            ),
511            decided_by:
512                "the benchmark projection's coverage, the same one `bench coverage --check` reads"
513                    .into(),
514            evidence: vec!["majordomus bench coverage --check".into()],
515            findings: coverage
516                .lines
517                .iter()
518                .filter(|l| matches!(l.state, crate::bench::CoverageState::Missing))
519                .map(|l| format!("{} on {}: no case", l.subject, l.transport.name()))
520                .collect(),
521        },
522    );
523
524    // --- the committed registry manifest against the registry this process built.
525    // Only that one artifact: it is derived from the code alone, and rendering it costs
526    // nothing but serialising the descriptors already in memory. Rendering the OpenAPI
527    // document, the reference and the benchmark matrix on every health call would rebuild
528    // canonical state per request, which this executable does not do; `generate --check`
529    // is the complete answer and this check names it.
530    let root = std::path::Path::new(&index.repository.root);
531    let manifest = generate::Document::new(
532        "registry",
533        generate::REGISTRY_SCHEMA,
534        "the canonical capability registry",
535        generate::registry_manifest(&ctx.registry),
536    )
537    .artifacts(crate::VERSION);
538    let manifest = manifest
539        .iter()
540        .find(|a| a.format == generate::ArtifactFormat::Json)
541        .map(|a| a.content.clone())
542        .unwrap_or_default();
543    let path = format!("{}/registry.json", generate::OUT_DIR);
544    let committed = std::fs::read_to_string(root.join(&path));
545    let (status, detail, findings) = match &committed {
546        Ok(text) if *text == manifest => (
547            HealthStatus::Ok,
548            format!("{path} matches the registry this process built"),
549            Vec::new(),
550        ),
551        Ok(_) => (
552            HealthStatus::Fail,
553            format!("{path} differs from the registry this process built"),
554            vec![format!("{path} (differs)")],
555        ),
556        // A repository that commits no manifest has nothing to drift from: that is every
557        // repository but the ones that run `majordomus generate`, and reading it as
558        // `unknown` made the whole report unknown wherever the layer was merely adopted.
559        Err(e) if e.kind() == std::io::ErrorKind::NotFound => (
560            HealthStatus::Ok,
561            format!("{path} is not committed here, so there is nothing to compare"),
562            Vec::new(),
563        ),
564        Err(e) => (
565            HealthStatus::Unknown,
566            format!("{path} could not be read: {e}"),
567            vec![format!("{path} (unreadable)")],
568        ),
569    };
570    record(
571        &mut checks,
572        &ctx.progress,
573        HealthCheck {
574            id: "generated-registry".into(),
575            title: "Committed projections".into(),
576            status,
577            detail,
578            decided_by:
579                "the same rendering `majordomus generate` writes, compared with what is committed"
580                    .into(),
581            evidence: vec!["majordomus generate --check".into()],
582            findings,
583        },
584    );
585
586    // --- handovers published from other machines
587    //
588    // `continuity.status` decides it: every record of the local continuity store admitted
589    // or refused, and the lineage read from what was admitted. A refused record (forged,
590    // foreign, leaking) or a broken line fails; a diverged line, a dangling parent or a
591    // record too new to read warns. A repository that never published is not a fault.
592    let continuity = crate::continuity::Machine::open_read(
593        root,
594        super::mesh::declaration(ctx).and_then(Result::ok),
595    )
596    .and_then(|m| crate::continuity::status(&m));
597    let (status, detail, findings) = continuity_health(continuity);
598    record(
599        &mut checks,
600        &ctx.progress,
601        HealthCheck {
602            id: "continuity".into(),
603            title: "Handovers published across machines".into(),
604            status,
605            detail,
606            decided_by: "the admission and lineage checks of `continuity.status`".into(),
607            evidence: vec!["majordomus-cli continuity status".into()],
608            findings,
609        },
610    );
611
612    // --- the shared server of this checkout
613    //
614    // Not a second opinion and not a second reading: `server::standing_at` is what answers
615    // `GET /api/v1/server`, and this reports what it says. It costs one small file and,
616    // when the lease names an address, one probe bounded by the declared probe timeout; the
617    // five-second cache on `health.report` is as fresh as a file other processes write can
618    // usefully be, and `server.status` is the uncached answer for a caller that needs one.
619    let checkout = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
620    let local_half = server::local_half(&checkout);
621    let lease_path = crate::lease::lease_file(&checkout, &local_half);
622    let (standing, reason, lease) = server::standing_at(&checkout, &local_half);
623    let detail = match lease.document() {
624        Some(doc) => format!(
625            "{}: {}, version {}, pid {}, since {}",
626            standing.as_str(),
627            doc.url.as_deref().unwrap_or("no address published yet"),
628            doc.version.as_deref().unwrap_or("unstated"),
629            doc.pid,
630            doc.started_at
631        ),
632        None if standing == ServerStanding::Absent => format!(
633            "absent: no lease at {}, so nothing serves this checkout",
634            lease_path.display()
635        ),
636        None => format!(
637            "{}: the lease at {} holds no document",
638            standing.as_str(),
639            lease_path.display()
640        ),
641    };
642    // Whose report this is. Everything above describes the checkout's server, read from the
643    // lease — and a process whose lease was taken over describes, accurately, a server that
644    // is not itself. The one caller that has reached the wrong process is the one caller
645    // who cannot afford to be told that the right server is fine, so this outranks the
646    // standing rather than qualifying it.
647    let orphaned = crate::lease::was_lost();
648    let mut findings: Vec<String> = reason.into_iter().collect();
649    if orphaned {
650        findings.push(
651            "this process no longer holds the lease: it serves the sessions it already had \
652             and takes on no new ones; what is described above is another process"
653                .into(),
654        );
655    }
656    record(
657        &mut checks,
658        &ctx.progress,
659        HealthCheck {
660            id: "server".into(),
661            title: "The shared server".into(),
662            status: if orphaned {
663                HealthStatus::Warn
664            } else {
665                match standing {
666                    // A checkout nobody serves is not an unhealthy one: nothing is running
667                    // and nothing claims to be. A lease still binding resolves itself
668                    // within the bind grace, and is reported as stale by this same engine
669                    // when it does not. Neither is a fault of what this process serves.
670                    ServerStanding::Absent | ServerStanding::Starting | ServerStanding::Ready => {
671                        HealthStatus::Ok
672                    }
673                    // A server answering from code this tree no longer has says yesterday's
674                    // truth in today's words, and a lease naming one that answers for
675                    // nobody sends the next reader at a dead address. Both are somebody's
676                    // to clear; neither stops this process from serving, so neither is a
677                    // failure.
678                    ServerStanding::Outdated | ServerStanding::Stale => HealthStatus::Warn,
679                }
680            },
681            detail: if orphaned {
682                format!("{detail}; and this process is not it")
683            } else {
684                detail
685            },
686            decided_by:
687                "the decision `server.status` makes, from this checkout's lease and one probe of the server it names"
688                    .into(),
689            evidence: vec!["majordomus serve status".into()],
690            findings,
691        },
692    );
693
694    // --- the peers attached to this process
695    //
696    // Decided from the board and tallied the way preflight tallies it, so the two cards on
697    // one page cannot count the same board two ways. Only a process that holds this
698    // checkout's lease holds its board: anything else — the command line, a process whose
699    // lease was never published — has an empty board of its own, and an empty board it does
700    // not own is no evidence that nobody is attached.
701    record(
702        &mut checks,
703        &ctx.progress,
704        peers_check(
705            crate::lease::held()
706                .is_some()
707                .then(|| ctx.peers.list())
708                .as_deref(),
709        ),
710    );
711
712    // --- the mesh, as the server running it decided: only a process a shared server runs in
713    // decided anything, and in any other the answer is unknown, never a guess
714    let mesh = if ctx.mesh.decided() {
715        let verdict = crate::mesh::doctor::runtime_verdict(
716            super::mesh::declaration(ctx),
717            Some(&ctx.mesh.status()),
718        );
719        HealthCheck {
720            id: "mesh".into(),
721            title: "The mesh runtime".into(),
722            status: if verdict.ok {
723                HealthStatus::Ok
724            } else {
725                HealthStatus::Fail
726            },
727            detail: verdict.detail,
728            decided_by: "mesh doctor's runtime check, over this server's mesh.status".into(),
729            evidence: Vec::new(),
730            findings: verdict.remediation.into_iter().collect(),
731        }
732    } else {
733        HealthCheck {
734            id: "mesh".into(),
735            title: "The mesh runtime".into(),
736            status: HealthStatus::Unknown,
737            detail: "this process runs no shared server, so no mesh was activated or declined here; ask the server (GET /api/v1/health) or run `majordomus mesh doctor`".into(),
738            decided_by: "whether a shared server decided the mesh in this process".into(),
739            evidence: Vec::new(),
740            findings: Vec::new(),
741        }
742    };
743    record(&mut checks, &ctx.progress, mesh);
744
745    let status = checks
746        .iter()
747        .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status));
748    let mut tallies: BTreeMap<String, usize> = BTreeMap::new();
749    for c in &checks {
750        *tallies.entry(c.status.as_str().into()).or_insert(0) += 1;
751    }
752    let observed = [
753        ("layer", &index.repository.observed.index),
754        ("git", &index.repository.observed.git),
755    ]
756    .into_iter()
757    .filter(|(id, o)| !o.observed_at.is_empty() && checks.iter().any(|c| c.id == *id))
758    .map(|(id, o)| (id.to_string(), o.clone()))
759    .collect();
760    Ok(Health {
761        status,
762        repository: repository_status(&checks),
763        tallies,
764        checks,
765        observed,
766    })
767}
768
769/// The module.
770pub fn module() -> ModuleDescriptor {
771    module! {
772        id: "health",
773        title: "Health",
774        description: "Whether what this process serves is healthy, decided by the engines that already decide it: the index's diagnostics, the registry builder, the benchmark projection's coverage, the comparison `generate --check` makes and the lease reading `server.status` answers from. No check here has an opinion of its own.",
775        stability: Stability::BehaviorallyVerified,
776        capabilities: [
777            capability! {
778                id: "health.report",
779                title: "Health of this process",
780                description: "Every dimension of what this process serves — the layer as it was read, the registry, the scope, version control, benchmark coverage, the committed registry manifest, the shared server of this checkout and the attached peers — each decided by the engine that owns it, with the command that reproduces the verdict.",
781                input: Empty,
782                output: Health,
783                stability: Stability::BehaviorallyVerified,
784                exposure: Exposure {
785                    mcp: Some(McpExposure {
786                        tool: Some("majordomus_health".into()),
787                        resource: Some(McpResource { uri: HEALTH_URI.into(), name: "health".into() }),
788                    }),
789                    http: get("/api/v1/health"),
790                    cli: None,
791                },
792                tags: ["health", "introspection"],
793                cache: CachePolicy::Process { max_entries: 4, ttl_seconds: Some(5) },
794                handler: health,
795            },
796            capability! {
797                id: "health.live",
798                title: "Liveness",
799                description: "Is this process alive: the cheapest true statement this executable can make about itself, with the version that answered. No filesystem traversal, no index build, no network — this is what a hosting platform polls, and it must cost nothing to say.",
800                input: Empty,
801                output: Liveness,
802                stability: Stability::BehaviorallyVerified,
803                exposure: Exposure { mcp: None, http: get("/api/v1/live"), cli: None },
804                tags: ["health", "deployment"],
805                handler: liveness,
806            },
807            capability! {
808                id: "health.ready",
809                title: "Readiness",
810                description: "Can this process serve traffic: the registry and the index it built at start-up, already resident, and how the layer read. Only local initialisation — never an external provider, a database or another service, because a readiness check that probes a dependency fails a deployment for something that is not this process.",
811                input: Empty,
812                output: Readiness,
813                stability: Stability::BehaviorallyVerified,
814                exposure: Exposure { mcp: None, http: get("/api/v1/ready"), cli: None },
815                tags: ["health", "deployment"],
816                handler: readiness,
817            },
818        ],
819    }
820}
821
822/// What the continuity store says about the repository's health: the worst severity among
823/// the status's diagnostics, how many records were admitted, refused and are resumable, and
824/// every diagnostic that is more than information. A store that could not be read is
825/// unknown, never ok.
826fn continuity_health(
827    status: Result<crate::continuity::Status, String>,
828) -> (HealthStatus, String, Vec<String>) {
829    match status {
830        Ok(st) => {
831            let worst = st
832                .diagnostics
833                .iter()
834                .map(|d| match d.severity {
835                    Severity::Error => HealthStatus::Fail,
836                    Severity::Warning => HealthStatus::Warn,
837                    _ => HealthStatus::Ok,
838                })
839                .fold(HealthStatus::Ok, HealthStatus::worse);
840            (
841                worst,
842                format!(
843                    "{} record(s) admitted, {} refused, {} resumable from another device",
844                    st.store.records,
845                    st.store.refused,
846                    st.resumable.len()
847                ),
848                st.diagnostics
849                    .iter()
850                    .filter(|d| d.severity != Severity::Info)
851                    .map(|d| {
852                        format!(
853                            "{} {}{}",
854                            d.code,
855                            d.path
856                                .as_deref()
857                                .map(|p| format!("{p}: "))
858                                .unwrap_or_default(),
859                            d.message
860                        )
861                    })
862                    .collect(),
863            )
864        }
865        Err(e) => (
866            HealthStatus::Unknown,
867            format!("the continuity store could not be read: {e}"),
868            Vec::new(),
869        ),
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use crate::synthetic::{Shape, SyntheticRepository};
877
878    /// No board is no evidence: a process that holds no lease cannot say who is attached,
879    /// and says so rather than reporting an empty board as a healthy one.
880    #[test]
881    fn attached_clients_are_unknown_without_a_board() {
882        let c = peers_check(None);
883        assert_eq!(c.status, HealthStatus::Unknown, "{c:?}");
884    }
885
886    /// A board with every live peer announced is ok, and the count is of live peers only.
887    #[test]
888    fn attached_clients_are_ok_when_every_live_peer_announced() {
889        let board = crate::peers::PeerBoard::new();
890        let a = board.attach(crate::peers::Transport::Http);
891        board.announce(&a, "writing docs", vec!["docs".into()]);
892        let c = peers_check(Some(&board.list()));
893        assert_eq!(c.status, HealthStatus::Ok, "{c:?}");
894        assert!(
895            c.detail.starts_with("1 attached (1 announced) · 0 gone"),
896            "{c:?}"
897        );
898    }
899
900    /// An attached peer that announced nothing, and a gone peer whose claims the board still
901    /// shows, are each worth a look; and a gone peer is not counted as attached.
902    #[test]
903    fn attached_clients_warn_on_silent_or_gone_peers() {
904        let board = crate::peers::PeerBoard::new();
905        board.attach(crate::peers::Transport::Stdio);
906        let c = peers_check(Some(&board.list()));
907        assert_eq!(c.status, HealthStatus::Warn, "a silent peer: {c:?}");
908
909        let board = crate::peers::PeerBoard::new();
910        let gone = board.attach(crate::peers::Transport::Http);
911        board.announce(&gone, "left", vec!["a".into()]);
912        board.detach(&gone);
913        let c = peers_check(Some(&board.list()));
914        assert_eq!(c.status, HealthStatus::Warn, "a gone peer: {c:?}");
915        assert!(
916            c.detail.starts_with("0 attached (0 announced) · 1 gone"),
917            "{c:?}"
918        );
919    }
920
921    /// Liveness says one thing and reads nothing: the counters prove it moved no
922    /// canonical state, and the answer is the same whatever the layer holds.
923    #[test]
924    fn liveness_is_the_same_answer_whatever_the_layer_holds() {
925        let small = SyntheticRepository::new(Shape {
926            rules: 1,
927            ..Shape::default()
928        })
929        .expect("a synthetic repository");
930        let large = SyntheticRepository::new(Shape {
931            rules: 40,
932            ..Shape::default()
933        })
934        .expect("a synthetic repository");
935        let a = liveness(&small.context().expect("a context"), Empty {}).expect("alive");
936        let b = liveness(&large.context().expect("a context"), Empty {}).expect("alive");
937        assert_eq!(a, b);
938        assert!(a.alive);
939        assert_eq!(a.version, crate::VERSION);
940        assert_eq!(a.commit, crate::COMMIT);
941        assert_eq!(a.dirty, crate::DIRTY);
942    }
943
944    /// Readiness reads what this process already holds — the registry it built and the
945    /// index it built — and reports the layer rather than refusing over it.
946    #[test]
947    fn readiness_reports_what_this_process_already_holds() {
948        let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
949        let ctx = repo.context().expect("a context");
950        let r = readiness(&ctx, Empty {}).expect("ready");
951        assert!(r.ready);
952        assert_eq!(r.capabilities, ctx.registry.summary().total);
953        assert_eq!(r.objects, ctx.index.objects.len());
954        assert_eq!(r.layer, HealthStatus::Ok);
955        assert_eq!(r.commit, crate::COMMIT);
956        assert_eq!(r.dirty, crate::DIRTY);
957    }
958
959    /// Both are registered capabilities, so every projection carries them without a
960    /// second registration anywhere: a route the smoke suite and the platform can read
961    /// out of the registry rather than out of a list somebody maintains.
962    #[test]
963    fn both_are_registered_routes() {
964        let routes: Vec<String> = module()
965            .capabilities
966            .iter()
967            .filter_map(|c| c.capability.exposure.http.as_ref().map(|h| h.path.clone()))
968            .collect();
969        assert!(routes.contains(&"/api/v1/live".to_string()), "{routes:?}");
970        assert!(routes.contains(&"/api/v1/ready".to_string()), "{routes:?}");
971    }
972    /// The continuity check reads its verdict from the status: nothing published is ok, a
973    /// file the store refused fails or warns by its severity and is named with its path, a
974    /// note that is only information is not a finding, and a store that could not be read
975    /// is unknown.
976    #[test]
977    fn the_continuity_check_is_the_worst_of_what_the_store_reports() {
978        use crate::continuity::tests_support::{body, handover, machine, World};
979        use crate::continuity::{self as domain, PublishRequest};
980        let w = World::new();
981        let a_root = w.root("a");
982        let a = machine(&a_root, w.identity("a", "macbook-pro"), None);
983
984        let (status, detail, findings) = continuity_health(domain::status(&a));
985        assert_eq!(status, HealthStatus::Ok);
986        assert!(
987            detail.starts_with("0 record(s) admitted, 0 refused, 0 resumable"),
988            "{detail}"
989        );
990        assert!(findings.is_empty(), "{findings:?}");
991
992        handover(&a_root, "20261003T120000Z", &body("o", "s", "n"), None);
993        domain::publish(&a, &PublishRequest::default()).unwrap();
994        crate::continuity::store::add(&a_root, &[("stray".into(), b"{}".to_vec())], "x\n").unwrap();
995        let st = domain::status(&a).unwrap();
996        let severities: Vec<Severity> = st.diagnostics.iter().map(|d| d.severity).collect();
997        let (status, detail, findings) = continuity_health(Ok(st.clone()));
998        assert_ne!(status, HealthStatus::Ok, "{severities:?}");
999        assert!(
1000            detail.starts_with("1 record(s) admitted, 1 refused"),
1001            "{detail}"
1002        );
1003        assert!(
1004            findings.iter().any(|f| f.contains("records/stray.json: ")),
1005            "{findings:?}"
1006        );
1007
1008        // each severity is its own verdict, and information is not a finding
1009        let with = |severity: Severity, path: Option<&str>| {
1010            let mut st = st.clone();
1011            st.diagnostics = vec![crate::model::Diagnostic {
1012                severity,
1013                code: "continuity.sample".into(),
1014                path: path.map(String::from),
1015                message: "said".into(),
1016            }];
1017            continuity_health(Ok(st))
1018        };
1019        let (status, _, findings) = with(Severity::Error, Some("records/x.json"));
1020        assert_eq!(status, HealthStatus::Fail);
1021        assert_eq!(findings, ["continuity.sample records/x.json: said"]);
1022        let (status, _, findings) = with(Severity::Warning, None);
1023        assert_eq!(status, HealthStatus::Warn);
1024        assert_eq!(findings, ["continuity.sample said"]);
1025        let (status, _, findings) = with(Severity::Info, None);
1026        assert_eq!(status, HealthStatus::Ok);
1027        assert!(findings.is_empty());
1028
1029        let (status, detail, findings) = continuity_health(Err("no device".into()));
1030        assert_eq!(status, HealthStatus::Unknown);
1031        assert_eq!(detail, "the continuity store could not be read: no device");
1032        assert!(findings.is_empty());
1033    }
1034
1035    /// A process that is no server cannot decide who is attached or what the mesh did, and the
1036    /// whole report says `unknown` for it; the repository's own verdict does not, because none
1037    /// of its dimensions is the process's.
1038    #[test]
1039    fn the_repositorys_verdict_is_decided_where_no_server_runs() {
1040        let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1041        let ctx = repo.context().expect("a context");
1042        let report = health(&ctx, Empty {}).expect("a report");
1043        let of = |id: &str| {
1044            report
1045                .checks
1046                .iter()
1047                .find(|c| c.id == id)
1048                .unwrap_or_else(|| panic!("no {id} check"))
1049                .status
1050        };
1051        assert_eq!(of("peers"), HealthStatus::Unknown);
1052        assert_eq!(of("mesh"), HealthStatus::Unknown);
1053        assert_eq!(report.status, HealthStatus::Unknown);
1054        for id in PROCESS_DIMENSIONS {
1055            of(id);
1056        }
1057        let expected = report
1058            .checks
1059            .iter()
1060            .filter(|c| !PROCESS_DIMENSIONS.contains(&c.id.as_str()))
1061            .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status));
1062        assert_eq!(report.repository, expected);
1063        assert_eq!(report.repository, repository_status(&report.checks));
1064    }
1065
1066    /// The committed registry manifest is judged by what is there: a repository that commits
1067    /// none has nothing to drift from and is `ok`; one whose manifest exists and cannot be
1068    /// read is `unknown`, because this process cannot say whether it drifted.
1069    #[test]
1070    fn an_uncommitted_registry_manifest_is_ok_and_an_unreadable_one_is_unknown() {
1071        let check = |repo: &SyntheticRepository| {
1072            let ctx = repo.context().expect("a context");
1073            health(&ctx, Empty {})
1074                .expect("a report")
1075                .checks
1076                .into_iter()
1077                .find(|c| c.id == "generated-registry")
1078                .expect("the check is reported")
1079        };
1080        let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1081        let manifest = repo.root().join(generate::OUT_DIR).join("registry.json");
1082        assert!(!manifest.exists(), "the fixture commits no manifest");
1083        let absent = check(&repo);
1084        assert_eq!(absent.status, HealthStatus::Ok, "{absent:?}");
1085        assert!(absent.detail.contains("not committed here"), "{absent:?}");
1086        assert!(absent.findings.is_empty(), "{absent:?}");
1087
1088        // a directory where the file belongs: it exists, and it cannot be read as a manifest
1089        std::fs::create_dir_all(&manifest).expect("a directory at the manifest's path");
1090        let unreadable = check(&repo);
1091        assert_eq!(unreadable.status, HealthStatus::Unknown, "{unreadable:?}");
1092        assert!(
1093            unreadable.detail.contains("could not be read"),
1094            "{unreadable:?}"
1095        );
1096        assert_eq!(unreadable.findings.len(), 1, "{unreadable:?}");
1097    }
1098
1099    /// A layer read with an error diagnostic is served as read: readiness names it as a
1100    /// reason, reports the layer as a warning, and is still ready.
1101    #[test]
1102    fn readiness_names_a_degraded_layer_and_is_still_ready() {
1103        let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1104        let ctx = repo.context().expect("a context");
1105        let mut index = repo.index().expect("an index");
1106        index.diagnostics.push(crate::model::Diagnostic {
1107            severity: Severity::Error,
1108            code: "unknown_key".into(),
1109            path: Some("docs/x.md".into()),
1110            message: "key(s) not in schema".into(),
1111        });
1112        index.state = State::Degraded;
1113        let mut degraded = (*ctx).clone();
1114        degraded.index = std::sync::Arc::new(index);
1115        let r = readiness(&degraded, Empty {}).expect("ready");
1116        assert!(r.ready, "a degraded layer is served: {r:?}");
1117        assert_eq!(r.layer, HealthStatus::Warn);
1118        let expected = format!(
1119            "layer_degraded: the layer was read with {} diagnostic(s); it is served as read",
1120            degraded.index.diagnostics.len()
1121        );
1122        assert!(r.reasons.contains(&expected), "{:?}", r.reasons);
1123    }
1124
1125    /// The variable that makes a run of this test binary the child of the test below.
1126    const STALE_CHILD: &str = "MAJORDOMUS_HEALTH_TEST_STALE_CHILD";
1127
1128    /// A process whose executable was removed under it is serving code that is no longer on
1129    /// disk: readiness says it is not ready, and why. Shown in a copy of this test binary,
1130    /// run as a process of its own, which removes its own executable once it has pinned it.
1131    #[test]
1132    fn readiness_refuses_a_process_whose_executable_was_removed_under_it() {
1133        if std::env::var_os(STALE_CHILD).is_some() {
1134            let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1135            let ctx = repo.context().expect("a context");
1136            assert_eq!(
1137                crate::lease::serving_replaced_code(),
1138                None,
1139                "pinned while the file is there"
1140            );
1141            std::fs::remove_file(std::env::current_exe().expect("this executable"))
1142                .expect("the copy is removed");
1143            let r = readiness(&ctx, Empty {}).expect("an answer");
1144            assert!(!r.ready, "{r:?}");
1145            assert!(r.stale.is_some(), "{r:?}");
1146            assert!(
1147                r.reasons
1148                    .iter()
1149                    .any(|x| x.starts_with("stale: ") && x.contains("has been removed")),
1150                "{:?}",
1151                r.reasons
1152            );
1153            return;
1154        }
1155        let dir = tempfile::tempdir().expect("a temp dir");
1156        let copy = dir.path().join("health-tests");
1157        std::fs::copy(std::env::current_exe().expect("this test binary"), &copy)
1158            .expect("a copy of this test binary");
1159        // another test thread forking while the copy was open for writing leaves the copy's
1160        // write descriptor in that child until it execs, and Linux refuses to execute a file
1161        // open for writing (ETXTBSY): the race is the harness's, so it is waited out
1162        let mut attempts = 0;
1163        let out = loop {
1164            let run = std::process::Command::new(&copy)
1165                .args([
1166                    "--exact",
1167                    "capability::builtin::health::tests::readiness_refuses_a_process_whose_executable_was_removed_under_it",
1168                    "--nocapture",
1169                    "--test-threads",
1170                    "1",
1171                ])
1172                .env(STALE_CHILD, "1")
1173                .output();
1174            match run {
1175                Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 50 => {
1176                    attempts += 1;
1177                    std::thread::sleep(std::time::Duration::from_millis(100));
1178                }
1179                other => break other.expect("the copy runs"),
1180            }
1181        };
1182        let stdout = String::from_utf8_lossy(&out.stdout);
1183        assert!(
1184            out.status.success() && stdout.contains("1 passed"),
1185            "{:?}\n{stdout}\n{}",
1186            out.status,
1187            String::from_utf8_lossy(&out.stderr)
1188        );
1189        assert!(!copy.exists(), "the child removed its own executable");
1190    }
1191}