use serde::{Deserialize, Serialize};
use crate::admission_recommendation::NextAction;
use crate::disk_pressure_report::ExecFailureClass;
use crate::incident::IncidentReasonCode;
use crate::proof_policy::{ProofRefusal, ProofRefusalReason};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProofFailureClass {
ProductFailure,
InfrastructureFailure,
AdmissionDenial,
LocalFallbackRefused,
}
impl ProofFailureClass {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ProofFailureClass::ProductFailure => "product_failure",
ProofFailureClass::InfrastructureFailure => "infrastructure_failure",
ProofFailureClass::AdmissionDenial => "admission_denial",
ProofFailureClass::LocalFallbackRefused => "local_fallback_refused",
}
}
pub const ALL: &'static [ProofFailureClass] = &[
Self::ProductFailure,
Self::InfrastructureFailure,
Self::AdmissionDenial,
Self::LocalFallbackRefused,
];
#[must_use]
pub fn from_exec_failure(class: ExecFailureClass) -> Self {
match class {
ExecFailureClass::ProductCompile => Self::ProductFailure,
ExecFailureClass::WorkerEnvironment => Self::InfrastructureFailure,
ExecFailureClass::Indeterminate => Self::InfrastructureFailure,
}
}
#[must_use]
pub fn from_incident_reason(code: IncidentReasonCode) -> Self {
match code {
IncidentReasonCode::DaemonSocketRefused
| IncidentReasonCode::RsyncVanishedFile
| IncidentReasonCode::DiskFull
| IncidentReasonCode::CriticalPressure
| IncidentReasonCode::WrongUserPathWorkerBinary
| IncidentReasonCode::ArtifactMiss => Self::InfrastructureFailure,
IncidentReasonCode::LocalFallback => Self::LocalFallbackRefused,
_ => Self::AdmissionDenial,
}
}
}
fn refusal_class(reason: ProofRefusalReason) -> ProofFailureClass {
match reason {
ProofRefusalReason::ClassifierChoseLocal => ProofFailureClass::LocalFallbackRefused,
ProofRefusalReason::NoAdmissibleWorker
| ProofRefusalReason::UnsafeCommandShape
| ProofRefusalReason::PlacementProfileUnhonored => ProofFailureClass::AdmissionDenial,
}
}
#[must_use]
pub fn next_action_for_refusal(reason: ProofRefusalReason) -> NextAction {
match reason {
ProofRefusalReason::NoAdmissibleWorker => NextAction::QueueProof,
ProofRefusalReason::ClassifierChoseLocal => NextAction::RefreshCapabilities,
ProofRefusalReason::UnsafeCommandShape => NextAction::ShrinkScope,
ProofRefusalReason::PlacementProfileUnhonored => NextAction::ContactOperator,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofHandoff {
pub proof_intent_id: String,
pub failure_class: ProofFailureClass,
pub reason_code: IncidentReasonCode,
pub next_action: NextAction,
pub replay_command: String,
pub detail: String,
}
impl ProofHandoff {
#[must_use]
pub fn for_refusal(
intent_id: impl Into<String>,
command: &str,
refusal: &ProofRefusal,
) -> Self {
let next_action = next_action_for_refusal(refusal.reason);
Self {
proof_intent_id: intent_id.into(),
failure_class: refusal_class(refusal.reason),
reason_code: refusal.incident_reason,
next_action,
replay_command: format!("rch exec --proof -- {command}"),
detail: refusal.detail.clone(),
}
}
#[must_use]
pub fn for_queued(intent_id: impl Into<String>, command: &str) -> Self {
let intent_id = intent_id.into();
Self {
replay_command: format!("rch exec --proof -- {command}"),
detail: format!("proof queued; reattach with intent {intent_id}"),
proof_intent_id: intent_id,
failure_class: ProofFailureClass::AdmissionDenial,
reason_code: IncidentReasonCode::InsufficientSlots,
next_action: NextAction::QueueProof,
}
}
#[must_use]
pub fn is_product_failure(&self) -> bool {
self.failure_class == ProofFailureClass::ProductFailure
}
#[must_use]
pub fn render(&self) -> String {
format!(
"proof handoff [{}] {} ({}) -> {} | replay: {}",
self.proof_intent_id,
self.failure_class.as_str(),
self.reason_code.code(),
self.next_action.as_str(),
self.replay_command,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refusal_for_no_admissible_worker_is_admission_denial_queue() {
let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
let handoff = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal);
assert_eq!(handoff.failure_class, ProofFailureClass::AdmissionDenial);
assert_eq!(handoff.reason_code, IncidentReasonCode::ProofRefusal);
assert_eq!(handoff.next_action, NextAction::QueueProof);
assert_eq!(handoff.replay_command, "rch exec --proof -- cargo build");
assert_eq!(handoff.proof_intent_id, "intent-1");
}
#[test]
fn classifier_chose_local_is_local_fallback_refused() {
let refusal = ProofRefusal::new(ProofRefusalReason::ClassifierChoseLocal);
let handoff = ProofHandoff::for_refusal("i2", "cargo fmt", &refusal);
assert_eq!(
handoff.failure_class,
ProofFailureClass::LocalFallbackRefused
);
assert_eq!(handoff.next_action, NextAction::RefreshCapabilities);
}
#[test]
fn unsafe_shape_and_placement_reasons_map_through() {
let unsafe_h = ProofHandoff::for_refusal(
"i",
"x",
&ProofRefusal::new(ProofRefusalReason::UnsafeCommandShape),
);
assert_eq!(unsafe_h.next_action, NextAction::ShrinkScope);
assert_eq!(unsafe_h.failure_class, ProofFailureClass::AdmissionDenial);
let place_h = ProofHandoff::for_refusal(
"i",
"x",
&ProofRefusal::new(ProofRefusalReason::PlacementProfileUnhonored),
);
assert_eq!(place_h.next_action, NextAction::ContactOperator);
}
#[test]
fn queued_handoff_reattaches_by_intent() {
let handoff = ProofHandoff::for_queued("intent-q", "cargo test");
assert_eq!(handoff.next_action, NextAction::QueueProof);
assert!(handoff.detail.contains("intent-q"));
assert!(handoff.replay_command.contains("cargo test"));
}
#[test]
fn failure_class_from_exec_failure() {
assert_eq!(
ProofFailureClass::from_exec_failure(ExecFailureClass::ProductCompile),
ProofFailureClass::ProductFailure
);
assert_eq!(
ProofFailureClass::from_exec_failure(ExecFailureClass::WorkerEnvironment),
ProofFailureClass::InfrastructureFailure
);
}
#[test]
fn failure_class_from_incident_reason() {
assert_eq!(
ProofFailureClass::from_incident_reason(IncidentReasonCode::DaemonSocketRefused),
ProofFailureClass::InfrastructureFailure
);
assert_eq!(
ProofFailureClass::from_incident_reason(IncidentReasonCode::DiskFull),
ProofFailureClass::InfrastructureFailure
);
assert_eq!(
ProofFailureClass::from_incident_reason(IncidentReasonCode::NoAdmissibleWorkers),
ProofFailureClass::AdmissionDenial
);
assert_eq!(
ProofFailureClass::from_incident_reason(IncidentReasonCode::LocalFallback),
ProofFailureClass::LocalFallbackRefused
);
}
#[test]
fn json_carries_all_four_discriminators() {
let tokens: Vec<&str> = ProofFailureClass::ALL.iter().map(|c| c.as_str()).collect();
assert!(tokens.contains(&"product_failure"));
assert!(tokens.contains(&"infrastructure_failure"));
assert!(tokens.contains(&"admission_denial"));
assert!(tokens.contains(&"local_fallback_refused"));
}
#[test]
fn handoff_serializes_standalone_without_beads() {
let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
let handoff = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal);
let value = serde_json::to_value(&handoff).unwrap();
assert_eq!(value["failure_class"], "admission_denial");
assert_eq!(value["reason_code"], "RCH-I012");
assert_eq!(value["next_action"], "queue_proof");
assert_eq!(value["proof_intent_id"], "intent-1");
let back: ProofHandoff = serde_json::from_value(value).unwrap();
assert_eq!(back, handoff);
}
#[test]
fn render_is_concise_and_machine_greppable() {
let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
let line = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal).render();
assert!(line.contains("intent-1"));
assert!(line.contains("admission_denial"));
assert!(line.contains("queue_proof"));
assert!(line.contains("replay:"));
}
}