use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::{Deserialize, Serialize};
pub const REPAIR_ACTION_GRAPH_SCHEMA_V1: &str = "ee.repair_action_graph.v1";
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RepairActionGraphError {
DuplicateActionId(String),
UnknownPrerequisite { from: String, missing: String },
DependencyCycle { contains: String },
}
impl std::fmt::Display for RepairActionGraphError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DuplicateActionId(id) => write!(f, "duplicate action id: {id:?}"),
Self::UnknownPrerequisite { from, missing } => write!(
f,
"action {from:?} declares prerequisite {missing:?}, which is not in the action set"
),
Self::DependencyCycle { contains } => {
write!(
f,
"dependency cycle detected (contains action {contains:?})"
)
}
}
}
}
impl std::error::Error for RepairActionGraphError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionKind {
ShellCommand,
EeSubcommand,
ExternalTool,
ManualStep,
}
impl ActionKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ShellCommand => "shell_command",
Self::EeSubcommand => "ee_subcommand",
Self::ExternalTool => "external_tool",
Self::ManualStep => "manual_step",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Critical,
High,
Medium,
Low,
}
impl Priority {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Critical => "critical",
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
#[must_use]
pub fn sort_key(self) -> u8 {
match self {
Self::Critical => 0,
Self::High => 1,
Self::Medium => 2,
Self::Low => 3,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionContext {
UserShell,
EeSubcommand,
ExternalTool,
}
impl ExecutionContext {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::UserShell => "user_shell",
Self::EeSubcommand => "ee_subcommand",
Self::ExternalTool => "external_tool",
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ExpectedOutcome {
#[serde(rename = "resolvesChecks", default)]
pub resolves_checks: Vec<String>,
#[serde(rename = "preconditionsForNextActions", default)]
pub preconditions_for_next_actions: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RepairAction {
pub id: String,
pub kind: ActionKind,
pub command: String,
#[serde(rename = "humanReadable")]
pub human_readable: String,
#[serde(default)]
pub prerequisites: Vec<String>,
#[serde(rename = "expectedOutcome", default)]
pub expected_outcome: ExpectedOutcome,
pub priority: Priority,
#[serde(rename = "estimatedDurationSeconds")]
pub estimated_duration_seconds: u32,
pub reversible: bool,
#[serde(rename = "reversalCommand", default)]
pub reversal_command: Option<String>,
#[serde(rename = "requiresUserConfirmation")]
pub requires_user_confirmation: bool,
#[serde(rename = "executionContext")]
pub execution_context: ExecutionContext,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RepairActionGraph {
pub schema: String,
pub actions: Vec<RepairAction>,
#[serde(rename = "topologicallyOrderedExecution")]
pub topologically_ordered_execution: Vec<String>,
#[serde(rename = "parallelizableGroups")]
pub parallelizable_groups: Vec<Vec<String>>,
#[serde(rename = "estimatedTotalDurationSeconds")]
pub estimated_total_duration_seconds: u64,
}
pub fn build_repair_action_graph(
actions: Vec<RepairAction>,
) -> Result<RepairActionGraph, RepairActionGraphError> {
let mut by_id: BTreeMap<String, RepairAction> = BTreeMap::new();
for action in actions {
let key = action.id.clone();
if by_id.insert(key.clone(), action).is_some() {
return Err(RepairActionGraphError::DuplicateActionId(key));
}
}
for action in by_id.values() {
for prereq in &action.prerequisites {
if !by_id.contains_key(prereq) {
return Err(RepairActionGraphError::UnknownPrerequisite {
from: action.id.clone(),
missing: prereq.clone(),
});
}
}
}
let topo_groups = kahn_layers(&by_id)?;
let topologically_ordered: Vec<String> = topo_groups
.iter()
.flat_map(|layer| layer.iter().cloned())
.collect();
let mut reverse_adjacency: BTreeMap<String, Vec<String>> = BTreeMap::new();
for action in by_id.values() {
for prereq in &action.prerequisites {
reverse_adjacency
.entry(prereq.clone())
.or_default()
.push(action.id.clone());
}
}
let mut actions_out: Vec<RepairAction> = by_id.into_values().collect();
for action in &mut actions_out {
if action
.expected_outcome
.preconditions_for_next_actions
.is_empty()
{
if let Some(downstream) = reverse_adjacency.get(&action.id) {
let mut sorted = downstream.clone();
sorted.sort();
sorted.dedup();
action.expected_outcome.preconditions_for_next_actions = sorted;
}
}
}
actions_out.sort_by(|a, b| a.id.cmp(&b.id));
let estimated_total_duration_seconds = actions_out
.iter()
.map(|action| u64::from(action.estimated_duration_seconds))
.sum();
Ok(RepairActionGraph {
schema: REPAIR_ACTION_GRAPH_SCHEMA_V1.to_owned(),
actions: actions_out,
topologically_ordered_execution: topologically_ordered,
parallelizable_groups: topo_groups,
estimated_total_duration_seconds,
})
}
fn kahn_layers(
by_id: &BTreeMap<String, RepairAction>,
) -> Result<Vec<Vec<String>>, RepairActionGraphError> {
let mut in_degree: BTreeMap<String, usize> =
by_id.keys().map(|id| (id.clone(), 0_usize)).collect();
for action in by_id.values() {
for _ in &action.prerequisites {
*in_degree.entry(action.id.clone()).or_default() += 1;
}
}
let mut adjacency: BTreeMap<String, Vec<String>> = BTreeMap::new();
for action in by_id.values() {
for prereq in &action.prerequisites {
adjacency
.entry(prereq.clone())
.or_default()
.push(action.id.clone());
}
}
let mut layers: Vec<Vec<String>> = Vec::new();
let mut placed: BTreeSet<String> = BTreeSet::new();
while placed.len() < by_id.len() {
let mut current_layer: Vec<String> = in_degree
.iter()
.filter(|(id, deg)| **deg == 0 && !placed.contains(*id))
.map(|(id, _)| id.clone())
.collect();
if current_layer.is_empty() {
let cycle_root = in_degree
.iter()
.find(|(id, deg)| **deg > 0 && !placed.contains(*id))
.map(|(id, _)| id.clone())
.unwrap_or_else(|| "<unknown>".to_owned());
return Err(RepairActionGraphError::DependencyCycle {
contains: cycle_root,
});
}
current_layer.sort_by(|a, b| {
let pa = by_id
.get(a)
.map(|act| act.priority.sort_key())
.unwrap_or(u8::MAX);
let pb = by_id
.get(b)
.map(|act| act.priority.sort_key())
.unwrap_or(u8::MAX);
pa.cmp(&pb).then_with(|| a.cmp(b))
});
let mut next_queue: VecDeque<String> = VecDeque::new();
for id in ¤t_layer {
placed.insert(id.clone());
if let Some(children) = adjacency.get(id) {
for child in children {
if let Some(deg) = in_degree.get_mut(child) {
if *deg > 0 {
*deg -= 1;
if *deg == 0 {
next_queue.push_back(child.clone());
}
}
}
}
}
}
layers.push(current_layer);
let _ = next_queue;
}
Ok(layers)
}
#[cfg(test)]
mod tests {
use super::*;
fn action(
id: &str,
prerequisites: &[&str],
priority: Priority,
duration_seconds: u32,
) -> RepairAction {
RepairAction {
id: id.to_owned(),
kind: ActionKind::ShellCommand,
command: format!("echo {id}"),
human_readable: format!("run {id}"),
prerequisites: prerequisites.iter().map(|s| (*s).to_owned()).collect(),
expected_outcome: ExpectedOutcome::default(),
priority,
estimated_duration_seconds: duration_seconds,
reversible: false,
reversal_command: None,
requires_user_confirmation: false,
execution_context: ExecutionContext::UserShell,
}
}
#[test]
fn schema_constant_matches_documented_version() {
assert_eq!(REPAIR_ACTION_GRAPH_SCHEMA_V1, "ee.repair_action_graph.v1");
}
#[test]
fn enum_strings_match_snake_case_serde() {
for variant in [
ActionKind::ShellCommand,
ActionKind::EeSubcommand,
ActionKind::ExternalTool,
ActionKind::ManualStep,
] {
let serialized = serde_json::to_string(&variant).expect("serialize");
assert!(serialized.contains(variant.as_str()), "{serialized}");
}
for variant in [
Priority::Critical,
Priority::High,
Priority::Medium,
Priority::Low,
] {
let serialized = serde_json::to_string(&variant).expect("serialize");
assert!(serialized.contains(variant.as_str()), "{serialized}");
}
for variant in [
ExecutionContext::UserShell,
ExecutionContext::EeSubcommand,
ExecutionContext::ExternalTool,
] {
let serialized = serde_json::to_string(&variant).expect("serialize");
assert!(serialized.contains(variant.as_str()), "{serialized}");
}
}
#[test]
fn empty_action_set_builds_empty_graph() {
let graph = build_repair_action_graph(Vec::new()).expect("empty graph builds");
assert_eq!(graph.schema, REPAIR_ACTION_GRAPH_SCHEMA_V1);
assert!(graph.actions.is_empty());
assert!(graph.topologically_ordered_execution.is_empty());
assert!(graph.parallelizable_groups.is_empty());
assert_eq!(graph.estimated_total_duration_seconds, 0);
}
#[test]
fn single_action_with_no_prereqs_is_one_layer() {
let actions = vec![action("a", &[], Priority::Medium, 5)];
let graph = build_repair_action_graph(actions).expect("graph builds");
assert_eq!(graph.parallelizable_groups, vec![vec!["a".to_owned()]]);
assert_eq!(graph.topologically_ordered_execution, vec!["a".to_owned()]);
assert_eq!(graph.estimated_total_duration_seconds, 5);
}
#[test]
fn linear_dependency_chain_produces_layers_in_order() {
let actions = vec![
action("c", &["b"], Priority::Medium, 1),
action("a", &[], Priority::Medium, 1),
action("b", &["a"], Priority::Medium, 1),
];
let graph = build_repair_action_graph(actions).expect("graph builds");
assert_eq!(
graph.parallelizable_groups,
vec![
vec!["a".to_owned()],
vec!["b".to_owned()],
vec!["c".to_owned()],
]
);
assert_eq!(
graph.topologically_ordered_execution,
vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]
);
}
#[test]
fn independent_actions_collapse_into_one_parallelizable_layer() {
let actions = vec![
action("z", &[], Priority::Critical, 2),
action("a", &[], Priority::Medium, 2),
action("m", &[], Priority::Medium, 2),
];
let graph = build_repair_action_graph(actions).expect("graph builds");
assert_eq!(graph.parallelizable_groups.len(), 1);
assert_eq!(
graph.parallelizable_groups[0],
vec!["z".to_owned(), "a".to_owned(), "m".to_owned()]
);
}
#[test]
fn cycle_is_detected_and_returned_with_root() {
let actions = vec![
action("a", &["b"], Priority::Medium, 1),
action("b", &["a"], Priority::Medium, 1),
];
let err = build_repair_action_graph(actions).expect_err("cycle detected");
match err {
RepairActionGraphError::DependencyCycle { contains } => {
assert!(contains == "a" || contains == "b", "{contains}");
}
other => panic!("expected DependencyCycle, got {other:?}"),
}
}
#[test]
fn duplicate_id_is_rejected_with_the_offending_id() {
let actions = vec![
action("dup", &[], Priority::Medium, 1),
action("dup", &[], Priority::Medium, 1),
];
let err = build_repair_action_graph(actions).expect_err("duplicate rejected");
match err {
RepairActionGraphError::DuplicateActionId(id) => assert_eq!(id, "dup"),
other => panic!("expected DuplicateActionId, got {other:?}"),
}
}
#[test]
fn unknown_prerequisite_is_rejected_with_both_action_ids() {
let actions = vec![action("present", &["missing"], Priority::Medium, 1)];
let err = build_repair_action_graph(actions).expect_err("unknown prereq rejected");
match err {
RepairActionGraphError::UnknownPrerequisite { from, missing } => {
assert_eq!(from, "present");
assert_eq!(missing, "missing");
}
other => panic!("expected UnknownPrerequisite, got {other:?}"),
}
}
#[test]
fn empty_expected_outcome_is_auto_populated_from_reverse_adjacency() {
let actions = vec![
action("root", &[], Priority::Medium, 1),
action("child_a", &["root"], Priority::Medium, 1),
action("child_b", &["root"], Priority::Medium, 1),
];
let graph = build_repair_action_graph(actions).expect("graph builds");
let root = graph
.actions
.iter()
.find(|a| a.id == "root")
.expect("root present");
assert_eq!(
root.expected_outcome.preconditions_for_next_actions,
vec!["child_a".to_owned(), "child_b".to_owned()]
);
let child_a = graph
.actions
.iter()
.find(|a| a.id == "child_a")
.expect("child_a present");
assert!(
child_a
.expected_outcome
.preconditions_for_next_actions
.is_empty()
);
}
#[test]
fn caller_provided_expected_outcome_is_preserved() {
let mut root = action("root", &[], Priority::Medium, 1);
root.expected_outcome.preconditions_for_next_actions = vec!["manually-supplied".to_owned()];
let actions = vec![root, action("child", &["root"], Priority::Medium, 1)];
let graph = build_repair_action_graph(actions).expect("graph builds");
let root = graph
.actions
.iter()
.find(|a| a.id == "root")
.expect("root present");
assert_eq!(
root.expected_outcome.preconditions_for_next_actions,
vec!["manually-supplied".to_owned()],
"caller value must not be overwritten by reverse-adjacency"
);
}
#[test]
fn estimated_total_duration_sums_all_actions() {
let actions = vec![
action("a", &[], Priority::Medium, 10),
action("b", &["a"], Priority::Medium, 5),
action("c", &["b"], Priority::Medium, 15),
];
let graph = build_repair_action_graph(actions).expect("graph builds");
assert_eq!(graph.estimated_total_duration_seconds, 30);
}
#[test]
fn round_trip_serialization_preserves_envelope() {
let mut act = action("act", &[], Priority::High, 7);
act.reversible = true;
act.reversal_command = Some("ee mesh disable".to_owned());
act.expected_outcome.resolves_checks = vec!["check_1".to_owned()];
act.execution_context = ExecutionContext::EeSubcommand;
act.kind = ActionKind::EeSubcommand;
act.requires_user_confirmation = true;
let graph = build_repair_action_graph(vec![act]).expect("graph builds");
let serialized = serde_json::to_string(&graph).expect("serialize");
let parsed: RepairActionGraph = serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(parsed, graph);
assert!(serialized.contains(REPAIR_ACTION_GRAPH_SCHEMA_V1));
assert!(serialized.contains("\"resolvesChecks\""));
assert!(serialized.contains("\"preconditionsForNextActions\""));
assert!(serialized.contains("\"reversalCommand\""));
assert!(serialized.contains("\"requiresUserConfirmation\""));
assert!(serialized.contains("\"executionContext\""));
}
#[test]
fn within_layer_ordering_is_priority_then_lex() {
let actions = vec![
action("zz_low", &[], Priority::Low, 1),
action("aa_critical", &[], Priority::Critical, 1),
action("bb_low", &[], Priority::Low, 1),
action("cc_medium", &[], Priority::Medium, 1),
];
let graph = build_repair_action_graph(actions).expect("graph builds");
assert_eq!(
graph.parallelizable_groups[0],
vec![
"aa_critical".to_owned(),
"cc_medium".to_owned(),
"bb_low".to_owned(),
"zz_low".to_owned(),
]
);
}
#[test]
fn diamond_dependency_collapses_correctly() {
let actions = vec![
action("a", &[], Priority::Medium, 1),
action("b", &["a"], Priority::Medium, 1),
action("c", &["a"], Priority::Medium, 1),
action("d", &["b", "c"], Priority::Medium, 1),
];
let graph = build_repair_action_graph(actions).expect("graph builds");
assert_eq!(graph.parallelizable_groups.len(), 3);
assert_eq!(graph.parallelizable_groups[0], vec!["a".to_owned()]);
assert_eq!(
graph.parallelizable_groups[1],
vec!["b".to_owned(), "c".to_owned()]
);
assert_eq!(graph.parallelizable_groups[2], vec!["d".to_owned()]);
}
}