Skip to main content

rch_common/
fleet_status.rs

1//! Fleet status report: desired/live grouping + dominant-problem summary +
2//! absence alerts (bd-session-history-remediation-ocv9i.2.2).
3//!
4//! [`crate::fleet_diff`] already classifies each worker into a
5//! [`WorkerDiffState`] and explains a capacity collapse in one line. This module
6//! adds the two pieces `rch status --fleet` needs on top of that grouping:
7//!
8//! 1. A **dominant problem class** ([`FleetProblemClass`]) — a single label that
9//!    answers "is this fleet problem cloud disappearance, local overload, disk
10//!    pressure, missing capability, admin intent, or daemon/config drift?", so an
11//!    operator gets the *category* of failure, not just per-worker rows.
12//! 2. **Absence alerts** — workers absent from live eligibility longer than a
13//!    policy window, surfaced as threshold-based warnings.
14//!
15//! Both are pure functions of [`FleetWorkerSignal`] inputs (the per-worker
16//! observation plus a few extra signals), so the whole report is deterministic
17//! and golden-testable; the CLI gathers the signals from config + daemon status
18//! + the bypass store and renders the result.
19
20use serde::{Deserialize, Serialize};
21
22use crate::fleet_diff::{
23    FleetDiff, WorkerDiffState, WorkerObservation, build_fleet_diff, derive_worker_diff,
24};
25
26/// Default policy window after which a worker absent from live eligibility is
27/// flagged (5 minutes).
28pub const DEFAULT_ABSENCE_THRESHOLD_SECS: u64 = 300;
29
30/// The dominant class of fleet problem, for a one-line operator summary.
31///
32/// When the fleet is usable this is [`FleetProblemClass::Healthy`]; otherwise it
33/// names the single category that best explains why capacity is constrained.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum FleetProblemClass {
37    /// At least one worker is ready with spare capacity.
38    Healthy,
39    /// Workers configured but missing/unreachable — the cloud hosts vanished.
40    CloudDisappearance,
41    /// Ready workers exist but all are at full slot capacity — overload, not absence.
42    LocalOverload,
43    /// Workers quarantined or critical because of disk/inode pressure.
44    DiskPressure,
45    /// Workers in the pool but lacking trustworthy capability facts or
46    /// admissibility for the work.
47    MissingCapability,
48    /// Workers explicitly admin-disabled — operator intent, not a fault.
49    AdminIntent,
50    /// Config and the daemon pool disagree (recovered-not-rejoined / orphan).
51    DaemonConfigDrift,
52}
53
54impl FleetProblemClass {
55    /// Stable lowercase token (matches the serde form).
56    #[must_use]
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::Healthy => "healthy",
60            Self::CloudDisappearance => "cloud_disappearance",
61            Self::LocalOverload => "local_overload",
62            Self::DiskPressure => "disk_pressure",
63            Self::MissingCapability => "missing_capability",
64            Self::AdminIntent => "admin_intent",
65            Self::DaemonConfigDrift => "daemon_config_drift",
66        }
67    }
68
69    /// Operator-facing label.
70    #[must_use]
71    pub const fn label(self) -> &'static str {
72        match self {
73            Self::Healthy => "healthy",
74            Self::CloudDisappearance => "cloud disappearance (hosts missing/unreachable)",
75            Self::LocalOverload => "overload (all ready workers at capacity)",
76            Self::DiskPressure => "disk/inode pressure",
77            Self::MissingCapability => "missing capability / not admissible",
78            Self::AdminIntent => "admin intent (operator-disabled)",
79            Self::DaemonConfigDrift => "daemon/config drift",
80        }
81    }
82}
83
84/// Per-worker signals beyond the desired/live diff, used to classify the
85/// dominant fleet problem and raise absence alerts. Plain data so the
86/// classification stays pure and unit-testable.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct FleetWorkerSignal {
89    /// The desired/live observation (drives the [`WorkerDiffState`]).
90    pub observation: WorkerObservation,
91    /// Worker is under warning/critical disk (or inode) pressure.
92    pub disk_pressure: bool,
93    /// Worker is usable but has no free slots (drives overload classification).
94    pub slots_saturated: bool,
95    /// Seconds the worker has been absent from live eligibility, if known
96    /// (e.g. from the age of its offline alert). Drives absence alerts.
97    pub absent_secs: Option<u64>,
98}
99
100impl FleetWorkerSignal {
101    /// A signal carrying just an observation, with no extra pressure/absence
102    /// information.
103    #[must_use]
104    pub fn from_observation(observation: WorkerObservation) -> Self {
105        Self {
106            observation,
107            disk_pressure: false,
108            slots_saturated: false,
109            absent_secs: None,
110        }
111    }
112}
113
114/// A worker absent from live eligibility longer than the policy window.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct AbsenceAlert {
117    /// Worker id.
118    pub worker_id: String,
119    /// Why it is out of play.
120    pub state: WorkerDiffState,
121    /// How long it has been absent, in seconds.
122    pub absent_secs: u64,
123    /// The policy window it exceeded, in seconds.
124    pub threshold_secs: u64,
125}
126
127/// Whole-fleet status: the desired/live grouping diff plus the dominant problem
128/// class and any absence alerts.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct FleetStatusReport {
131    /// Per-worker grouping + counts + capacity-collapse explanation.
132    pub diff: FleetDiff,
133    /// The single category that best explains the fleet's state.
134    pub problem_class: FleetProblemClass,
135    /// One-line human summary of the dominant problem.
136    pub problem_summary: String,
137    /// Workers absent from live eligibility longer than the policy window,
138    /// newest-absence... longest-absence first.
139    pub absence_alerts: Vec<AbsenceAlert>,
140    /// The policy window used for absence alerts, in seconds.
141    pub absence_threshold_secs: u64,
142}
143
144impl FleetStatusReport {
145    /// Whether any worker tripped the absence policy window.
146    #[must_use]
147    pub fn has_absence_warnings(&self) -> bool {
148        !self.absence_alerts.is_empty()
149    }
150
151    /// Whether the fleet has collapsed to zero usable workers.
152    #[must_use]
153    pub fn capacity_collapsed(&self) -> bool {
154        self.diff.capacity_collapsed()
155    }
156}
157
158/// Compute the fleet status report from per-worker signals.
159///
160/// Pure and deterministic: the grouping comes from [`build_fleet_diff`], the
161/// problem class from [`classify_fleet_problem`], and absence alerts from any
162/// non-ready worker whose known absence meets `absence_threshold_secs`.
163#[must_use]
164pub fn compute_fleet_status(
165    signals: &[FleetWorkerSignal],
166    absence_threshold_secs: u64,
167) -> FleetStatusReport {
168    let observations: Vec<WorkerObservation> =
169        signals.iter().map(|s| s.observation.clone()).collect();
170    let diff = build_fleet_diff(&observations);
171
172    let mut absence_alerts: Vec<AbsenceAlert> = signals
173        .iter()
174        .filter_map(|s| {
175            let state = derive_worker_diff(&s.observation);
176            let secs = s.absent_secs?;
177            (!state.is_usable() && secs >= absence_threshold_secs).then(|| AbsenceAlert {
178                worker_id: s.observation.worker_id.clone(),
179                state,
180                absent_secs: secs,
181                threshold_secs: absence_threshold_secs,
182            })
183        })
184        .collect();
185    // Longest-absent first; ties broken by id for determinism.
186    absence_alerts.sort_by(|a, b| {
187        b.absent_secs
188            .cmp(&a.absent_secs)
189            .then_with(|| a.worker_id.cmp(&b.worker_id))
190    });
191
192    let (problem_class, problem_summary) = classify_fleet_problem(&diff, signals);
193
194    FleetStatusReport {
195        diff,
196        problem_class,
197        problem_summary,
198        absence_alerts,
199        absence_threshold_secs,
200    }
201}
202
203/// Classify the dominant fleet problem from the diff and per-worker signals.
204///
205/// When at least one worker is ready, the fleet is [`FleetProblemClass::Healthy`]
206/// unless every ready worker is at full slot capacity, which is
207/// [`FleetProblemClass::LocalOverload`]. On a capacity collapse, each absent
208/// worker contributes to one problem dimension and the dominant dimension wins,
209/// with a fixed precedence breaking ties:
210/// admin intent > disk pressure > cloud disappearance > missing capability >
211/// daemon/config drift.
212#[must_use]
213pub fn classify_fleet_problem(
214    diff: &FleetDiff,
215    signals: &[FleetWorkerSignal],
216) -> (FleetProblemClass, String) {
217    let total = diff.workers.len();
218    if total == 0 {
219        return (
220            FleetProblemClass::Healthy,
221            "no workers configured".to_string(),
222        );
223    }
224
225    if diff.ready_workers > 0 {
226        let saturated_ready = signals
227            .iter()
228            .filter(|s| derive_worker_diff(&s.observation).is_usable() && s.slots_saturated)
229            .count();
230        if saturated_ready == diff.ready_workers {
231            return (
232                FleetProblemClass::LocalOverload,
233                format!(
234                    "{}/{total} worker(s) ready but all at full slot capacity (overload)",
235                    diff.ready_workers
236                ),
237            );
238        }
239        return (
240            FleetProblemClass::Healthy,
241            format!("{}/{total} worker(s) ready", diff.ready_workers),
242        );
243    }
244
245    // Capacity collapse: attribute each absent worker to exactly one dimension.
246    let mut admin = 0usize;
247    let mut disk = 0usize;
248    let mut cloud = 0usize;
249    let mut capability = 0usize;
250    let mut drift = 0usize;
251    for s in signals {
252        match derive_worker_diff(&s.observation) {
253            WorkerDiffState::Ready => {}
254            WorkerDiffState::AdminDisabled => admin += 1,
255            WorkerDiffState::RecoveredNotRejoined | WorkerDiffState::Unconfigured => drift += 1,
256            WorkerDiffState::FactsUnknown | WorkerDiffState::CommandIneligible => capability += 1,
257            WorkerDiffState::TemporarilyBypassed
258            | WorkerDiffState::Unreachable
259            | WorkerDiffState::MissingFromFleet => {
260                // Disk-flagged absences are a disk problem; otherwise the host
261                // is gone/flaky (cloud disappearance).
262                if s.disk_pressure {
263                    disk += 1;
264                } else {
265                    cloud += 1;
266                }
267            }
268        }
269    }
270
271    // Highest count wins; iteration order encodes the tie-break precedence.
272    let scored = [
273        (FleetProblemClass::AdminIntent, admin),
274        (FleetProblemClass::DiskPressure, disk),
275        (FleetProblemClass::CloudDisappearance, cloud),
276        (FleetProblemClass::MissingCapability, capability),
277        (FleetProblemClass::DaemonConfigDrift, drift),
278    ];
279    let mut best = (FleetProblemClass::Healthy, 0usize);
280    for (class, n) in scored {
281        if n > best.1 {
282            best = (class, n);
283        }
284    }
285    let (class, n) = best;
286    let summary = format!(
287        "0/{total} ready — {} ({n} worker(s)); {}",
288        class.label(),
289        diff.explanation
290    );
291    (class, summary)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn ready(id: &str) -> WorkerObservation {
299        WorkerObservation::ready(id)
300    }
301
302    fn sig(obs: WorkerObservation) -> FleetWorkerSignal {
303        FleetWorkerSignal::from_observation(obs)
304    }
305
306    #[test]
307    fn problem_class_tokens_are_stable() {
308        assert_eq!(
309            FleetProblemClass::CloudDisappearance.as_str(),
310            "cloud_disappearance"
311        );
312        let json = serde_json::to_string(&FleetProblemClass::DiskPressure).unwrap();
313        assert_eq!(json, "\"disk_pressure\"");
314    }
315
316    #[test]
317    fn healthy_when_a_ready_worker_has_headroom() {
318        let report = compute_fleet_status(&[sig(ready("a"))], DEFAULT_ABSENCE_THRESHOLD_SECS);
319        assert_eq!(report.problem_class, FleetProblemClass::Healthy);
320        assert_eq!(report.diff.ready_workers, 1);
321        assert!(!report.has_absence_warnings());
322    }
323
324    #[test]
325    fn overload_when_all_ready_workers_saturated() {
326        let mut s = sig(ready("a"));
327        s.slots_saturated = true;
328        let report = compute_fleet_status(&[s], DEFAULT_ABSENCE_THRESHOLD_SECS);
329        assert_eq!(report.problem_class, FleetProblemClass::LocalOverload);
330        assert!(report.problem_summary.contains("overload"));
331    }
332
333    #[test]
334    fn not_overload_when_some_ready_worker_has_headroom() {
335        let mut a = sig(ready("a"));
336        a.slots_saturated = true;
337        let b = sig(ready("b")); // has headroom
338        let report = compute_fleet_status(&[a, b], DEFAULT_ABSENCE_THRESHOLD_SECS);
339        assert_eq!(report.problem_class, FleetProblemClass::Healthy);
340    }
341
342    #[test]
343    fn admin_intent_dominates_collapse() {
344        let mut a = ready("a");
345        a.admin_disabled = true;
346        let mut b = ready("b");
347        b.admin_disabled = true;
348        let mut c = ready("c");
349        c.reachable = false; // unreachable -> cloud
350        let report =
351            compute_fleet_status(&[sig(a), sig(b), sig(c)], DEFAULT_ABSENCE_THRESHOLD_SECS);
352        assert!(report.capacity_collapsed());
353        assert_eq!(report.problem_class, FleetProblemClass::AdminIntent);
354        assert!(report.problem_summary.contains("admin intent"));
355    }
356
357    #[test]
358    fn disk_pressure_classified_from_bypass_signal() {
359        let mut a = ready("a");
360        a.temporarily_bypassed = true;
361        let mut sa = sig(a);
362        sa.disk_pressure = true;
363        let mut b = ready("b");
364        b.temporarily_bypassed = true;
365        let mut sb = sig(b);
366        sb.disk_pressure = true;
367        let report = compute_fleet_status(&[sa, sb], DEFAULT_ABSENCE_THRESHOLD_SECS);
368        assert_eq!(report.problem_class, FleetProblemClass::DiskPressure);
369    }
370
371    #[test]
372    fn cloud_disappearance_when_hosts_missing() {
373        let mut a = ready("a");
374        a.in_daemon_pool = false;
375        a.reachable = false; // missing_from_fleet
376        let mut b = ready("b");
377        b.reachable = false; // unreachable
378        let report = compute_fleet_status(&[sig(a), sig(b)], DEFAULT_ABSENCE_THRESHOLD_SECS);
379        assert_eq!(report.problem_class, FleetProblemClass::CloudDisappearance);
380    }
381
382    #[test]
383    fn daemon_config_drift_when_recovered_not_rejoined() {
384        let mut a = ready("a");
385        a.in_daemon_pool = false;
386        a.reachable = true; // recovered_not_rejoined
387        let mut b = ready("b");
388        b.configured = false; // unconfigured orphan
389        let report = compute_fleet_status(&[sig(a), sig(b)], DEFAULT_ABSENCE_THRESHOLD_SECS);
390        assert_eq!(report.problem_class, FleetProblemClass::DaemonConfigDrift);
391    }
392
393    #[test]
394    fn missing_capability_when_facts_or_admissibility_fail() {
395        let mut a = ready("a");
396        a.facts_known = false;
397        let mut b = ready("b");
398        b.command_admissible = false;
399        let report = compute_fleet_status(&[sig(a), sig(b)], DEFAULT_ABSENCE_THRESHOLD_SECS);
400        assert_eq!(report.problem_class, FleetProblemClass::MissingCapability);
401    }
402
403    #[test]
404    fn absence_alerts_respect_threshold_and_sort_longest_first() {
405        let mut a = ready("a");
406        a.reachable = false;
407        let mut sa = sig(a);
408        sa.absent_secs = Some(1000); // over threshold
409
410        let mut b = ready("b");
411        b.reachable = false;
412        let mut sb = sig(b);
413        sb.absent_secs = Some(60); // under threshold
414
415        let mut c = ready("c");
416        c.reachable = false;
417        let mut sc = sig(c);
418        sc.absent_secs = Some(500); // over threshold
419
420        let report = compute_fleet_status(&[sa, sb, sc], 300);
421        assert!(report.has_absence_warnings());
422        let ids: Vec<&str> = report
423            .absence_alerts
424            .iter()
425            .map(|x| x.worker_id.as_str())
426            .collect();
427        // Only a (1000s) and c (500s), longest first.
428        assert_eq!(ids, vec!["a", "c"]);
429        assert_eq!(report.absence_alerts[0].absent_secs, 1000);
430        assert_eq!(report.absence_alerts[0].threshold_secs, 300);
431    }
432
433    #[test]
434    fn ready_worker_never_raises_absence_alert() {
435        let mut s = sig(ready("a"));
436        s.absent_secs = Some(99_999); // even with a huge value, ready != absent
437        let report = compute_fleet_status(&[s], 300);
438        assert!(!report.has_absence_warnings());
439    }
440
441    #[test]
442    fn empty_fleet_is_healthy_with_no_alerts() {
443        let report = compute_fleet_status(&[], DEFAULT_ABSENCE_THRESHOLD_SECS);
444        assert_eq!(report.problem_class, FleetProblemClass::Healthy);
445        assert_eq!(report.problem_summary, "no workers configured");
446        assert!(!report.has_absence_warnings());
447    }
448
449    #[test]
450    fn report_serializes_with_stable_shape() {
451        let mut a = ready("a");
452        a.admin_disabled = true;
453        let report = compute_fleet_status(&[sig(a)], DEFAULT_ABSENCE_THRESHOLD_SECS);
454        let value = serde_json::to_value(&report).unwrap();
455        assert_eq!(value["problem_class"], "admin_intent");
456        assert_eq!(value["diff"]["state_counts"]["admin_disabled"], 1);
457        assert_eq!(value["absence_threshold_secs"], 300);
458        assert!(value["absence_alerts"].as_array().unwrap().is_empty());
459        let back: FleetStatusReport = serde_json::from_value(value).unwrap();
460        assert_eq!(back, report);
461    }
462}