1use serde::{Deserialize, Serialize};
19
20use crate::admission_rejection::AdmissionRejectionCategory;
21use crate::incident::{IncidentEvent, IncidentReasonCode};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ReadinessSplit {
26 pub daemon_reachable: bool,
28 pub desired_fleet_workers: u32,
30 pub live_healthy_workers: u32,
32 pub command_admissible_workers: u32,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum DecisiveBlocker {
40 None,
42 DaemonDown,
44 ProofRefused,
46 NoDesiredFleet,
48 NoHealthyWorkers,
50 NoCommandCapability,
52 PressureBlocked,
54 ArtifactMissed,
56}
57
58impl DecisiveBlocker {
59 #[must_use]
61 pub fn as_str(self) -> &'static str {
62 match self {
63 DecisiveBlocker::None => "none",
64 DecisiveBlocker::DaemonDown => "daemon_down",
65 DecisiveBlocker::ProofRefused => "proof_refused",
66 DecisiveBlocker::NoDesiredFleet => "no_desired_fleet",
67 DecisiveBlocker::NoHealthyWorkers => "no_healthy_workers",
68 DecisiveBlocker::NoCommandCapability => "no_command_capability",
69 DecisiveBlocker::PressureBlocked => "pressure_blocked",
70 DecisiveBlocker::ArtifactMissed => "artifact_missed",
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct IncidentChainEntry {
78 pub reason_code: IncidentReasonCode,
80 pub occurred_at_unix_ms: u64,
82}
83
84impl From<&IncidentEvent> for IncidentChainEntry {
85 fn from(event: &IncidentEvent) -> Self {
86 Self {
87 reason_code: event.reason_code,
88 occurred_at_unix_ms: event.occurred_at_unix_ms,
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct ReadinessInputs {
96 pub split: ReadinessSplit,
97 pub proof_refused: bool,
99 pub dominant_rejection: Option<AdmissionRejectionCategory>,
102 pub recent_incidents: Vec<IncidentChainEntry>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ReadinessReport {
109 pub split: ReadinessSplit,
110 pub remote_ready: bool,
113 pub blocker: DecisiveBlocker,
114 pub incident_chain: Vec<IncidentChainEntry>,
116 pub detail: String,
117}
118
119impl ReadinessReport {
120 #[must_use]
124 pub fn invariant_holds(&self) -> bool {
125 !self.remote_ready || self.split.command_admissible_workers > 0
126 }
127}
128
129#[must_use]
135pub fn assess_readiness(inputs: &ReadinessInputs) -> ReadinessReport {
136 let s = &inputs.split;
137 let blocker = if !s.daemon_reachable {
138 DecisiveBlocker::DaemonDown
139 } else if inputs.proof_refused {
140 DecisiveBlocker::ProofRefused
141 } else if s.desired_fleet_workers == 0 {
142 DecisiveBlocker::NoDesiredFleet
143 } else if s.live_healthy_workers == 0 {
144 DecisiveBlocker::NoHealthyWorkers
145 } else if s.command_admissible_workers == 0 {
146 if inputs.dominant_rejection == Some(AdmissionRejectionCategory::CriticalPressure) {
147 DecisiveBlocker::PressureBlocked
148 } else {
149 DecisiveBlocker::NoCommandCapability
150 }
151 } else if inputs
152 .recent_incidents
153 .last()
154 .is_some_and(|e| e.reason_code == IncidentReasonCode::ArtifactMiss)
155 {
156 DecisiveBlocker::ArtifactMissed
157 } else {
158 DecisiveBlocker::None
159 };
160
161 let remote_ready = blocker == DecisiveBlocker::None;
162 let detail = match blocker {
163 DecisiveBlocker::None => "remote execution ready".to_string(),
164 DecisiveBlocker::DaemonDown => "daemon socket unreachable".to_string(),
165 DecisiveBlocker::ProofRefused => "proof mode refused before execution".to_string(),
166 DecisiveBlocker::NoDesiredFleet => "no workers configured in the fleet".to_string(),
167 DecisiveBlocker::NoHealthyWorkers => {
168 format!(
169 "{} desired worker(s) but none live/healthy",
170 s.desired_fleet_workers
171 )
172 }
173 DecisiveBlocker::NoCommandCapability => format!(
174 "{} healthy worker(s) but none admissible for this command",
175 s.live_healthy_workers
176 ),
177 DecisiveBlocker::PressureBlocked => {
178 "admissibility blocked by critical resource pressure".to_string()
179 }
180 DecisiveBlocker::ArtifactMissed => {
181 "ready, but the most recent attempt failed at artifact retrieval".to_string()
182 }
183 };
184
185 ReadinessReport {
186 split: *s,
187 remote_ready,
188 blocker,
189 incident_chain: inputs.recent_incidents.clone(),
190 detail,
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn entry(reason: IncidentReasonCode, ts: u64) -> IncidentChainEntry {
199 IncidentChainEntry {
200 reason_code: reason,
201 occurred_at_unix_ms: ts,
202 }
203 }
204
205 fn split(daemon: bool, desired: u32, healthy: u32, admissible: u32) -> ReadinessSplit {
206 ReadinessSplit {
207 daemon_reachable: daemon,
208 desired_fleet_workers: desired,
209 live_healthy_workers: healthy,
210 command_admissible_workers: admissible,
211 }
212 }
213
214 fn inputs(split: ReadinessSplit) -> ReadinessInputs {
215 ReadinessInputs {
216 split,
217 proof_refused: false,
218 dominant_rejection: None,
219 recent_incidents: Vec::new(),
220 }
221 }
222
223 #[test]
226 fn remote_ready_never_appears_with_zero_admissible() {
227 for daemon in [false, true] {
229 for desired in [0u32, 2] {
230 for healthy in [0u32, 2] {
231 let report = assess_readiness(&inputs(split(daemon, desired, healthy, 0)));
232 assert!(!report.remote_ready, "ready with 0 admissible: {report:?}");
233 assert!(report.invariant_holds());
234 }
235 }
236 }
237 }
238
239 #[test]
242 fn golden_healthy() {
243 let report = assess_readiness(&inputs(split(true, 3, 3, 2)));
244 let v = serde_json::to_value(&report).unwrap();
245 assert_eq!(v["remote_ready"], true);
246 assert_eq!(v["blocker"], "none");
247 assert_eq!(v["split"]["command_admissible_workers"], 2);
248 assert!(report.invariant_holds());
249 }
250
251 #[test]
252 fn golden_daemon_down() {
253 let report = assess_readiness(&inputs(split(false, 3, 0, 0)));
254 let v = serde_json::to_value(&report).unwrap();
255 assert_eq!(v["remote_ready"], false);
256 assert_eq!(v["blocker"], "daemon_down");
257 }
258
259 #[test]
260 fn golden_healthy_but_no_command_capability() {
261 let report = assess_readiness(&inputs(split(true, 3, 3, 0)));
263 let v = serde_json::to_value(&report).unwrap();
264 assert_eq!(v["remote_ready"], false);
265 assert_eq!(v["blocker"], "no_command_capability");
266 assert_eq!(v["split"]["live_healthy_workers"], 3);
267 }
268
269 #[test]
270 fn golden_pressure_blocked() {
271 let mut i = inputs(split(true, 3, 3, 0));
272 i.dominant_rejection = Some(AdmissionRejectionCategory::CriticalPressure);
273 let report = assess_readiness(&i);
274 let v = serde_json::to_value(&report).unwrap();
275 assert_eq!(v["remote_ready"], false);
276 assert_eq!(v["blocker"], "pressure_blocked");
277 }
278
279 #[test]
280 fn golden_proof_refused() {
281 let mut i = inputs(split(true, 3, 3, 2));
282 i.proof_refused = true;
283 let report = assess_readiness(&i);
284 let v = serde_json::to_value(&report).unwrap();
285 assert_eq!(v["remote_ready"], false);
286 assert_eq!(v["blocker"], "proof_refused");
287 }
288
289 #[test]
290 fn golden_artifact_missed() {
291 let mut i = inputs(split(true, 3, 3, 2));
293 i.recent_incidents = vec![
294 entry(IncidentReasonCode::LocalFallback, 1_700_000_000_000),
295 entry(IncidentReasonCode::ArtifactMiss, 1_700_000_001_000),
296 ];
297 let report = assess_readiness(&i);
298 let v = serde_json::to_value(&report).unwrap();
299 assert_eq!(v["remote_ready"], false);
300 assert_eq!(v["blocker"], "artifact_missed");
301 assert_eq!(v["incident_chain"][0]["reason_code"], "RCH-I011");
303 assert_eq!(v["incident_chain"][1]["reason_code"], "RCH-I014");
304 assert_eq!(
305 v["incident_chain"][1]["occurred_at_unix_ms"],
306 1_700_000_001_000u64
307 );
308 }
309
310 #[test]
313 fn daemon_down_outranks_everything() {
314 let mut i = inputs(split(false, 0, 0, 0));
315 i.proof_refused = true;
316 assert_eq!(assess_readiness(&i).blocker, DecisiveBlocker::DaemonDown);
317 }
318
319 #[test]
320 fn no_healthy_distinct_from_no_desired_and_no_capability() {
321 assert_eq!(
322 assess_readiness(&inputs(split(true, 0, 0, 0))).blocker,
323 DecisiveBlocker::NoDesiredFleet
324 );
325 assert_eq!(
326 assess_readiness(&inputs(split(true, 3, 0, 0))).blocker,
327 DecisiveBlocker::NoHealthyWorkers
328 );
329 assert_eq!(
330 assess_readiness(&inputs(split(true, 3, 3, 0))).blocker,
331 DecisiveBlocker::NoCommandCapability
332 );
333 }
334
335 #[test]
336 fn artifact_miss_only_blocks_when_it_is_the_latest_incident() {
337 let mut i = inputs(split(true, 3, 3, 2));
339 i.recent_incidents = vec![
340 entry(IncidentReasonCode::ArtifactMiss, 1_700_000_000_000),
341 entry(IncidentReasonCode::LocalFallback, 1_700_000_001_000),
342 ];
343 assert_eq!(assess_readiness(&i).blocker, DecisiveBlocker::None);
344 assert!(assess_readiness(&i).remote_ready);
345 }
346
347 #[test]
348 fn report_round_trips() {
349 let report = assess_readiness(&inputs(split(true, 3, 3, 2)));
350 let v = serde_json::to_value(&report).unwrap();
351 let back: ReadinessReport = serde_json::from_value(v).unwrap();
352 assert_eq!(back, report);
353 }
354
355 #[test]
356 fn incident_chain_entry_from_event() {
357 use crate::incident::{IncidentEvent, IncidentEventType, IncidentSource, SelectedMode};
358 let event = IncidentEvent::new(
359 IncidentEventType::ArtifactRetrieval,
360 IncidentReasonCode::ArtifactMiss,
361 IncidentSource::Hook,
362 "proj",
363 "cargo build",
364 SelectedMode::Remote,
365 true,
366 1_700_000_002_000,
367 );
368 let chain_entry = IncidentChainEntry::from(&event);
369 assert_eq!(chain_entry.reason_code, IncidentReasonCode::ArtifactMiss);
370 assert_eq!(chain_entry.occurred_at_unix_ms, 1_700_000_002_000);
371 }
372}