use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::errors::ErrorCode;
use crate::schema_versions::{SchemaComponent, current_version};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IncidentReasonCode {
NoAdmissibleWorkers,
CriticalPressure,
InsufficientSlots,
HardPreflight,
ActiveProjectExclusion,
MissingRuntimeToolchainTarget,
OsArchMismatch,
TelemetryStale,
CircuitOpen,
DaemonSocketRefused,
LocalFallback,
ProofRefusal,
RsyncVanishedFile,
ArtifactMiss,
QueueAmbiguity,
DiskFull,
WrongUserPathWorkerBinary,
ToolchainDrift,
}
impl IncidentReasonCode {
pub const ALL: &'static [IncidentReasonCode] = &[
Self::NoAdmissibleWorkers,
Self::CriticalPressure,
Self::InsufficientSlots,
Self::HardPreflight,
Self::ActiveProjectExclusion,
Self::MissingRuntimeToolchainTarget,
Self::OsArchMismatch,
Self::TelemetryStale,
Self::CircuitOpen,
Self::DaemonSocketRefused,
Self::LocalFallback,
Self::ProofRefusal,
Self::RsyncVanishedFile,
Self::ArtifactMiss,
Self::QueueAmbiguity,
Self::DiskFull,
Self::WrongUserPathWorkerBinary,
Self::ToolchainDrift,
];
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::NoAdmissibleWorkers => "RCH-I001",
Self::CriticalPressure => "RCH-I002",
Self::InsufficientSlots => "RCH-I003",
Self::HardPreflight => "RCH-I004",
Self::ActiveProjectExclusion => "RCH-I005",
Self::MissingRuntimeToolchainTarget => "RCH-I006",
Self::OsArchMismatch => "RCH-I007",
Self::TelemetryStale => "RCH-I008",
Self::CircuitOpen => "RCH-I009",
Self::DaemonSocketRefused => "RCH-I010",
Self::LocalFallback => "RCH-I011",
Self::ProofRefusal => "RCH-I012",
Self::RsyncVanishedFile => "RCH-I013",
Self::ArtifactMiss => "RCH-I014",
Self::QueueAmbiguity => "RCH-I015",
Self::DiskFull => "RCH-I016",
Self::WrongUserPathWorkerBinary => "RCH-I017",
Self::ToolchainDrift => "RCH-I018",
}
}
#[must_use]
pub const fn failure_class(self) -> &'static str {
match self {
Self::NoAdmissibleWorkers => "no admissible workers",
Self::CriticalPressure => "critical pressure",
Self::InsufficientSlots => "insufficient slots",
Self::HardPreflight => "hard preflight",
Self::ActiveProjectExclusion => "active project exclusion",
Self::MissingRuntimeToolchainTarget => "missing runtime/toolchain/Rust target",
Self::OsArchMismatch => "OS/arch mismatch",
Self::TelemetryStale => "telemetry stale/age unknown",
Self::CircuitOpen => "circuit open",
Self::DaemonSocketRefused => "daemon socket refused",
Self::LocalFallback => "local fallback",
Self::ProofRefusal => "proof refusal",
Self::RsyncVanishedFile => "rsync vanished file",
Self::ArtifactMiss => "artifact miss",
Self::QueueAmbiguity => "queue ambiguity",
Self::DiskFull => "disk full",
Self::WrongUserPathWorkerBinary => "wrong user/path worker binary",
Self::ToolchainDrift => "toolchain drift",
}
}
#[must_use]
pub fn error_code(self) -> Option<ErrorCode> {
Some(match self {
Self::NoAdmissibleWorkers => ErrorCode::WorkerNoneAvailable,
Self::CriticalPressure => ErrorCode::WorkerDiskPressureCritical,
Self::InsufficientSlots => ErrorCode::WorkerAtCapacity,
Self::MissingRuntimeToolchainTarget => ErrorCode::WorkerMissingToolchain,
Self::TelemetryStale => ErrorCode::WorkerTelemetryGap,
Self::CircuitOpen => ErrorCode::WorkerCircuitOpen,
Self::DaemonSocketRefused => ErrorCode::InternalDaemonSocket,
Self::RsyncVanishedFile => ErrorCode::TransferSourceMissing,
Self::ArtifactMiss => ErrorCode::BuildArtifactMissing,
Self::DiskFull => ErrorCode::TransferDiskFull,
Self::HardPreflight
| Self::ActiveProjectExclusion
| Self::OsArchMismatch
| Self::LocalFallback
| Self::ProofRefusal
| Self::QueueAmbiguity
| Self::WrongUserPathWorkerBinary
| Self::ToolchainDrift => return None,
})
}
#[must_use]
pub fn from_code_str(code: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|r| r.code() == code)
}
}
impl fmt::Display for IncidentReasonCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
impl Serialize for IncidentReasonCode {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.code())
}
}
impl<'de> Deserialize<'de> for IncidentReasonCode {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
Self::from_code_str(&raw)
.ok_or_else(|| serde::de::Error::custom(format!("unknown incident reason code: {raw}")))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IncidentEventType {
Selection,
Admission,
Fallback,
Proof,
Doctor,
Telemetry,
ArtifactRetrieval,
WorkerLifecycle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IncidentSource {
Hook,
Daemon,
Worker,
Doctor,
Cli,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SelectedMode {
Local,
Remote,
Deferred,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ControlState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_worker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_profile: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub strict_remote_policy: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_dir_policy: Option<String>,
}
impl ControlState {
#[must_use]
pub fn is_empty(&self) -> bool {
*self == ControlState::default()
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IncidentEvent {
pub schema_version: String,
pub event_type: IncidentEventType,
pub reason_code: IncidentReasonCode,
pub project_id: String,
pub command_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
pub selected_mode: SelectedMode,
pub local_fallback_allowed: bool,
pub source: IncidentSource,
pub occurred_at_unix_ms: u64,
#[serde(default, skip_serializing_if = "ControlState::is_empty")]
pub control: ControlState,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub details: BTreeMap<String, String>,
}
impl IncidentEvent {
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn new(
event_type: IncidentEventType,
reason_code: IncidentReasonCode,
source: IncidentSource,
project_id: impl Into<String>,
command_fingerprint: impl Into<String>,
selected_mode: SelectedMode,
local_fallback_allowed: bool,
occurred_at_unix_ms: u64,
) -> Self {
Self {
schema_version: incident_schema_version().to_string(),
event_type,
reason_code,
project_id: project_id.into(),
command_fingerprint: command_fingerprint.into(),
worker_id: None,
selected_mode,
local_fallback_allowed,
source,
occurred_at_unix_ms,
control: ControlState::default(),
details: BTreeMap::new(),
}
}
#[must_use]
pub fn with_worker_id(mut self, worker_id: impl Into<String>) -> Self {
self.worker_id = Some(worker_id.into());
self
}
#[must_use]
pub fn with_control(mut self, control: ControlState) -> Self {
self.control = control;
self
}
#[must_use]
pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.details.insert(key.into(), value.into());
self
}
#[must_use]
pub fn redacted(&self) -> Self {
let mut out = self.clone();
out.details = self
.details
.iter()
.map(|(k, v)| (k.clone(), crate::redaction::redact_secrets(v)))
.collect();
out
}
}
#[must_use]
pub fn incident_schema_version() -> &'static str {
current_version(SchemaComponent::IncidentLedger)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_covers_session_history_failure_classes_plus_appended() {
assert_eq!(IncidentReasonCode::ALL.len(), 18);
assert_eq!(
IncidentReasonCode::WrongUserPathWorkerBinary.code(),
"RCH-I017"
);
assert_eq!(IncidentReasonCode::ToolchainDrift.code(), "RCH-I018");
assert!(
!IncidentReasonCode::ToolchainDrift
.failure_class()
.is_empty()
);
}
#[test]
fn reason_codes_are_unique() {
let mut codes: Vec<&str> = IncidentReasonCode::ALL.iter().map(|r| r.code()).collect();
codes.sort_unstable();
let before = codes.len();
codes.dedup();
assert_eq!(before, codes.len(), "duplicate RCH-I codes in registry");
}
#[test]
fn failure_class_strings_are_unique() {
let mut classes: Vec<&str> = IncidentReasonCode::ALL
.iter()
.map(|r| r.failure_class())
.collect();
classes.sort_unstable();
let before = classes.len();
classes.dedup();
assert_eq!(before, classes.len(), "duplicate failure-class strings");
}
#[test]
fn codes_follow_rch_i_format_and_sequence() {
for (i, reason) in IncidentReasonCode::ALL.iter().enumerate() {
let expected = format!("RCH-I{:03}", i + 1);
assert_eq!(reason.code(), expected, "code out of sequence at {i}");
}
}
#[test]
fn reason_code_roundtrips_through_code_str() {
for reason in IncidentReasonCode::ALL {
assert_eq!(
IncidentReasonCode::from_code_str(reason.code()),
Some(*reason)
);
}
assert_eq!(IncidentReasonCode::from_code_str("RCH-I999"), None);
assert_eq!(IncidentReasonCode::from_code_str("nonsense"), None);
}
#[test]
fn reason_code_serializes_as_stable_string() {
let json = serde_json::to_string(&IncidentReasonCode::DiskFull).unwrap();
assert_eq!(json, "\"RCH-I016\"");
let back: IncidentReasonCode = serde_json::from_str("\"RCH-I016\"").unwrap();
assert_eq!(back, IncidentReasonCode::DiskFull);
}
#[test]
fn unknown_reason_code_deserialization_errors() {
let res: Result<IncidentReasonCode, _> = serde_json::from_str("\"RCH-I404\"");
assert!(res.is_err());
}
#[test]
fn error_code_mapping_is_present_where_expected() {
assert_eq!(
IncidentReasonCode::CircuitOpen.error_code(),
Some(ErrorCode::WorkerCircuitOpen)
);
assert_eq!(
IncidentReasonCode::DiskFull.error_code(),
Some(ErrorCode::TransferDiskFull)
);
assert_eq!(
IncidentReasonCode::ArtifactMiss.error_code(),
Some(ErrorCode::BuildArtifactMissing)
);
assert_eq!(IncidentReasonCode::LocalFallback.error_code(), None);
assert_eq!(IncidentReasonCode::ProofRefusal.error_code(), None);
}
fn sample_event() -> IncidentEvent {
IncidentEvent::new(
IncidentEventType::Admission,
IncidentReasonCode::NoAdmissibleWorkers,
IncidentSource::Daemon,
"proj-abc",
"cargo build --release",
SelectedMode::Local,
true,
1_700_000_000_000,
)
}
#[test]
fn incident_event_roundtrips() {
let event = sample_event()
.with_worker_id("css")
.with_detail("candidates", "3")
.with_control(ControlState {
strict_remote_policy: true,
requested_worker: Some("bil".to_string()),
wait_timeout_ms: Some(5000),
..ControlState::default()
});
let json = serde_json::to_string(&event).unwrap();
let back: IncidentEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, back);
}
#[test]
fn incident_event_serialization_is_stable_golden() {
let event = sample_event();
let value = serde_json::to_value(&event).unwrap();
assert_eq!(value["schema_version"], "1.0.0");
assert_eq!(value["event_type"], "admission");
assert_eq!(value["reason_code"], "RCH-I001");
assert_eq!(value["project_id"], "proj-abc");
assert_eq!(value["command_fingerprint"], "cargo build --release");
assert_eq!(value["selected_mode"], "local");
assert_eq!(value["local_fallback_allowed"], true);
assert_eq!(value["source"], "daemon");
assert_eq!(value["occurred_at_unix_ms"], 1_700_000_000_000u64);
assert!(value.get("worker_id").is_none());
assert!(value.get("control").is_none());
assert!(value.get("details").is_none());
}
#[test]
fn control_state_fields_round_trip_and_omit_when_empty() {
let control = ControlState {
requested_worker: Some("css".to_string()),
requested_profile: Some("fast".to_string()),
strict_remote_policy: true,
queue_policy: Some("queue_when_busy".to_string()),
visibility_mode: Some("visible".to_string()),
wait_timeout_ms: Some(10_000),
target_dir_policy: Some("pooled".to_string()),
};
assert!(!control.is_empty());
let value = serde_json::to_value(&control).unwrap();
for key in [
"requested_worker",
"requested_profile",
"strict_remote_policy",
"queue_policy",
"visibility_mode",
"wait_timeout_ms",
"target_dir_policy",
] {
assert!(value.get(key).is_some(), "missing control field {key}");
}
assert!(ControlState::default().is_empty());
let empty = serde_json::to_value(ControlState::default()).unwrap();
assert_eq!(empty.as_object().unwrap().len(), 0);
}
#[test]
fn incident_event_carries_current_schema_version() {
assert_eq!(incident_schema_version(), "1.0.0");
assert_eq!(sample_event().schema_version, incident_schema_version());
}
}