1use serde::{Deserialize, Serialize};
21
22use crate::fleet_diff::{
23 FleetDiff, WorkerDiffState, WorkerObservation, build_fleet_diff, derive_worker_diff,
24};
25
26pub const DEFAULT_ABSENCE_THRESHOLD_SECS: u64 = 300;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum FleetProblemClass {
37 Healthy,
39 CloudDisappearance,
41 LocalOverload,
43 DiskPressure,
45 MissingCapability,
48 AdminIntent,
50 DaemonConfigDrift,
52}
53
54impl FleetProblemClass {
55 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct FleetWorkerSignal {
89 pub observation: WorkerObservation,
91 pub disk_pressure: bool,
93 pub slots_saturated: bool,
95 pub absent_secs: Option<u64>,
98}
99
100impl FleetWorkerSignal {
101 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct AbsenceAlert {
117 pub worker_id: String,
119 pub state: WorkerDiffState,
121 pub absent_secs: u64,
123 pub threshold_secs: u64,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct FleetStatusReport {
131 pub diff: FleetDiff,
133 pub problem_class: FleetProblemClass,
135 pub problem_summary: String,
137 pub absence_alerts: Vec<AbsenceAlert>,
140 pub absence_threshold_secs: u64,
142}
143
144impl FleetStatusReport {
145 #[must_use]
147 pub fn has_absence_warnings(&self) -> bool {
148 !self.absence_alerts.is_empty()
149 }
150
151 #[must_use]
153 pub fn capacity_collapsed(&self) -> bool {
154 self.diff.capacity_collapsed()
155 }
156}
157
158#[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 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#[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 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 if s.disk_pressure {
263 disk += 1;
264 } else {
265 cloud += 1;
266 }
267 }
268 }
269 }
270
271 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")); 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; 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; let mut b = ready("b");
377 b.reachable = false; 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; let mut b = ready("b");
388 b.configured = false; 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); let mut b = ready("b");
411 b.reachable = false;
412 let mut sb = sig(b);
413 sb.absent_secs = Some(60); let mut c = ready("c");
416 c.reachable = false;
417 let mut sc = sig(c);
418 sc.absent_secs = Some(500); 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 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); 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}