use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::admission_rejection::{AdmissionRejectionCategory, AdmissionRejectionSummary};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextAction {
QueueProof,
Defer,
ShrinkScope,
WaitForBypassRecovery,
RefreshCapabilities,
ForceResync,
FixWorkerCapability,
ContactOperator,
}
impl NextAction {
pub const ALL: &'static [NextAction] = &[
Self::QueueProof,
Self::Defer,
Self::ShrinkScope,
Self::WaitForBypassRecovery,
Self::RefreshCapabilities,
Self::ForceResync,
Self::FixWorkerCapability,
Self::ContactOperator,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
NextAction::QueueProof => "queue_proof",
NextAction::Defer => "defer",
NextAction::ShrinkScope => "shrink_scope",
NextAction::WaitForBypassRecovery => "wait_for_bypass_recovery",
NextAction::RefreshCapabilities => "refresh_capabilities",
NextAction::ForceResync => "force_resync",
NextAction::FixWorkerCapability => "fix_worker_capability",
NextAction::ContactOperator => "contact_operator",
}
}
#[must_use]
pub fn agent_hint(self) -> &'static str {
match self {
NextAction::QueueProof => "queue the proof and reattach with the job id",
NextAction::Defer => "defer and retry when capacity frees up",
NextAction::ShrinkScope => "shrink command scope (fewer crates/targets) and retry",
NextAction::WaitForBypassRecovery => "wait for the bypassed worker to recover",
NextAction::RefreshCapabilities => "refresh capabilities/classifier, then retry",
NextAction::ForceResync => "force a source/path-dependency resync, then retry",
NextAction::FixWorkerCapability => {
"install the missing runtime/toolchain/target on a worker"
}
NextAction::ContactOperator => "contact an operator (structural/config issue)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recommendation {
pub category: AdmissionRejectionCategory,
pub action: NextAction,
pub hint: String,
}
#[must_use]
pub fn recommend(category: AdmissionRejectionCategory, proof_mode: bool) -> NextAction {
use AdmissionRejectionCategory as C;
match category {
C::CriticalPressure => NextAction::WaitForBypassRecovery,
C::InsufficientSlots => {
if proof_mode {
NextAction::QueueProof
} else {
NextAction::Defer
}
}
C::CircuitOpen => NextAction::WaitForBypassRecovery,
C::TelemetryStale => NextAction::RefreshCapabilities,
C::HardPreflight => NextAction::ShrinkScope,
C::MissingRuntime | C::MissingToolchain | C::MissingRustTarget => {
NextAction::FixWorkerCapability
}
C::OsArchMismatch => NextAction::ContactOperator,
C::ClassifierLocalDrift => NextAction::RefreshCapabilities,
C::ActiveProjectExclusion => NextAction::ForceResync,
C::ProjectExcluded => NextAction::ContactOperator,
}
}
#[must_use]
pub fn recommendation_for(
category: AdmissionRejectionCategory,
proof_mode: bool,
) -> Recommendation {
let action = recommend(category, proof_mode);
Recommendation {
category,
action,
hint: action.agent_hint().to_string(),
}
}
#[must_use]
pub fn recommend_for_summary(
summary: &AdmissionRejectionSummary,
proof_mode: bool,
) -> Vec<Recommendation> {
let present: BTreeSet<&str> = summary.by_category.keys().map(String::as_str).collect();
AdmissionRejectionCategory::ALL
.iter()
.filter(|c| present.contains(c.as_str()))
.map(|c| recommendation_for(*c, proof_mode))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admission_rejection::{CandidateRejection, aggregate_rejections};
#[test]
fn every_taxonomy_reason_has_a_recommendation() {
for category in AdmissionRejectionCategory::ALL {
for proof_mode in [false, true] {
let rec = recommendation_for(*category, proof_mode);
assert_eq!(rec.category, *category);
assert!(!rec.hint.is_empty());
}
}
}
#[test]
fn proof_mode_queues_proof_for_transient_capacity() {
assert_eq!(
recommend(AdmissionRejectionCategory::InsufficientSlots, true),
NextAction::QueueProof
);
assert_eq!(
recommend(AdmissionRejectionCategory::InsufficientSlots, false),
NextAction::Defer
);
}
#[test]
fn missing_capability_says_fix_worker_capability() {
for c in [
AdmissionRejectionCategory::MissingRuntime,
AdmissionRejectionCategory::MissingToolchain,
AdmissionRejectionCategory::MissingRustTarget,
] {
assert_eq!(recommend(c, false), NextAction::FixWorkerCapability);
}
}
#[test]
fn structural_reasons_contact_operator() {
assert_eq!(
recommend(AdmissionRejectionCategory::OsArchMismatch, false),
NextAction::ContactOperator
);
assert_eq!(
recommend(AdmissionRejectionCategory::ProjectExcluded, true),
NextAction::ContactOperator
);
}
#[test]
fn drift_and_stale_refresh_capabilities() {
assert_eq!(
recommend(AdmissionRejectionCategory::ClassifierLocalDrift, false),
NextAction::RefreshCapabilities
);
assert_eq!(
recommend(AdmissionRejectionCategory::TelemetryStale, false),
NextAction::RefreshCapabilities
);
}
#[test]
fn active_project_exclusion_recommends_force_resync() {
assert_eq!(
recommend(AdmissionRejectionCategory::ActiveProjectExclusion, false),
NextAction::ForceResync
);
}
#[test]
fn health_reasons_wait_for_recovery() {
assert_eq!(
recommend(AdmissionRejectionCategory::CircuitOpen, false),
NextAction::WaitForBypassRecovery
);
assert_eq!(
recommend(AdmissionRejectionCategory::CriticalPressure, false),
NextAction::WaitForBypassRecovery
);
}
#[test]
fn all_eight_actions_are_reachable_from_the_taxonomy() {
let mut seen = BTreeSet::new();
for category in AdmissionRejectionCategory::ALL {
for proof_mode in [false, true] {
seen.insert(recommend(*category, proof_mode));
}
}
for action in NextAction::ALL {
assert!(seen.contains(action), "{} unreachable", action.as_str());
}
}
#[test]
fn hints_are_short_enough_to_act_on() {
for action in NextAction::ALL {
assert!(
action.agent_hint().len() <= 80,
"hint too long: {}",
action.as_str()
);
}
}
#[test]
fn action_tokens_are_unique() {
let mut tokens: Vec<&str> = NextAction::ALL.iter().map(|a| a.as_str()).collect();
tokens.sort_unstable();
let before = tokens.len();
tokens.dedup();
assert_eq!(before, tokens.len());
}
#[test]
fn recommend_for_summary_dedups_and_orders() {
let summary = aggregate_rejections(
3,
&[
CandidateRejection {
worker_id: "a".into(),
category: AdmissionRejectionCategory::MissingRustTarget,
},
CandidateRejection {
worker_id: "b".into(),
category: AdmissionRejectionCategory::MissingRustTarget,
},
CandidateRejection {
worker_id: "c".into(),
category: AdmissionRejectionCategory::CircuitOpen,
},
],
);
let recs = recommend_for_summary(&summary, false);
assert_eq!(recs.len(), 2, "deduped to distinct categories");
assert_eq!(recs[0].category, AdmissionRejectionCategory::CircuitOpen);
assert_eq!(recs[0].action, NextAction::WaitForBypassRecovery);
assert_eq!(
recs[1].category,
AdmissionRejectionCategory::MissingRustTarget
);
assert_eq!(recs[1].action, NextAction::FixWorkerCapability);
}
#[test]
fn recommendation_serializes_with_stable_tokens() {
let rec = recommendation_for(AdmissionRejectionCategory::MissingToolchain, false);
let value = serde_json::to_value(&rec).unwrap();
assert_eq!(value["category"], "missing_toolchain");
assert_eq!(value["action"], "fix_worker_capability");
let back: Recommendation = serde_json::from_value(value).unwrap();
assert_eq!(back, rec);
}
}