use serde::{Deserialize, Serialize};
use crate::fleet_diff::{
FleetDiff, WorkerDiffState, WorkerObservation, build_fleet_diff, derive_worker_diff,
};
pub const DEFAULT_ABSENCE_THRESHOLD_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FleetProblemClass {
Healthy,
CloudDisappearance,
LocalOverload,
DiskPressure,
MissingCapability,
AdminIntent,
DaemonConfigDrift,
}
impl FleetProblemClass {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::CloudDisappearance => "cloud_disappearance",
Self::LocalOverload => "local_overload",
Self::DiskPressure => "disk_pressure",
Self::MissingCapability => "missing_capability",
Self::AdminIntent => "admin_intent",
Self::DaemonConfigDrift => "daemon_config_drift",
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::CloudDisappearance => "cloud disappearance (hosts missing/unreachable)",
Self::LocalOverload => "overload (all ready workers at capacity)",
Self::DiskPressure => "disk/inode pressure",
Self::MissingCapability => "missing capability / not admissible",
Self::AdminIntent => "admin intent (operator-disabled)",
Self::DaemonConfigDrift => "daemon/config drift",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetWorkerSignal {
pub observation: WorkerObservation,
pub disk_pressure: bool,
pub slots_saturated: bool,
pub absent_secs: Option<u64>,
}
impl FleetWorkerSignal {
#[must_use]
pub fn from_observation(observation: WorkerObservation) -> Self {
Self {
observation,
disk_pressure: false,
slots_saturated: false,
absent_secs: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AbsenceAlert {
pub worker_id: String,
pub state: WorkerDiffState,
pub absent_secs: u64,
pub threshold_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FleetStatusReport {
pub diff: FleetDiff,
pub problem_class: FleetProblemClass,
pub problem_summary: String,
pub absence_alerts: Vec<AbsenceAlert>,
pub absence_threshold_secs: u64,
}
impl FleetStatusReport {
#[must_use]
pub fn has_absence_warnings(&self) -> bool {
!self.absence_alerts.is_empty()
}
#[must_use]
pub fn capacity_collapsed(&self) -> bool {
self.diff.capacity_collapsed()
}
}
#[must_use]
pub fn compute_fleet_status(
signals: &[FleetWorkerSignal],
absence_threshold_secs: u64,
) -> FleetStatusReport {
let observations: Vec<WorkerObservation> =
signals.iter().map(|s| s.observation.clone()).collect();
let diff = build_fleet_diff(&observations);
let mut absence_alerts: Vec<AbsenceAlert> = signals
.iter()
.filter_map(|s| {
let state = derive_worker_diff(&s.observation);
let secs = s.absent_secs?;
(!state.is_usable() && secs >= absence_threshold_secs).then(|| AbsenceAlert {
worker_id: s.observation.worker_id.clone(),
state,
absent_secs: secs,
threshold_secs: absence_threshold_secs,
})
})
.collect();
absence_alerts.sort_by(|a, b| {
b.absent_secs
.cmp(&a.absent_secs)
.then_with(|| a.worker_id.cmp(&b.worker_id))
});
let (problem_class, problem_summary) = classify_fleet_problem(&diff, signals);
FleetStatusReport {
diff,
problem_class,
problem_summary,
absence_alerts,
absence_threshold_secs,
}
}
#[must_use]
pub fn classify_fleet_problem(
diff: &FleetDiff,
signals: &[FleetWorkerSignal],
) -> (FleetProblemClass, String) {
let total = diff.workers.len();
if total == 0 {
return (
FleetProblemClass::Healthy,
"no workers configured".to_string(),
);
}
if diff.ready_workers > 0 {
let saturated_ready = signals
.iter()
.filter(|s| derive_worker_diff(&s.observation).is_usable() && s.slots_saturated)
.count();
if saturated_ready == diff.ready_workers {
return (
FleetProblemClass::LocalOverload,
format!(
"{}/{total} worker(s) ready but all at full slot capacity (overload)",
diff.ready_workers
),
);
}
return (
FleetProblemClass::Healthy,
format!("{}/{total} worker(s) ready", diff.ready_workers),
);
}
let mut admin = 0usize;
let mut disk = 0usize;
let mut cloud = 0usize;
let mut capability = 0usize;
let mut drift = 0usize;
for s in signals {
match derive_worker_diff(&s.observation) {
WorkerDiffState::Ready => {}
WorkerDiffState::AdminDisabled => admin += 1,
WorkerDiffState::RecoveredNotRejoined | WorkerDiffState::Unconfigured => drift += 1,
WorkerDiffState::FactsUnknown | WorkerDiffState::CommandIneligible => capability += 1,
WorkerDiffState::TemporarilyBypassed
| WorkerDiffState::Unreachable
| WorkerDiffState::MissingFromFleet => {
if s.disk_pressure {
disk += 1;
} else {
cloud += 1;
}
}
}
}
let scored = [
(FleetProblemClass::AdminIntent, admin),
(FleetProblemClass::DiskPressure, disk),
(FleetProblemClass::CloudDisappearance, cloud),
(FleetProblemClass::MissingCapability, capability),
(FleetProblemClass::DaemonConfigDrift, drift),
];
let mut best = (FleetProblemClass::Healthy, 0usize);
for (class, n) in scored {
if n > best.1 {
best = (class, n);
}
}
let (class, n) = best;
let summary = format!(
"0/{total} ready — {} ({n} worker(s)); {}",
class.label(),
diff.explanation
);
(class, summary)
}
#[cfg(test)]
mod tests {
use super::*;
fn ready(id: &str) -> WorkerObservation {
WorkerObservation::ready(id)
}
fn sig(obs: WorkerObservation) -> FleetWorkerSignal {
FleetWorkerSignal::from_observation(obs)
}
#[test]
fn problem_class_tokens_are_stable() {
assert_eq!(
FleetProblemClass::CloudDisappearance.as_str(),
"cloud_disappearance"
);
let json = serde_json::to_string(&FleetProblemClass::DiskPressure).unwrap();
assert_eq!(json, "\"disk_pressure\"");
}
#[test]
fn healthy_when_a_ready_worker_has_headroom() {
let report = compute_fleet_status(&[sig(ready("a"))], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::Healthy);
assert_eq!(report.diff.ready_workers, 1);
assert!(!report.has_absence_warnings());
}
#[test]
fn overload_when_all_ready_workers_saturated() {
let mut s = sig(ready("a"));
s.slots_saturated = true;
let report = compute_fleet_status(&[s], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::LocalOverload);
assert!(report.problem_summary.contains("overload"));
}
#[test]
fn not_overload_when_some_ready_worker_has_headroom() {
let mut a = sig(ready("a"));
a.slots_saturated = true;
let b = sig(ready("b")); let report = compute_fleet_status(&[a, b], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::Healthy);
}
#[test]
fn admin_intent_dominates_collapse() {
let mut a = ready("a");
a.admin_disabled = true;
let mut b = ready("b");
b.admin_disabled = true;
let mut c = ready("c");
c.reachable = false; let report =
compute_fleet_status(&[sig(a), sig(b), sig(c)], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert!(report.capacity_collapsed());
assert_eq!(report.problem_class, FleetProblemClass::AdminIntent);
assert!(report.problem_summary.contains("admin intent"));
}
#[test]
fn disk_pressure_classified_from_bypass_signal() {
let mut a = ready("a");
a.temporarily_bypassed = true;
let mut sa = sig(a);
sa.disk_pressure = true;
let mut b = ready("b");
b.temporarily_bypassed = true;
let mut sb = sig(b);
sb.disk_pressure = true;
let report = compute_fleet_status(&[sa, sb], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::DiskPressure);
}
#[test]
fn cloud_disappearance_when_hosts_missing() {
let mut a = ready("a");
a.in_daemon_pool = false;
a.reachable = false; let mut b = ready("b");
b.reachable = false; let report = compute_fleet_status(&[sig(a), sig(b)], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::CloudDisappearance);
}
#[test]
fn daemon_config_drift_when_recovered_not_rejoined() {
let mut a = ready("a");
a.in_daemon_pool = false;
a.reachable = true; let mut b = ready("b");
b.configured = false; let report = compute_fleet_status(&[sig(a), sig(b)], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::DaemonConfigDrift);
}
#[test]
fn missing_capability_when_facts_or_admissibility_fail() {
let mut a = ready("a");
a.facts_known = false;
let mut b = ready("b");
b.command_admissible = false;
let report = compute_fleet_status(&[sig(a), sig(b)], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::MissingCapability);
}
#[test]
fn absence_alerts_respect_threshold_and_sort_longest_first() {
let mut a = ready("a");
a.reachable = false;
let mut sa = sig(a);
sa.absent_secs = Some(1000);
let mut b = ready("b");
b.reachable = false;
let mut sb = sig(b);
sb.absent_secs = Some(60);
let mut c = ready("c");
c.reachable = false;
let mut sc = sig(c);
sc.absent_secs = Some(500);
let report = compute_fleet_status(&[sa, sb, sc], 300);
assert!(report.has_absence_warnings());
let ids: Vec<&str> = report
.absence_alerts
.iter()
.map(|x| x.worker_id.as_str())
.collect();
assert_eq!(ids, vec!["a", "c"]);
assert_eq!(report.absence_alerts[0].absent_secs, 1000);
assert_eq!(report.absence_alerts[0].threshold_secs, 300);
}
#[test]
fn ready_worker_never_raises_absence_alert() {
let mut s = sig(ready("a"));
s.absent_secs = Some(99_999); let report = compute_fleet_status(&[s], 300);
assert!(!report.has_absence_warnings());
}
#[test]
fn empty_fleet_is_healthy_with_no_alerts() {
let report = compute_fleet_status(&[], DEFAULT_ABSENCE_THRESHOLD_SECS);
assert_eq!(report.problem_class, FleetProblemClass::Healthy);
assert_eq!(report.problem_summary, "no workers configured");
assert!(!report.has_absence_warnings());
}
#[test]
fn report_serializes_with_stable_shape() {
let mut a = ready("a");
a.admin_disabled = true;
let report = compute_fleet_status(&[sig(a)], DEFAULT_ABSENCE_THRESHOLD_SECS);
let value = serde_json::to_value(&report).unwrap();
assert_eq!(value["problem_class"], "admin_intent");
assert_eq!(value["diff"]["state_counts"]["admin_disabled"], 1);
assert_eq!(value["absence_threshold_secs"], 300);
assert!(value["absence_alerts"].as_array().unwrap().is_empty());
let back: FleetStatusReport = serde_json::from_value(value).unwrap();
assert_eq!(back, report);
}
}