Skip to main content

majordomus_cli/capability/builtin/
dashboard.rs

1//! The `dashboard` module: the Overview of the Dashboard Suite, as a projection of the
2//! capabilities that already answer each of its facts (ADR 0088).
3//!
4//! **A dashboard has no data of its own.** `dashboard.overview` answers four questions —
5//! *is it healthy*, *what changed*, *what is broken*, *what needs action* — and every card
6//! under them is one fact read out of another capability's answer through the same
7//! executor MCP, the HTTP routes and the Cockpit use. Nothing here is stored, counted or
8//! judged a second time: the card carries the capability it asked, the input it asked with,
9//! and the RFC 6901 pointer into that answer its value was read from, so a reader — or a
10//! test — can ask the source the same question and get the same value.
11//!
12//! **The value is the source's, exactly.** A card's [`DashboardSource::measure`] is one of
13//! two readings, and neither is arithmetic on the source's behalf:
14//!
15//! - [`DashboardMeasure::Exact`] — `value == answer.pointer(pointer)`, and `null` when the
16//!   pointer resolves to nothing;
17//! - [`DashboardMeasure::Count`] — the number at the pointer, or the length of the list or
18//!   map there, and `0` when the source left it out: the sources omit an empty list and a
19//!   zero tally (`skip_serializing_if`), so absence *is* their way of saying none.
20//!
21//! `tests/dashboard.rs` and `test/cases/534_the_overview_answers_four_questions.sh` hold
22//! that sentence for every card, naming none: they execute each card's source with its
23//! input and compare. A card that computed its own number fails them.
24//!
25//! **Status words are the sources' verdicts.** `fail` where the source says something
26//! refuses (a failing check, a worktree out of place, a question that refuses `finish`),
27//! `warn` where it names something worth looking at (a pending bump, a blocked issue, two
28//! workers claiming one scope), `ok` otherwise — and `unknown`, never `ok`, when the source
29//! could not answer, with its error as the card's detail. No threshold is invented here.
30//!
31//! **It is cheap.** Each source is asked once per answer, and the sources are the ones that
32//! read what this process already holds: no delivery report, no release analysis, no
33//! evidence judgement — nothing that rebuilds canonical state or refuses on a network. They
34//! are asked at once, so the answer costs its slowest source, not the sum of them.
35//!
36//! ```
37//! use majordomus_cli::capability::builtin::dashboard::module;
38//!
39//! // one capability, reachable from every surface the registry projects
40//! let m = module();
41//! let c = &m.capabilities[0].capability;
42//! assert_eq!(c.id.as_str(), "dashboard.overview");
43//! assert_eq!(c.exposure.http.as_ref().unwrap().path, "/api/v1/dashboard/overview");
44//! assert_eq!(c.exposure.cli.as_ref().unwrap().path, ["dashboard", "overview"]);
45//! ```
46
47use std::collections::BTreeMap;
48
49use schemars::JsonSchema;
50use serde::{Deserialize, Serialize};
51use serde_json::{json, Value};
52
53use crate::capability::handler::{CapabilityError, Context};
54use crate::capability::model::{CachePolicy, CliExposure, Exposure, Stability};
55use crate::capability::module::ModuleDescriptor;
56use crate::{capability, module};
57
58use super::health::HealthStatus;
59use super::{get, mcp, Empty};
60
61// ---------------------------------------------------------------- output types
62
63/// How a card's value is read out of its source's answer. Neither reading is arithmetic on
64/// the source's behalf.
65///
66/// ```
67/// use majordomus_cli::capability::builtin::dashboard::DashboardMeasure;
68/// use serde_json::json;
69///
70/// let answer = json!({ "status": "warn", "tallies": { "ok": 6 }, "overlaps": [1, 2] });
71/// // exact: the value at the pointer, null when there is none
72/// assert_eq!(DashboardMeasure::Exact.read(&answer, "/status"), json!("warn"));
73/// assert_eq!(DashboardMeasure::Exact.read(&answer, "/tallies/fail"), json!(null));
74/// // count: a number as it stands, a list or map by its length, and an omitted one is none
75/// assert_eq!(DashboardMeasure::Count.read(&answer, "/tallies/ok"), json!(6));
76/// assert_eq!(DashboardMeasure::Count.read(&answer, "/overlaps"), json!(2));
77/// assert_eq!(DashboardMeasure::Count.read(&answer, "/tallies/fail"), json!(0));
78/// ```
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "snake_case")]
81pub enum DashboardMeasure {
82    /// The value at the pointer, exactly; `null` when the pointer resolves to nothing.
83    Exact,
84    /// The number at the pointer, or the length of the list or map there; `0` when the
85    /// source left it out, which is how the sources say an empty list or a zero tally.
86    Count,
87}
88
89impl DashboardMeasure {
90    /// Read the card's value out of the source's answer.
91    ///
92    /// ```
93    /// use majordomus_cli::capability::builtin::dashboard::DashboardMeasure;
94    /// use serde_json::json;
95    /// let answer = json!({ "issue": { "id": "I0001" } });
96    /// assert_eq!(DashboardMeasure::Exact.read(&answer, "/issue/id"), json!("I0001"));
97    /// assert_eq!(DashboardMeasure::Count.read(&answer, "/blockers"), json!(0));
98    /// ```
99    pub fn read(self, answer: &Value, pointer: &str) -> Value {
100        let at = answer.pointer(pointer);
101        match self {
102            DashboardMeasure::Exact => at.cloned().unwrap_or(Value::Null),
103            DashboardMeasure::Count => match at {
104                None | Some(Value::Null) => json!(0),
105                Some(Value::Array(list)) => json!(list.len()),
106                Some(Value::Object(map)) => json!(map.len()),
107                Some(other) => other.clone(),
108            },
109        }
110    }
111}
112
113/// Where a card's value came from: the capability asked, the input it was asked with, and
114/// the pointer into its answer. Asking the capability the same question and reading the
115/// pointer with the same measure yields the card's value; that is the whole contract.
116///
117/// ```
118/// use majordomus_cli::capability::builtin::dashboard::{DashboardMeasure, DashboardSource};
119/// use serde_json::json;
120///
121/// let s: DashboardSource = serde_json::from_value(json!({
122///     "capability": "health.report",
123///     "input": {},
124///     "pointer": "/status",
125///     "measure": "exact",
126/// })).unwrap();
127/// assert_eq!(s.measure, DashboardMeasure::Exact);
128/// assert_eq!(s.measure.read(&json!({ "status": "ok" }), &s.pointer), json!("ok"));
129/// ```
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131pub struct DashboardSource {
132    /// The canonical id of the capability the value was read from.
133    pub capability: String,
134    /// The input it was asked with, as its input schema takes it.
135    pub input: Value,
136    /// An RFC 6901 JSON pointer into that capability's answer.
137    pub pointer: String,
138    /// How the value is read at the pointer.
139    pub measure: DashboardMeasure,
140}
141
142/// One fact on a dashboard: a value read out of one capability's answer, the verdict the
143/// source gives it, where a reader drills in, and what acts on it.
144///
145/// ```
146/// use majordomus_cli::capability::builtin::dashboard::DashboardCard;
147/// use serde_json::json;
148///
149/// let card: DashboardCard = serde_json::from_value(json!({
150///     "id": "health-status",
151///     "title": "Health",
152///     "value": "ok",
153///     "status": "ok",
154///     "detail": "the worst of 8 check(s)",
155///     "source": { "capability": "health.report", "input": {}, "pointer": "/status", "measure": "exact" },
156///     "route": "/cockpit/health",
157/// })).unwrap();
158/// // the value is the source's: reading the pointer out of its answer gives it back
159/// assert_eq!(card.source.measure.read(&json!({ "status": "ok" }), &card.source.pointer), card.value);
160/// assert!(card.action.is_none());
161/// ```
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163pub struct DashboardCard {
164    /// A stable id, `[a-z][a-z0-9-]*`, unique across the dashboard.
165    pub id: String,
166    /// What the value is.
167    pub title: String,
168    /// The value, read out of the source's answer by the source's measure: a number, a
169    /// string or a boolean, or `null` when the source holds none.
170    pub value: Value,
171    /// The source's verdict on it; `unknown` when the source could not answer.
172    pub status: HealthStatus,
173    /// One line: what the value means, or why the source could not answer.
174    pub detail: String,
175    /// Where the value came from.
176    pub source: DashboardSource,
177    /// The Cockpit page a reader drills into for the evidence.
178    pub route: String,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    /// The command that acts on it, when one does.
181    pub action: Option<String>,
182}
183
184/// One of the four questions the Overview answers, with the cards that answer it.
185///
186/// ```
187/// use majordomus_cli::capability::builtin::dashboard::DashboardQuestion;
188/// use majordomus_cli::capability::builtin::HealthStatus;
189/// use serde_json::json;
190///
191/// let q: DashboardQuestion = serde_json::from_value(json!({
192///     "id": "healthy", "title": "Is it healthy?", "status": "ok", "cards": [],
193/// })).unwrap();
194/// assert_eq!(q.status, HealthStatus::Ok);
195/// ```
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct DashboardQuestion {
198    /// `healthy`, `changed`, `broken` or `action`.
199    pub id: String,
200    /// The question, as a reader asks it.
201    pub title: String,
202    /// The worst of its cards.
203    pub status: HealthStatus,
204    /// The facts that answer it, each read from a capability.
205    pub cards: Vec<DashboardCard>,
206}
207
208/// The Overview: four questions, each answered by facts other capabilities hold.
209///
210/// ```
211/// use majordomus_cli::capability::builtin::dashboard::DashboardOverview;
212/// use majordomus_cli::capability::builtin::HealthStatus;
213/// use serde_json::json;
214///
215/// let o: DashboardOverview = serde_json::from_value(json!({ "status": "unknown", "questions": [] })).unwrap();
216/// assert_eq!(o.status, HealthStatus::Unknown);
217/// ```
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
219pub struct DashboardOverview {
220    /// The worst of the questions: `unknown` outranks `fail`, because an undecided fact is
221    /// not a healthy one.
222    pub status: HealthStatus,
223    /// `healthy`, `changed`, `broken`, `action`, in that order.
224    pub questions: Vec<DashboardQuestion>,
225}
226
227// ---------------------------------------------------------------- the cards
228
229/// A verdict and a line of detail, from the card's value and the source's whole answer.
230type Judge = fn(&Value, &Value) -> (HealthStatus, String);
231
232/// One card, declared: which question it answers, where its value is, and how the source's
233/// own verdict on it is worded. `pointers` is tried in order and the first that resolves
234/// is the one the card carries — `plan.next` answers with an issue or with a reason.
235struct Spec {
236    question: &'static str,
237    id: &'static str,
238    title: &'static str,
239    capability: &'static str,
240    input: &'static str,
241    pointers: &'static [&'static str],
242    measure: DashboardMeasure,
243    route: &'static str,
244    action: Option<&'static str>,
245    judge: Judge,
246}
247
248const QUESTIONS: [(&str, &str); 4] = [
249    ("healthy", "Is it healthy?"),
250    ("changed", "What changed?"),
251    ("broken", "What is broken?"),
252    ("action", "What needs action?"),
253];
254
255fn count(v: &Value) -> u64 {
256    v.as_u64().unwrap_or(0)
257}
258
259fn some_or(v: &Value, none: HealthStatus, some: HealthStatus) -> HealthStatus {
260    if count(v) > 0 {
261        some
262    } else {
263        none
264    }
265}
266
267fn health_status(v: &Value, answer: &Value) -> (HealthStatus, String) {
268    let status = serde_json::from_value::<HealthStatus>(v.clone()).unwrap_or(HealthStatus::Unknown);
269    let checks = answer["checks"].as_array().cloned().unwrap_or_default();
270    let not_ok: Vec<String> = checks
271        .iter()
272        .filter(|c| c["status"] != "ok")
273        .map(|c| {
274            format!(
275                "{} ({})",
276                c["id"].as_str().unwrap_or("?"),
277                c["status"].as_str().unwrap_or("?")
278            )
279        })
280        .collect();
281    let detail = if not_ok.is_empty() {
282        format!("the worst of {} check(s); every one is ok", checks.len())
283    } else {
284        format!(
285            "the worst of {} check(s); not ok: {}",
286            checks.len(),
287            not_ok.join(", ")
288        )
289    };
290    (status, detail)
291}
292
293fn health_failing(v: &Value, answer: &Value) -> (HealthStatus, String) {
294    let failing: Vec<&str> = answer["checks"]
295        .as_array()
296        .map(|a| {
297            a.iter()
298                .filter(|c| c["status"] == "fail")
299                .filter_map(|c| c["id"].as_str())
300                .collect()
301        })
302        .unwrap_or_default();
303    let detail = if failing.is_empty() {
304        "no check's engine reports a failure".to_string()
305    } else {
306        format!("failing: {}", failing.join(", "))
307    };
308    (some_or(v, HealthStatus::Ok, HealthStatus::Fail), detail)
309}
310
311fn version_declared(_: &Value, answer: &Value) -> (HealthStatus, String) {
312    let tool = answer["tool"].as_str().unwrap_or("?");
313    if answer["agree"].as_bool() == Some(true) {
314        (
315            HealthStatus::Ok,
316            format!("authored in Cargo.toml; share/version.txt states {tool}, current"),
317        )
318    } else {
319        (
320            HealthStatus::Fail,
321            format!("share/version.txt states {tool}, which `generate --check` refuses"),
322        )
323    }
324}
325
326fn last_release(v: &Value, _: &Value) -> (HealthStatus, String) {
327    match v.as_str() {
328        Some(r) => (
329            HealthStatus::Ok,
330            format!("the layer records {r} as released"),
331        ),
332        None => (
333            HealthStatus::Ok,
334            "the layer records no release yet".to_string(),
335        ),
336    }
337}
338
339fn pending_bump(v: &Value, answer: &Value) -> (HealthStatus, String) {
340    let n = answer["changes"].as_array().map(Vec::len).unwrap_or(0);
341    match v.as_str() {
342        Some("none") | None => (
343            HealthStatus::Ok,
344            "no commit since the last release implies a release".to_string(),
345        ),
346        Some(bump) => (
347            HealthStatus::Warn,
348            format!("{n} kind(s) of change since the last release imply a {bump} release"),
349        ),
350    }
351}
352
353fn next_version(v: &Value, answer: &Value) -> (HealthStatus, String) {
354    let bump = answer["bump"].as_str().unwrap_or("?");
355    match (v.as_str(), answer["decided_by"].as_str()) {
356        (Some(_), Some("contract")) => (
357            HealthStatus::Ok,
358            format!(
359                "the version the public contract requires: the declared one when it already \
360                 satisfies the contract, otherwise the smallest it allows; the commits imply a \
361                 {bump} (evidence)"
362            ),
363        ),
364        (Some(_), Some("contract_and_commits")) => (
365            HealthStatus::Ok,
366            format!(
367                "the contract requires no release over the last one and the commits imply a \
368                 {bump}: the smallest release above it, a patch"
369            ),
370        ),
371        (_, Some("undecided")) => (
372            HealthStatus::Unknown,
373            format!(
374                "undecided: the public contract could not be measured ({}), so no version is \
375                 selected; the commits imply a {bump} (evidence only), and `release bump` needs \
376                 an explicit target",
377                answer["contract_unreadable"]
378                    .as_str()
379                    .unwrap_or("no reason given")
380            ),
381        ),
382        (Some(_), _) => (
383            HealthStatus::Ok,
384            "the version the public contract requires".to_string(),
385        ),
386        (None, _) => (HealthStatus::Ok, "nothing would be released".to_string()),
387    }
388}
389
390fn blocked_issues(v: &Value, answer: &Value) -> (HealthStatus, String) {
391    if v.is_null() {
392        return (
393            HealthStatus::Unknown,
394            "the plan's vocabulary declares no BLOCKED status".to_string(),
395        );
396    }
397    (
398        some_or(v, HealthStatus::Ok, HealthStatus::Warn),
399        format!(
400            "of {} issue(s), waiting on a dependency that is not done",
401            answer["counts"]["total"].as_u64().unwrap_or(0)
402        ),
403    )
404}
405
406fn repository_errors(v: &Value, _: &Value) -> (HealthStatus, String) {
407    (
408        some_or(v, HealthStatus::Ok, HealthStatus::Fail),
409        "error-level diagnostics across the whole worktree topology".to_string(),
410    )
411}
412
413fn canonical(v: &Value, answer: &Value) -> (HealthStatus, String) {
414    let path = answer["worktree"]["path"].as_str().unwrap_or("?");
415    if v.as_bool() == Some(true) {
416        (
417            HealthStatus::Ok,
418            format!("{path} is where its branch belongs"),
419        )
420    } else {
421        (
422            HealthStatus::Fail,
423            format!("{path} is not where its branch belongs; the guard refuses a commit there"),
424        )
425    }
426}
427
428fn next_issue(v: &Value, answer: &Value) -> (HealthStatus, String) {
429    let milestone = answer["active_milestone"].as_str().unwrap_or("?");
430    if answer["issue"].is_object() {
431        (
432            HealthStatus::Ok,
433            format!(
434                "{} (active milestone {milestone})",
435                answer["issue"]["title"]
436                    .as_str()
437                    .unwrap_or("the next ready issue")
438            ),
439        )
440    } else if v.is_null() {
441        (
442            HealthStatus::Warn,
443            format!("nothing is executable and no reason was given (active milestone {milestone})"),
444        )
445    } else {
446        (
447            HealthStatus::Warn,
448            format!("nothing is executable (active milestone {milestone})"),
449        )
450    }
451}
452
453fn blockers(v: &Value, _: &Value) -> (HealthStatus, String) {
454    (
455        some_or(v, HealthStatus::Ok, HealthStatus::Fail),
456        "unresolved questions on this branch; every one refuses `finish --outcome completed`"
457            .to_string(),
458    )
459}
460
461fn overlaps(v: &Value, answer: &Value) -> (HealthStatus, String) {
462    (
463        some_or(v, HealthStatus::Ok, HealthStatus::Warn),
464        format!(
465            "pairs of workers whose claimed scopes meet, on this checkout's board of {} peer(s)",
466            answer["count"].as_u64().unwrap_or(0)
467        ),
468    )
469}
470
471/// Every card of the Overview, in the order it is read.
472const CARDS: &[Spec] = &[
473    Spec {
474        question: "healthy",
475        id: "health-status",
476        title: "Health of this process",
477        capability: "health.report",
478        input: "{}",
479        pointers: &["/status"],
480        measure: DashboardMeasure::Exact,
481        route: "/cockpit/health",
482        action: Some("majordomus doctor"),
483        judge: health_status,
484    },
485    Spec {
486        question: "healthy",
487        id: "health-failing",
488        title: "Failing checks",
489        capability: "health.report",
490        input: "{}",
491        pointers: &["/tallies/fail"],
492        measure: DashboardMeasure::Count,
493        route: "/cockpit/health",
494        action: Some("majordomus doctor"),
495        judge: health_failing,
496    },
497    Spec {
498        question: "changed",
499        id: "version-declared",
500        title: "Declared version",
501        capability: "release.version",
502        input: "{}",
503        pointers: &["/declared"],
504        measure: DashboardMeasure::Exact,
505        route: "/cockpit/release",
506        action: Some("majordomus release version"),
507        judge: version_declared,
508    },
509    Spec {
510        question: "changed",
511        id: "last-release",
512        title: "Last release",
513        capability: "release.version",
514        input: "{}",
515        pointers: &["/last_release"],
516        measure: DashboardMeasure::Exact,
517        route: "/cockpit/release",
518        action: Some("majordomus release changelog"),
519        judge: last_release,
520    },
521    Spec {
522        question: "changed",
523        id: "pending-bump",
524        title: "Bump the commits imply",
525        capability: "release.version",
526        input: "{}",
527        pointers: &["/bump"],
528        measure: DashboardMeasure::Exact,
529        route: "/cockpit/release",
530        action: Some("majordomus release analyze"),
531        judge: pending_bump,
532    },
533    Spec {
534        question: "changed",
535        id: "next-version",
536        title: "Next version",
537        capability: "release.version",
538        input: "{}",
539        pointers: &["/next"],
540        measure: DashboardMeasure::Exact,
541        route: "/cockpit/release",
542        action: Some("majordomus release bump"),
543        judge: next_version,
544    },
545    Spec {
546        question: "broken",
547        id: "blocked-issues",
548        title: "Blocked issues",
549        capability: "plan.status",
550        input: "{}",
551        pointers: &["/counts/by_status/BLOCKED"],
552        measure: DashboardMeasure::Exact,
553        route: "/cockpit/capabilities/plan.status",
554        action: Some("majordomus plan blocked"),
555        judge: blocked_issues,
556    },
557    Spec {
558        question: "broken",
559        id: "worktree-errors",
560        title: "Worktree topology errors",
561        capability: "worktree.status",
562        input: "{}",
563        pointers: &["/repository_errors"],
564        measure: DashboardMeasure::Exact,
565        route: "/cockpit/worktrees",
566        action: Some("majordomus worktree topology"),
567        judge: repository_errors,
568    },
569    Spec {
570        question: "broken",
571        id: "worktree-canonical",
572        title: "This worktree is where it belongs",
573        capability: "worktree.status",
574        input: "{}",
575        pointers: &["/canonical"],
576        measure: DashboardMeasure::Exact,
577        route: "/cockpit/worktrees",
578        action: Some("majordomus worktree migrate"),
579        judge: canonical,
580    },
581    Spec {
582        question: "action",
583        id: "next-issue",
584        title: "Next issue",
585        capability: "plan.next",
586        input: "{}",
587        pointers: &["/issue/id", "/reason"],
588        measure: DashboardMeasure::Exact,
589        route: "/cockpit/capabilities/plan.next",
590        action: Some("majordomus plan next"),
591        judge: next_issue,
592    },
593    Spec {
594        question: "action",
595        id: "open-blockers",
596        title: "Questions refusing completion",
597        capability: "continuity.state",
598        input: "{}",
599        pointers: &["/tallies/blockers"],
600        measure: DashboardMeasure::Count,
601        route: "/cockpit/continuity",
602        action: Some("majordomus question list"),
603        judge: blockers,
604    },
605    Spec {
606        question: "action",
607        id: "peer-overlaps",
608        title: "Overlapping claims",
609        capability: "peers.list",
610        input: r#"{"checkouts":"this"}"#,
611        pointers: &["/overlaps"],
612        measure: DashboardMeasure::Count,
613        route: "/cockpit/capabilities/peers.list",
614        action: None,
615        judge: overlaps,
616    },
617];
618
619// ---------------------------------------------------------------- the handler
620
621/// The overview: each source asked once, through the executor, and every card read out of
622/// its answer.
623fn overview(ctx: &Context, _: Empty) -> Result<DashboardOverview, CapabilityError> {
624    let answers = ask_all(ctx, &sources(CARDS));
625    let questions: Vec<DashboardQuestion> = QUESTIONS
626        .iter()
627        .map(|(id, title)| {
628            let cards: Vec<DashboardCard> = CARDS
629                .iter()
630                .filter(|s| s.question == *id)
631                .map(|spec| card(spec, &answers[&(spec.capability, spec.input)]))
632                .collect();
633            DashboardQuestion {
634                id: (*id).into(),
635                title: (*title).into(),
636                status: cards
637                    .iter()
638                    .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status)),
639                cards,
640            }
641        })
642        .collect();
643    Ok(DashboardOverview {
644        status: questions
645            .iter()
646            .fold(HealthStatus::Ok, |acc, q| acc.worse(q.status)),
647        questions,
648    })
649}
650
651/// One source: a capability and the input it is asked with, however many cards read it.
652type Source = (&'static str, &'static str);
653
654/// The distinct sources of `cards`, in the order the cards first name them: four cards of
655/// `release.version` are one source, asked once.
656fn sources(cards: &[Spec]) -> Vec<Source> {
657    let mut out: Vec<Source> = Vec::new();
658    for spec in cards {
659        let source = (spec.capability, spec.input);
660        if !out.contains(&source) {
661            out.push(source);
662        }
663    }
664    out
665}
666
667/// Every source asked at once, each on a thread of its own, so that the overview costs its
668/// slowest source rather than the sum of them. Asked one after another, the seven took
669/// 7.4-10.6 s on this repository (`continuity.state` 4.9 s, `worktree.status` 2.0 s,
670/// `health.report` 0.9 s, `release.version` 0.6 s), which is the Cockpit's first paint.
671///
672/// The sources are independent reads through the shared executor, which every transport
673/// already calls from many threads. Progress is reported after the last answer, in the order
674/// the sources were declared, so an execution's step list does not depend on which thread
675/// finished first. A source whose thread panicked is an answer that failed, and its cards
676/// are `unknown` with that reason, as for any other failure.
677fn ask_all(ctx: &Context, sources: &[Source]) -> BTreeMap<Source, Result<Value, String>> {
678    let answers: Vec<Result<Value, String>> = std::thread::scope(|scope| {
679        let asked: Vec<_> = sources
680            .iter()
681            .map(|&(capability, input)| scope.spawn(move || ask(ctx, capability, input)))
682            .collect();
683        asked
684            .into_iter()
685            .map(|thread| joined(thread.join()))
686            .collect()
687    });
688    let mut out = BTreeMap::new();
689    for (&source, answer) in sources.iter().zip(answers) {
690        ctx.progress.step_done(source.0, answer.is_ok(), None);
691        out.insert(source, answer);
692    }
693    out
694}
695
696/// What a source's thread came back with: its answer, or — when asking it panicked — a
697/// failure that says so, so one broken source fails its own card and not the overview.
698fn joined(thread: std::thread::Result<Result<Value, String>>) -> Result<Value, String> {
699    match thread {
700        Ok(answer) => answer,
701        Err(_) => Err("the source panicked while answering".into()),
702    }
703}
704
705/// One source asked through the executor, with the input its card declares.
706fn ask(ctx: &Context, capability: &str, input: &str) -> Result<Value, String> {
707    let input: Value = serde_json::from_str(input)
708        .map_err(|e| format!("the card's own input does not parse: {e}"))?;
709    ctx.execute(capability, input).map_err(|e| e.to_string())
710}
711
712/// One card from its declaration and its source's answer — or, when the source could not
713/// answer, an `unknown` card that says why. Never a pass in place of an answer.
714fn card(spec: &Spec, answer: &Result<Value, String>) -> DashboardCard {
715    let input: Value = serde_json::from_str(spec.input).unwrap_or(Value::Null);
716    let mut source = DashboardSource {
717        capability: spec.capability.into(),
718        input,
719        pointer: spec.pointers[0].into(),
720        measure: spec.measure,
721    };
722    let (value, status, detail) = match answer {
723        Ok(answer) => {
724            if let Some(p) = spec.pointers.iter().find(|p| answer.pointer(p).is_some()) {
725                source.pointer = (*p).into();
726            }
727            let value = spec.measure.read(answer, &source.pointer);
728            let (status, detail) = (spec.judge)(&value, answer);
729            (value, status, detail)
730        }
731        Err(reason) => (
732            Value::Null,
733            HealthStatus::Unknown,
734            format!(
735                "{} could not answer, so this is unknown rather than a pass: {reason}",
736                spec.capability
737            ),
738        ),
739    };
740    DashboardCard {
741        id: spec.id.into(),
742        title: spec.title.into(),
743        value,
744        status,
745        detail,
746        source,
747        route: spec.route.into(),
748        action: spec.action.map(str::to_string),
749    }
750}
751
752// ---------------------------------------------------------------- the module
753
754/// The `dashboard` module: the Dashboard Suite's pages, each a projection of the
755/// capabilities that hold its facts. The Overview is the first.
756///
757/// ```
758/// use majordomus_cli::capability::builtin::dashboard::module;
759/// let m = module();
760/// assert_eq!(m.id.as_str(), "dashboard");
761/// let tool = m.capabilities[0].capability.exposure.mcp.as_ref().and_then(|m| m.tool.clone());
762/// assert_eq!(tool.as_deref(), Some("majordomus_dashboard_overview"));
763/// ```
764pub fn module() -> ModuleDescriptor {
765    module! {
766        id: "dashboard",
767        title: "Dashboards",
768        description: "The Dashboard Suite as a projection of the canonical state: every card is one fact read out of an existing capability's answer, carrying the capability it asked, the input, the JSON pointer its value was read from, the Cockpit page that holds the evidence and the command that acts on it. No dashboard stores, counts or judges anything of its own (ADR 0088).",
769        stability: Stability::BehaviorallyVerified,
770        capabilities: [
771            capability! {
772                id: "dashboard.overview",
773                title: "The Overview: four questions",
774                description: "Is it healthy, what changed, what is broken, what needs action — each answered by cards read out of `health.report`, `release.version`, `plan.status`, `worktree.status`, `plan.next`, `continuity.state` and `peers.list` (this checkout's board), asked once each through the executor. Every card carries its source capability, input and RFC 6901 pointer, so asking the source the same question yields the same value; a source that cannot answer makes its cards `unknown`, never `ok`. Nothing here rebuilds canonical state or asks anything beyond this process and its checkout.",
775                input: Empty,
776                output: DashboardOverview,
777                stability: Stability::BehaviorallyVerified,
778                exposure: Exposure {
779                    mcp: mcp("majordomus_dashboard_overview"),
780                    http: get("/api/v1/dashboard/overview"),
781                    cli: Some(CliExposure { path: vec!["dashboard".into(), "overview".into()] }),
782                },
783                tags: ["dashboard", "overview", "introspection"],
784                // every source carries its own cache policy; caching the composition would
785                // serve a card older than the source a reader drills into
786                cache: CachePolicy::Disabled,
787                handler: overview,
788            },
789        ],
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use crate::synthetic::{Shape, SyntheticRepository};
797
798    /// A source that panicked is that source's failure, and a card whose own input does not
799    /// parse fails with the reason; neither takes the overview down with it.
800    #[test]
801    fn a_panicked_source_and_an_unparsable_input_fail_their_own_card() {
802        assert_eq!(
803            joined(Err(Box::new("boom"))),
804            Err("the source panicked while answering".to_string())
805        );
806        assert_eq!(joined(Ok(Ok(Value::Null))), Ok(Value::Null));
807        let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
808        let ctx = repo.context().expect("a context");
809        let reason =
810            ask(&ctx, "plan.status", "{not json").expect_err("an input that does not parse");
811        assert!(reason.contains("does not parse"), "{reason}");
812    }
813
814    /// The next-version card names who answered: the contract, or the contract with the
815    /// commits — and, when the contract cannot be measured, that no one did: the commit
816    /// inference is never passed off as the answer.
817    #[test]
818    fn the_next_version_card_names_who_answered() {
819        let v = Value::from("0.11.0");
820        let by = |decided_by: &str| {
821            next_version(
822                &v,
823                &serde_json::json!({ "bump": "major", "decided_by": decided_by }),
824            )
825        };
826        let (status, contract) = by("contract");
827        assert_eq!(status, HealthStatus::Ok);
828        assert!(contract.starts_with("the version the public contract requires"));
829        assert!(contract.contains("the commits imply a major (evidence)"));
830        let (_, both) = by("contract_and_commits");
831        assert!(both.starts_with("the contract requires no release over the last one"));
832        // unmeasurable: no version is selected, and the card neither passes the commit
833        // inference off as the answer nor reports `ok`
834        let (status, undecided) = next_version(
835            &Value::Null,
836            &serde_json::json!({
837                "bump": "major",
838                "decided_by": "undecided",
839                "contract_unreadable": "no release to compare with",
840            }),
841        );
842        assert_eq!(status, HealthStatus::Unknown);
843        assert!(undecided.starts_with("undecided: the public contract could not be measured"));
844        assert!(undecided.contains("no release to compare with"));
845        assert!(undecided.contains("needs an explicit target"));
846        let (_, nothing) = next_version(
847            &Value::Null,
848            &serde_json::json!({ "decided_by": "contract" }),
849        );
850        assert_eq!(nothing, "nothing would be released");
851        // an answer that names no one, or a word this card does not know (an older or a
852        // newer executable): the version is still the contract's, and the card claims no
853        // more than that — neither the commits nor "undecided" are read into it
854        for answer in [
855            serde_json::json!({ "bump": "minor" }),
856            serde_json::json!({ "bump": "minor", "decided_by": "a-later-word" }),
857        ] {
858            let (status, plain) = next_version(&v, &answer);
859            assert_eq!(status, HealthStatus::Ok);
860            assert_eq!(plain, "the version the public contract requires");
861        }
862    }
863
864    /// The declaration is the only place the projections are named; a refactor that
865    /// dropped one would still compile, and this is the assertion that would not.
866    #[test]
867    fn the_declaration_yields_the_projections_it_claims() {
868        let m = module();
869        let ids: Vec<&str> = m
870            .capabilities
871            .iter()
872            .map(|e| e.capability.id.as_str())
873            .collect();
874        assert_eq!(ids, ["dashboard.overview"]);
875        let exposure = &m.capabilities[0].capability.exposure;
876        assert_eq!(
877            exposure.http.as_ref().unwrap().path,
878            "/api/v1/dashboard/overview"
879        );
880        assert_eq!(
881            exposure.cli.as_ref().unwrap().path,
882            ["dashboard", "overview"]
883        );
884    }
885
886    /// Every card answers one of the four questions, has an id of its own, and names a
887    /// source that is not one of the expensive or refusing capabilities.
888    #[test]
889    fn every_card_is_declared_once_and_reads_a_cheap_source() {
890        let mut seen = std::collections::BTreeSet::new();
891        for spec in CARDS {
892            assert!(seen.insert(spec.id), "{} is declared twice", spec.id);
893            assert!(
894                QUESTIONS.iter().any(|(q, _)| *q == spec.question),
895                "{} answers no question",
896                spec.id
897            );
898            assert!(!spec.pointers.is_empty(), "{} reads nowhere", spec.id);
899            for p in spec.pointers {
900                assert!(p.starts_with('/'), "{}: {p} is not a JSON pointer", spec.id);
901            }
902            serde_json::from_str::<Value>(spec.input)
903                .unwrap_or_else(|e| panic!("{}: input does not parse: {e}", spec.id));
904            for refused in ["delivery.", "release.analysis", "evidence."] {
905                assert!(
906                    !spec.capability.starts_with(refused),
907                    "{} reads {}, which the Overview must not ask",
908                    spec.id,
909                    spec.capability
910                );
911            }
912        }
913        for (q, _) in QUESTIONS {
914            assert!(
915                CARDS.iter().any(|s| s.question == q),
916                "the question {q} has no card"
917            );
918        }
919    }
920
921    /// Each source is asked once however many cards read it, and the sources keep the order
922    /// the cards first name them in, which is the order progress is reported in.
923    #[test]
924    fn every_source_is_asked_once_in_the_order_it_is_declared() {
925        let asked = sources(CARDS);
926        let mut seen = std::collections::BTreeSet::new();
927        for source in &asked {
928            assert!(seen.insert(*source), "{source:?} would be asked twice");
929        }
930        let mut first_named: Vec<Source> = Vec::new();
931        for spec in CARDS {
932            if !first_named.contains(&(spec.capability, spec.input)) {
933                first_named.push((spec.capability, spec.input));
934            }
935        }
936        assert_eq!(asked, first_named);
937        assert!(
938            asked.len() < CARDS.len(),
939            "some cards share a source, and sharing is what this is for"
940        );
941    }
942
943    /// Asked at once, every source answers what it answers when asked alone: the threads
944    /// change when an answer arrives, not what it is or which source it is filed under.
945    #[test]
946    fn asked_at_once_every_source_answers_as_it_does_alone() {
947        let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
948        let ctx = repo.context().expect("a context");
949        let asked = sources(CARDS);
950        let at_once = ask_all(&ctx, &asked);
951        assert_eq!(at_once.len(), asked.len(), "one answer per source");
952        for &(capability, input) in &asked {
953            let alone = ask(&ctx, capability, input);
954            let concurrent = &at_once[&(capability, input)];
955            assert_eq!(
956                alone.is_ok(),
957                concurrent.is_ok(),
958                "{capability} answered differently at once ({concurrent:?}) and alone ({alone:?})"
959            );
960        }
961    }
962
963    /// Against a real registry: every source exists, every card's value is its source's
964    /// value at its pointer, and a source that could not answer is unknown — never ok.
965    #[test]
966    fn every_card_is_its_sources_value_or_unknown() {
967        let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
968        let ctx = repo.context().expect("a context");
969        for spec in CARDS {
970            assert!(
971                ctx.registry.get(spec.capability).is_some(),
972                "{} reads {}, which the registry does not hold",
973                spec.id,
974                spec.capability
975            );
976        }
977        let o = overview(&ctx, Empty {}).expect("the overview answers");
978        let ids: Vec<&str> = o.questions.iter().map(|q| q.id.as_str()).collect();
979        assert_eq!(ids, ["healthy", "changed", "broken", "action"]);
980        for card in o.questions.iter().flat_map(|q| &q.cards) {
981            match ctx.execute(&card.source.capability, card.source.input.clone()) {
982                Ok(answer) => assert_eq!(
983                    card.source.measure.read(&answer, &card.source.pointer),
984                    card.value,
985                    "{} is not its source's value",
986                    card.id
987                ),
988                Err(_) => {
989                    assert_eq!(card.status, HealthStatus::Unknown, "{}", card.id);
990                    assert!(card.value.is_null(), "{}", card.id);
991                }
992            }
993        }
994    }
995
996    /// A source that refuses turns its cards unknown with the reason, and the whole
997    /// overview with them: absence of an answer is never reported as a pass.
998    #[test]
999    fn an_unanswered_source_is_unknown_with_its_reason() {
1000        let spec = &CARDS[0];
1001        let c = card(spec, &Err("no such thing".into()));
1002        assert_eq!(c.status, HealthStatus::Unknown);
1003        assert!(c.value.is_null());
1004        assert!(c.detail.contains("no such thing"), "{}", c.detail);
1005        assert!(
1006            c.detail.contains("unknown rather than a pass"),
1007            "{}",
1008            c.detail
1009        );
1010        assert_eq!(c.source.capability, spec.capability);
1011    }
1012
1013    /// The fallback pointer is taken only when the first resolves to nothing, and the card
1014    /// names the pointer it actually read.
1015    #[test]
1016    fn a_card_names_the_pointer_it_read() {
1017        let spec = CARDS
1018            .iter()
1019            .find(|s| s.id == "next-issue")
1020            .expect("declared");
1021        let with = card(
1022            spec,
1023            &Ok(json!({ "issue": { "id": "I0001" }, "active_milestone": "M1" })),
1024        );
1025        assert_eq!(with.source.pointer, "/issue/id");
1026        assert_eq!(with.value, json!("I0001"));
1027        assert_eq!(with.status, HealthStatus::Ok);
1028        let without = card(
1029            spec,
1030            &Ok(json!({ "reason": "run plan blocked", "active_milestone": "M1" })),
1031        );
1032        assert_eq!(without.source.pointer, "/reason");
1033        assert_eq!(without.value, json!("run plan blocked"));
1034        assert_eq!(without.status, HealthStatus::Warn);
1035    }
1036}