Skip to main content

rch_common/
fleet_diff.rs

1//! Desired-state inventory and live-eligibility diff
2//! (bd-session-history-remediation-ocv9i.2.1).
3//!
4//! Session history showed operators chasing a capacity collapse across three
5//! disjoint surfaces — `workers.toml` (what's configured), the daemon's
6//! in-memory pool (what joined), and status (what's healthy) — with no single
7//! view that says *why* there are zero usable workers for a command. This module
8//! is that view: it diffs the **desired** fleet against the **live** state
9//! across every dimension that can take a worker out of play (not in the pool,
10//! unreachable, admin-disabled, temporarily bypassed, stale facts, or admissible
11//! for everything *except this command*), and explains the collapse in one
12//! [`FleetDiff`].
13//!
14//! [`derive_worker_diff`] is the pure per-worker classifier; [`build_fleet_diff`]
15//! aggregates and counts, so the report stands alone without reading the config,
16//! the daemon log, and status separately.
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22/// One worker's observed state across the desired/live dimensions. Plain data so
23/// the classification is pure and unit-testable.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct WorkerObservation {
26    pub worker_id: String,
27    /// Present in `workers.toml` (desired).
28    pub configured: bool,
29    /// Present in the daemon's in-memory pool (joined).
30    pub in_daemon_pool: bool,
31    /// Answering probes right now.
32    pub reachable: bool,
33    /// Administratively disabled.
34    pub admin_disabled: bool,
35    /// Under a temporary bypass (e.g. disk pressure) expected to recover.
36    pub temporarily_bypassed: bool,
37    /// Last-known capability facts are present and fresh enough to trust.
38    pub facts_known: bool,
39    /// Admissible for the *selected command* (capability-checked).
40    pub command_admissible: bool,
41}
42
43impl WorkerObservation {
44    /// A fully-ready worker (all dimensions green) — a base for tests/builders.
45    #[must_use]
46    pub fn ready(worker_id: impl Into<String>) -> Self {
47        Self {
48            worker_id: worker_id.into(),
49            configured: true,
50            in_daemon_pool: true,
51            reachable: true,
52            admin_disabled: false,
53            temporarily_bypassed: false,
54            facts_known: true,
55            command_admissible: true,
56        }
57    }
58}
59
60/// The single diagnosis for one worker, explaining whether — and why not — it
61/// can run the selected command right now.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum WorkerDiffState {
65    /// Usable for the command right now.
66    Ready,
67    /// Configured but absent from the daemon pool and unreachable — never joined
68    /// (or fell out and is down).
69    MissingFromFleet,
70    /// Reachable again but not back in the daemon pool — recovered, not rejoined.
71    RecoveredNotRejoined,
72    /// Administratively disabled.
73    AdminDisabled,
74    /// Temporarily bypassed; expected to recover.
75    TemporarilyBypassed,
76    /// In the pool but not answering probes.
77    Unreachable,
78    /// No trustworthy capability facts.
79    FactsUnknown,
80    /// Healthy and in the pool, but not admissible for THIS command.
81    CommandIneligible,
82    /// Present in the live pool but not in the desired config (an orphan).
83    Unconfigured,
84}
85
86impl WorkerDiffState {
87    /// Stable lowercase token.
88    #[must_use]
89    pub fn as_str(self) -> &'static str {
90        match self {
91            WorkerDiffState::Ready => "ready",
92            WorkerDiffState::MissingFromFleet => "missing_from_fleet",
93            WorkerDiffState::RecoveredNotRejoined => "recovered_not_rejoined",
94            WorkerDiffState::AdminDisabled => "admin_disabled",
95            WorkerDiffState::TemporarilyBypassed => "temporarily_bypassed",
96            WorkerDiffState::Unreachable => "unreachable",
97            WorkerDiffState::FactsUnknown => "facts_unknown",
98            WorkerDiffState::CommandIneligible => "command_ineligible",
99            WorkerDiffState::Unconfigured => "unconfigured",
100        }
101    }
102
103    /// Whether a worker in this state can run the command now.
104    #[must_use]
105    pub fn is_usable(self) -> bool {
106        matches!(self, WorkerDiffState::Ready)
107    }
108}
109
110/// Classify one worker's diff state. Pure and total.
111///
112/// Precedence (the most fundamental reason a worker is out of play, first):
113/// admin-disabled (an explicit operator choice) → temporarily-bypassed →
114/// configured-but-not-pooled (missing vs recovered-not-rejoined, by
115/// reachability) → in-pool-but-unreachable → present-but-unconfigured →
116/// facts-unknown → command-ineligible → ready.
117#[must_use]
118pub fn derive_worker_diff(obs: &WorkerObservation) -> WorkerDiffState {
119    if obs.admin_disabled {
120        return WorkerDiffState::AdminDisabled;
121    }
122    if obs.temporarily_bypassed {
123        return WorkerDiffState::TemporarilyBypassed;
124    }
125    if obs.configured && !obs.in_daemon_pool {
126        return if obs.reachable {
127            WorkerDiffState::RecoveredNotRejoined
128        } else {
129            WorkerDiffState::MissingFromFleet
130        };
131    }
132    if obs.in_daemon_pool && !obs.reachable {
133        return WorkerDiffState::Unreachable;
134    }
135    if !obs.configured && obs.in_daemon_pool {
136        return WorkerDiffState::Unconfigured;
137    }
138    if !obs.facts_known {
139        return WorkerDiffState::FactsUnknown;
140    }
141    if !obs.command_admissible {
142        return WorkerDiffState::CommandIneligible;
143    }
144    WorkerDiffState::Ready
145}
146
147/// One worker's row in the fleet diff.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct WorkerDiffRow {
150    pub worker_id: String,
151    pub state: WorkerDiffState,
152}
153
154/// The whole-fleet desired-vs-live diff with a capacity-collapse explanation.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct FleetDiff {
157    pub workers: Vec<WorkerDiffRow>,
158    /// Count per diff state (only non-zero entries), for the explanation.
159    pub state_counts: BTreeMap<String, usize>,
160    /// Workers usable for the command right now.
161    pub ready_workers: usize,
162    /// One-line capacity-collapse explanation.
163    pub explanation: String,
164}
165
166impl FleetDiff {
167    /// Whether the fleet has collapsed to zero usable workers.
168    #[must_use]
169    pub fn capacity_collapsed(&self) -> bool {
170        self.ready_workers == 0 && !self.workers.is_empty()
171    }
172
173    /// Count of workers in a given state.
174    #[must_use]
175    pub fn count(&self, state: WorkerDiffState) -> usize {
176        self.state_counts.get(state.as_str()).copied().unwrap_or(0)
177    }
178}
179
180/// Build the fleet diff from per-worker observations.
181#[must_use]
182pub fn build_fleet_diff(observations: &[WorkerObservation]) -> FleetDiff {
183    let mut workers = Vec::with_capacity(observations.len());
184    let mut state_counts: BTreeMap<String, usize> = BTreeMap::new();
185    let mut ready_workers = 0;
186    for obs in observations {
187        let state = derive_worker_diff(obs);
188        if state.is_usable() {
189            ready_workers += 1;
190        }
191        *state_counts.entry(state.as_str().to_string()).or_insert(0) += 1;
192        workers.push(WorkerDiffRow {
193            worker_id: obs.worker_id.clone(),
194            state,
195        });
196    }
197
198    let explanation = if observations.is_empty() {
199        "no workers configured".to_string()
200    } else if ready_workers > 0 {
201        format!("{ready_workers}/{} worker(s) ready", observations.len())
202    } else {
203        // Capacity collapse: name the dominant non-ready reasons inline so an
204        // operator never has to cross-reference config/log/status.
205        let mut parts: Vec<String> = state_counts
206            .iter()
207            .map(|(state, n)| format!("{state}={n}"))
208            .collect();
209        parts.sort();
210        format!(
211            "0/{} ready — capacity collapse: {}",
212            observations.len(),
213            parts.join(", ")
214        )
215    };
216
217    FleetDiff {
218        workers,
219        state_counts,
220        ready_workers,
221        explanation,
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    // --- The four required scenarios ----------------------------------------
230
231    #[test]
232    fn missing_configured_worker() {
233        // Configured in workers.toml but absent from the pool and unreachable.
234        let mut obs = WorkerObservation::ready("css");
235        obs.in_daemon_pool = false;
236        obs.reachable = false;
237        assert_eq!(derive_worker_diff(&obs), WorkerDiffState::MissingFromFleet);
238    }
239
240    #[test]
241    fn recovered_but_not_rejoined_worker() {
242        // Reachable again, but the daemon hasn't re-admitted it to the pool.
243        let mut obs = WorkerObservation::ready("css");
244        obs.in_daemon_pool = false;
245        obs.reachable = true;
246        assert_eq!(
247            derive_worker_diff(&obs),
248            WorkerDiffState::RecoveredNotRejoined
249        );
250    }
251
252    #[test]
253    fn admin_disabled_worker() {
254        let mut obs = WorkerObservation::ready("css");
255        obs.admin_disabled = true;
256        // Admin-disabled outranks every other condition.
257        obs.reachable = false;
258        obs.in_daemon_pool = false;
259        assert_eq!(derive_worker_diff(&obs), WorkerDiffState::AdminDisabled);
260    }
261
262    #[test]
263    fn healthy_worker_rejected_only_for_a_specific_command_capability() {
264        // In the pool, reachable, facts known — but not admissible for THIS
265        // command. The crucial distinction from an unhealthy/absent worker.
266        let mut obs = WorkerObservation::ready("css");
267        obs.command_admissible = false;
268        assert_eq!(derive_worker_diff(&obs), WorkerDiffState::CommandIneligible);
269    }
270
271    // --- Other dimensions + precedence --------------------------------------
272
273    #[test]
274    fn temporary_bypass_and_unreachable_and_unconfigured() {
275        let mut bypass = WorkerObservation::ready("a");
276        bypass.temporarily_bypassed = true;
277        assert_eq!(
278            derive_worker_diff(&bypass),
279            WorkerDiffState::TemporarilyBypassed
280        );
281
282        let mut unreachable = WorkerObservation::ready("b");
283        unreachable.reachable = false; // still in pool
284        assert_eq!(
285            derive_worker_diff(&unreachable),
286            WorkerDiffState::Unreachable
287        );
288
289        let mut orphan = WorkerObservation::ready("c");
290        orphan.configured = false; // in pool but not configured
291        assert_eq!(derive_worker_diff(&orphan), WorkerDiffState::Unconfigured);
292    }
293
294    #[test]
295    fn ready_when_all_green() {
296        assert_eq!(
297            derive_worker_diff(&WorkerObservation::ready("css")),
298            WorkerDiffState::Ready
299        );
300    }
301
302    // --- Aggregate explanation ----------------------------------------------
303
304    #[test]
305    fn fleet_diff_explains_capacity_collapse() {
306        // Three configured workers, all out of play for different reasons —
307        // exactly the cross-surface confusion this report collapses into one line.
308        let mut admin = WorkerObservation::ready("a");
309        admin.admin_disabled = true;
310        let mut unreachable = WorkerObservation::ready("b");
311        unreachable.reachable = false;
312        let mut ineligible = WorkerObservation::ready("c");
313        ineligible.command_admissible = false;
314
315        let diff = build_fleet_diff(&[admin, unreachable, ineligible]);
316        assert_eq!(diff.ready_workers, 0);
317        assert!(diff.capacity_collapsed());
318        assert_eq!(diff.count(WorkerDiffState::AdminDisabled), 1);
319        assert_eq!(diff.count(WorkerDiffState::Unreachable), 1);
320        assert_eq!(diff.count(WorkerDiffState::CommandIneligible), 1);
321        assert!(diff.explanation.contains("capacity collapse"));
322        assert!(diff.explanation.contains("admin_disabled=1"));
323        assert!(diff.explanation.contains("command_ineligible=1"));
324    }
325
326    #[test]
327    fn fleet_diff_reports_ready_count() {
328        let diff = build_fleet_diff(&[WorkerObservation::ready("a"), {
329            let mut b = WorkerObservation::ready("b");
330            b.command_admissible = false;
331            b
332        }]);
333        assert_eq!(diff.ready_workers, 1);
334        assert!(!diff.capacity_collapsed());
335        assert!(diff.explanation.contains("1/2 worker(s) ready"));
336    }
337
338    #[test]
339    fn empty_fleet_is_explained() {
340        let diff = build_fleet_diff(&[]);
341        assert_eq!(diff.ready_workers, 0);
342        assert!(!diff.capacity_collapsed());
343        assert!(diff.explanation.contains("no workers configured"));
344    }
345
346    #[test]
347    fn fleet_diff_serializes_with_stable_tokens() {
348        let diff = build_fleet_diff(&[{
349            let mut a = WorkerObservation::ready("a");
350            a.admin_disabled = true;
351            a
352        }]);
353        let value = serde_json::to_value(&diff).unwrap();
354        assert_eq!(value["workers"][0]["state"], "admin_disabled");
355        assert_eq!(value["state_counts"]["admin_disabled"], 1);
356        let back: FleetDiff = serde_json::from_value(value).unwrap();
357        assert_eq!(back, diff);
358    }
359}