1use std::collections::BTreeMap;
19use std::fmt;
20
21use serde::{Deserialize, Serialize};
22
23use crate::errors::ErrorCode;
24use crate::schema_versions::{SchemaComponent, current_version};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum IncidentReasonCode {
32 NoAdmissibleWorkers,
34 CriticalPressure,
36 InsufficientSlots,
38 HardPreflight,
40 ActiveProjectExclusion,
42 MissingRuntimeToolchainTarget,
44 OsArchMismatch,
46 TelemetryStale,
48 CircuitOpen,
50 DaemonSocketRefused,
52 LocalFallback,
54 ProofRefusal,
56 RsyncVanishedFile,
58 ArtifactMiss,
60 QueueAmbiguity,
62 DiskFull,
64 WrongUserPathWorkerBinary,
66 ToolchainDrift,
71}
72
73impl IncidentReasonCode {
74 pub const ALL: &'static [IncidentReasonCode] = &[
77 Self::NoAdmissibleWorkers,
78 Self::CriticalPressure,
79 Self::InsufficientSlots,
80 Self::HardPreflight,
81 Self::ActiveProjectExclusion,
82 Self::MissingRuntimeToolchainTarget,
83 Self::OsArchMismatch,
84 Self::TelemetryStale,
85 Self::CircuitOpen,
86 Self::DaemonSocketRefused,
87 Self::LocalFallback,
88 Self::ProofRefusal,
89 Self::RsyncVanishedFile,
90 Self::ArtifactMiss,
91 Self::QueueAmbiguity,
92 Self::DiskFull,
93 Self::WrongUserPathWorkerBinary,
94 Self::ToolchainDrift,
95 ];
96
97 #[must_use]
100 pub const fn code(self) -> &'static str {
101 match self {
102 Self::NoAdmissibleWorkers => "RCH-I001",
103 Self::CriticalPressure => "RCH-I002",
104 Self::InsufficientSlots => "RCH-I003",
105 Self::HardPreflight => "RCH-I004",
106 Self::ActiveProjectExclusion => "RCH-I005",
107 Self::MissingRuntimeToolchainTarget => "RCH-I006",
108 Self::OsArchMismatch => "RCH-I007",
109 Self::TelemetryStale => "RCH-I008",
110 Self::CircuitOpen => "RCH-I009",
111 Self::DaemonSocketRefused => "RCH-I010",
112 Self::LocalFallback => "RCH-I011",
113 Self::ProofRefusal => "RCH-I012",
114 Self::RsyncVanishedFile => "RCH-I013",
115 Self::ArtifactMiss => "RCH-I014",
116 Self::QueueAmbiguity => "RCH-I015",
117 Self::DiskFull => "RCH-I016",
118 Self::WrongUserPathWorkerBinary => "RCH-I017",
119 Self::ToolchainDrift => "RCH-I018",
120 }
121 }
122
123 #[must_use]
127 pub const fn failure_class(self) -> &'static str {
128 match self {
129 Self::NoAdmissibleWorkers => "no admissible workers",
130 Self::CriticalPressure => "critical pressure",
131 Self::InsufficientSlots => "insufficient slots",
132 Self::HardPreflight => "hard preflight",
133 Self::ActiveProjectExclusion => "active project exclusion",
134 Self::MissingRuntimeToolchainTarget => "missing runtime/toolchain/Rust target",
135 Self::OsArchMismatch => "OS/arch mismatch",
136 Self::TelemetryStale => "telemetry stale/age unknown",
137 Self::CircuitOpen => "circuit open",
138 Self::DaemonSocketRefused => "daemon socket refused",
139 Self::LocalFallback => "local fallback",
140 Self::ProofRefusal => "proof refusal",
141 Self::RsyncVanishedFile => "rsync vanished file",
142 Self::ArtifactMiss => "artifact miss",
143 Self::QueueAmbiguity => "queue ambiguity",
144 Self::DiskFull => "disk full",
145 Self::WrongUserPathWorkerBinary => "wrong user/path worker binary",
146 Self::ToolchainDrift => "toolchain drift",
147 }
148 }
149
150 #[must_use]
154 pub fn error_code(self) -> Option<ErrorCode> {
155 Some(match self {
156 Self::NoAdmissibleWorkers => ErrorCode::WorkerNoneAvailable,
157 Self::CriticalPressure => ErrorCode::WorkerDiskPressureCritical,
158 Self::InsufficientSlots => ErrorCode::WorkerAtCapacity,
159 Self::MissingRuntimeToolchainTarget => ErrorCode::WorkerMissingToolchain,
160 Self::TelemetryStale => ErrorCode::WorkerTelemetryGap,
161 Self::CircuitOpen => ErrorCode::WorkerCircuitOpen,
162 Self::DaemonSocketRefused => ErrorCode::InternalDaemonSocket,
163 Self::RsyncVanishedFile => ErrorCode::TransferSourceMissing,
164 Self::ArtifactMiss => ErrorCode::BuildArtifactMissing,
165 Self::DiskFull => ErrorCode::TransferDiskFull,
166 Self::HardPreflight
168 | Self::ActiveProjectExclusion
169 | Self::OsArchMismatch
170 | Self::LocalFallback
171 | Self::ProofRefusal
172 | Self::QueueAmbiguity
173 | Self::WrongUserPathWorkerBinary
174 | Self::ToolchainDrift => return None,
176 })
177 }
178
179 #[must_use]
181 pub fn from_code_str(code: &str) -> Option<Self> {
182 Self::ALL.iter().copied().find(|r| r.code() == code)
183 }
184}
185
186impl fmt::Display for IncidentReasonCode {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.write_str(self.code())
189 }
190}
191
192impl Serialize for IncidentReasonCode {
193 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
194 s.serialize_str(self.code())
195 }
196}
197
198impl<'de> Deserialize<'de> for IncidentReasonCode {
199 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
200 let raw = String::deserialize(d)?;
201 Self::from_code_str(&raw)
202 .ok_or_else(|| serde::de::Error::custom(format!("unknown incident reason code: {raw}")))
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum IncidentEventType {
210 Selection,
211 Admission,
212 Fallback,
213 Proof,
214 Doctor,
215 Telemetry,
216 ArtifactRetrieval,
217 WorkerLifecycle,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum IncidentSource {
224 Hook,
225 Daemon,
226 Worker,
227 Doctor,
228 Cli,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum SelectedMode {
235 Local,
236 Remote,
237 Deferred,
238}
239
240#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ControlState {
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub requested_worker: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub requested_profile: Option<String>,
254 #[serde(default, skip_serializing_if = "is_false")]
256 pub strict_remote_policy: bool,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub queue_policy: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub visibility_mode: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub wait_timeout_ms: Option<u64>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub target_dir_policy: Option<String>,
269}
270
271impl ControlState {
272 #[must_use]
274 pub fn is_empty(&self) -> bool {
275 *self == ControlState::default()
276 }
277}
278
279#[allow(clippy::trivially_copy_pass_by_ref)]
281fn is_false(b: &bool) -> bool {
282 !*b
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct IncidentEvent {
289 pub schema_version: String,
291 pub event_type: IncidentEventType,
293 pub reason_code: IncidentReasonCode,
295 pub project_id: String,
297 pub command_fingerprint: String,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub worker_id: Option<String>,
302 pub selected_mode: SelectedMode,
304 pub local_fallback_allowed: bool,
306 pub source: IncidentSource,
308 pub occurred_at_unix_ms: u64,
311 #[serde(default, skip_serializing_if = "ControlState::is_empty")]
313 pub control: ControlState,
314 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
316 pub details: BTreeMap<String, String>,
317}
318
319impl IncidentEvent {
320 #[allow(clippy::too_many_arguments)]
328 #[must_use]
329 pub fn new(
330 event_type: IncidentEventType,
331 reason_code: IncidentReasonCode,
332 source: IncidentSource,
333 project_id: impl Into<String>,
334 command_fingerprint: impl Into<String>,
335 selected_mode: SelectedMode,
336 local_fallback_allowed: bool,
337 occurred_at_unix_ms: u64,
338 ) -> Self {
339 Self {
340 schema_version: incident_schema_version().to_string(),
341 event_type,
342 reason_code,
343 project_id: project_id.into(),
344 command_fingerprint: command_fingerprint.into(),
345 worker_id: None,
346 selected_mode,
347 local_fallback_allowed,
348 source,
349 occurred_at_unix_ms,
350 control: ControlState::default(),
351 details: BTreeMap::new(),
352 }
353 }
354
355 #[must_use]
357 pub fn with_worker_id(mut self, worker_id: impl Into<String>) -> Self {
358 self.worker_id = Some(worker_id.into());
359 self
360 }
361
362 #[must_use]
364 pub fn with_control(mut self, control: ControlState) -> Self {
365 self.control = control;
366 self
367 }
368
369 #[must_use]
371 pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
372 self.details.insert(key.into(), value.into());
373 self
374 }
375
376 #[must_use]
385 pub fn redacted(&self) -> Self {
386 let mut out = self.clone();
387 out.details = self
388 .details
389 .iter()
390 .map(|(k, v)| (k.clone(), crate::redaction::redact_secrets(v)))
391 .collect();
392 out
393 }
394}
395
396#[must_use]
398pub fn incident_schema_version() -> &'static str {
399 current_version(SchemaComponent::IncidentLedger)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn registry_covers_session_history_failure_classes_plus_appended() {
408 assert_eq!(IncidentReasonCode::ALL.len(), 18);
413 assert_eq!(
414 IncidentReasonCode::WrongUserPathWorkerBinary.code(),
415 "RCH-I017"
416 );
417 assert_eq!(IncidentReasonCode::ToolchainDrift.code(), "RCH-I018");
418 assert!(
419 !IncidentReasonCode::ToolchainDrift
420 .failure_class()
421 .is_empty()
422 );
423 }
424
425 #[test]
426 fn reason_codes_are_unique() {
427 let mut codes: Vec<&str> = IncidentReasonCode::ALL.iter().map(|r| r.code()).collect();
428 codes.sort_unstable();
429 let before = codes.len();
430 codes.dedup();
431 assert_eq!(before, codes.len(), "duplicate RCH-I codes in registry");
432 }
433
434 #[test]
435 fn failure_class_strings_are_unique() {
436 let mut classes: Vec<&str> = IncidentReasonCode::ALL
437 .iter()
438 .map(|r| r.failure_class())
439 .collect();
440 classes.sort_unstable();
441 let before = classes.len();
442 classes.dedup();
443 assert_eq!(before, classes.len(), "duplicate failure-class strings");
444 }
445
446 #[test]
447 fn codes_follow_rch_i_format_and_sequence() {
448 for (i, reason) in IncidentReasonCode::ALL.iter().enumerate() {
449 let expected = format!("RCH-I{:03}", i + 1);
450 assert_eq!(reason.code(), expected, "code out of sequence at {i}");
451 }
452 }
453
454 #[test]
455 fn reason_code_roundtrips_through_code_str() {
456 for reason in IncidentReasonCode::ALL {
457 assert_eq!(
458 IncidentReasonCode::from_code_str(reason.code()),
459 Some(*reason)
460 );
461 }
462 assert_eq!(IncidentReasonCode::from_code_str("RCH-I999"), None);
463 assert_eq!(IncidentReasonCode::from_code_str("nonsense"), None);
464 }
465
466 #[test]
467 fn reason_code_serializes_as_stable_string() {
468 let json = serde_json::to_string(&IncidentReasonCode::DiskFull).unwrap();
469 assert_eq!(json, "\"RCH-I016\"");
470 let back: IncidentReasonCode = serde_json::from_str("\"RCH-I016\"").unwrap();
471 assert_eq!(back, IncidentReasonCode::DiskFull);
472 }
473
474 #[test]
475 fn unknown_reason_code_deserialization_errors() {
476 let res: Result<IncidentReasonCode, _> = serde_json::from_str("\"RCH-I404\"");
477 assert!(res.is_err());
478 }
479
480 #[test]
481 fn error_code_mapping_is_present_where_expected() {
482 assert_eq!(
484 IncidentReasonCode::CircuitOpen.error_code(),
485 Some(ErrorCode::WorkerCircuitOpen)
486 );
487 assert_eq!(
488 IncidentReasonCode::DiskFull.error_code(),
489 Some(ErrorCode::TransferDiskFull)
490 );
491 assert_eq!(
492 IncidentReasonCode::ArtifactMiss.error_code(),
493 Some(ErrorCode::BuildArtifactMissing)
494 );
495 assert_eq!(IncidentReasonCode::LocalFallback.error_code(), None);
497 assert_eq!(IncidentReasonCode::ProofRefusal.error_code(), None);
498 }
499
500 fn sample_event() -> IncidentEvent {
501 IncidentEvent::new(
502 IncidentEventType::Admission,
503 IncidentReasonCode::NoAdmissibleWorkers,
504 IncidentSource::Daemon,
505 "proj-abc",
506 "cargo build --release",
507 SelectedMode::Local,
508 true,
509 1_700_000_000_000,
510 )
511 }
512
513 #[test]
514 fn incident_event_roundtrips() {
515 let event = sample_event()
516 .with_worker_id("css")
517 .with_detail("candidates", "3")
518 .with_control(ControlState {
519 strict_remote_policy: true,
520 requested_worker: Some("bil".to_string()),
521 wait_timeout_ms: Some(5000),
522 ..ControlState::default()
523 });
524 let json = serde_json::to_string(&event).unwrap();
525 let back: IncidentEvent = serde_json::from_str(&json).unwrap();
526 assert_eq!(event, back);
527 }
528
529 #[test]
530 fn incident_event_serialization_is_stable_golden() {
531 let event = sample_event();
532 let value = serde_json::to_value(&event).unwrap();
533 assert_eq!(value["schema_version"], "1.0.0");
535 assert_eq!(value["event_type"], "admission");
536 assert_eq!(value["reason_code"], "RCH-I001");
537 assert_eq!(value["project_id"], "proj-abc");
538 assert_eq!(value["command_fingerprint"], "cargo build --release");
539 assert_eq!(value["selected_mode"], "local");
540 assert_eq!(value["local_fallback_allowed"], true);
541 assert_eq!(value["source"], "daemon");
542 assert_eq!(value["occurred_at_unix_ms"], 1_700_000_000_000u64);
543 assert!(value.get("worker_id").is_none());
545 assert!(value.get("control").is_none());
546 assert!(value.get("details").is_none());
547 }
548
549 #[test]
550 fn control_state_fields_round_trip_and_omit_when_empty() {
551 let control = ControlState {
553 requested_worker: Some("css".to_string()),
554 requested_profile: Some("fast".to_string()),
555 strict_remote_policy: true,
556 queue_policy: Some("queue_when_busy".to_string()),
557 visibility_mode: Some("visible".to_string()),
558 wait_timeout_ms: Some(10_000),
559 target_dir_policy: Some("pooled".to_string()),
560 };
561 assert!(!control.is_empty());
562 let value = serde_json::to_value(&control).unwrap();
563 for key in [
564 "requested_worker",
565 "requested_profile",
566 "strict_remote_policy",
567 "queue_policy",
568 "visibility_mode",
569 "wait_timeout_ms",
570 "target_dir_policy",
571 ] {
572 assert!(value.get(key).is_some(), "missing control field {key}");
573 }
574 assert!(ControlState::default().is_empty());
576 let empty = serde_json::to_value(ControlState::default()).unwrap();
577 assert_eq!(empty.as_object().unwrap().len(), 0);
578 }
579
580 #[test]
581 fn incident_event_carries_current_schema_version() {
582 assert_eq!(incident_schema_version(), "1.0.0");
583 assert_eq!(sample_event().schema_version, incident_schema_version());
584 }
585}