1use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct WorkerObservation {
26 pub worker_id: String,
27 pub configured: bool,
29 pub in_daemon_pool: bool,
31 pub reachable: bool,
33 pub admin_disabled: bool,
35 pub temporarily_bypassed: bool,
37 pub facts_known: bool,
39 pub command_admissible: bool,
41}
42
43impl WorkerObservation {
44 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum WorkerDiffState {
65 Ready,
67 MissingFromFleet,
70 RecoveredNotRejoined,
72 AdminDisabled,
74 TemporarilyBypassed,
76 Unreachable,
78 FactsUnknown,
80 CommandIneligible,
82 Unconfigured,
84}
85
86impl WorkerDiffState {
87 #[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 #[must_use]
105 pub fn is_usable(self) -> bool {
106 matches!(self, WorkerDiffState::Ready)
107 }
108}
109
110#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct WorkerDiffRow {
150 pub worker_id: String,
151 pub state: WorkerDiffState,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct FleetDiff {
157 pub workers: Vec<WorkerDiffRow>,
158 pub state_counts: BTreeMap<String, usize>,
160 pub ready_workers: usize,
162 pub explanation: String,
164}
165
166impl FleetDiff {
167 #[must_use]
169 pub fn capacity_collapsed(&self) -> bool {
170 self.ready_workers == 0 && !self.workers.is_empty()
171 }
172
173 #[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#[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 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 #[test]
232 fn missing_configured_worker() {
233 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 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 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 let mut obs = WorkerObservation::ready("css");
267 obs.command_admissible = false;
268 assert_eq!(derive_worker_diff(&obs), WorkerDiffState::CommandIneligible);
269 }
270
271 #[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; assert_eq!(
285 derive_worker_diff(&unreachable),
286 WorkerDiffState::Unreachable
287 );
288
289 let mut orphan = WorkerObservation::ready("c");
290 orphan.configured = false; 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 #[test]
305 fn fleet_diff_explains_capacity_collapse() {
306 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}