Skip to main content

ito_core/orchestrate/
plan.rs

1//! Orchestrator run planning.
2//!
3//! Builds a deterministic, dependency-aware execution plan for a multi-change
4//! orchestration run.
5
6use crate::errors::{CoreError, CoreResult};
7use crate::orchestrate::gates::default_gate_order;
8use crate::orchestrate::types::{
9    FailurePolicy, GatePolicy, OrchestrateRunConfig, parse_max_parallel,
10};
11use ito_domain::changes::ChangeOrchestrateMetadata;
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet, VecDeque};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16/// One gate in a planned pipeline.
17pub struct PlannedGate {
18    /// Gate identifier (e.g. `tests`, `code-review`).
19    pub name: String,
20    /// Whether the gate should run or be recorded as skipped.
21    pub policy: GatePolicy,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25/// Planned execution for a single change.
26pub struct PlannedChange {
27    /// Canonical change id.
28    pub id: String,
29    /// Canonical change ids that must complete before this change begins.
30    #[serde(default)]
31    pub depends_on: Vec<String>,
32    /// Gate pipeline for this change.
33    pub gates: Vec<PlannedGate>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37/// Resolved plan for an orchestration run.
38pub struct RunPlan {
39    /// Run id.
40    pub run_id: String,
41    /// Preset name used for this run.
42    pub preset: String,
43    /// Maximum number of concurrent change pipelines.
44    pub max_parallel: usize,
45    /// Run-level failure policy.
46    pub failure_policy: FailurePolicy,
47    /// Planned changes in dependency order.
48    pub changes: Vec<PlannedChange>,
49}
50
51#[derive(Debug, Clone)]
52/// Input required to plan a change.
53pub struct ChangePlanInput {
54    /// Canonical change id.
55    pub id: String,
56    /// Per-change orchestration metadata.
57    pub orchestrate: ChangeOrchestrateMetadata,
58}
59
60/// Build a dependency-aware run plan.
61///
62/// Dependencies are enforced via a topological sort. Cycles are rejected.
63pub fn build_run_plan(
64    run_id: &str,
65    preset: &str,
66    config: OrchestrateRunConfig,
67    changes: Vec<ChangePlanInput>,
68) -> CoreResult<RunPlan> {
69    let max_parallel = parse_max_parallel(config.max_parallel, config.max_parallel_cap)?;
70    let failure_policy = config.failure_policy.unwrap_or(FailurePolicy::Remediate);
71
72    let change_ids = collect_change_ids(&changes);
73    let deps = build_dependency_map(&changes, &change_ids);
74
75    let ordered_ids = topo_sort(&deps)?;
76    let mut changes_by_id: BTreeMap<String, ChangePlanInput> = BTreeMap::new();
77    for c in changes {
78        changes_by_id.insert(c.id.clone(), c);
79    }
80
81    let default_order = if config.gate_order.is_empty() {
82        default_gate_order()
83    } else {
84        config.gate_order.clone()
85    };
86    let skip_gates = config.skip_gates;
87
88    let mut planned = Vec::new();
89    for id in ordered_ids {
90        let Some(input) = changes_by_id.remove(&id) else {
91            continue;
92        };
93
94        let gate_names = resolve_gate_names(&input, &default_order);
95        let gates = build_planned_gates(gate_names, &skip_gates);
96
97        let depends_on = deps
98            .get(&input.id)
99            .map(|s| s.iter().cloned().collect())
100            .unwrap_or_default();
101
102        planned.push(PlannedChange {
103            id: input.id,
104            depends_on,
105            gates,
106        });
107    }
108
109    Ok(RunPlan {
110        run_id: run_id.to_string(),
111        preset: preset.to_string(),
112        max_parallel,
113        failure_policy,
114        changes: planned,
115    })
116}
117
118fn collect_change_ids(changes: &[ChangePlanInput]) -> BTreeSet<String> {
119    let mut change_ids = BTreeSet::new();
120    for change in changes {
121        change_ids.insert(change.id.clone());
122    }
123    change_ids
124}
125
126fn build_dependency_map(
127    changes: &[ChangePlanInput],
128    change_ids: &BTreeSet<String>,
129) -> BTreeMap<String, BTreeSet<String>> {
130    let mut deps = BTreeMap::new();
131    for change in changes {
132        let depends_on = change
133            .orchestrate
134            .depends_on
135            .iter()
136            .filter(|dep| change_ids.contains(dep.as_str()))
137            .cloned()
138            .collect();
139        deps.insert(change.id.clone(), depends_on);
140    }
141    deps
142}
143
144fn resolve_gate_names(input: &ChangePlanInput, default_order: &[String]) -> Vec<String> {
145    if !input.orchestrate.preferred_gates.is_empty() {
146        return input.orchestrate.preferred_gates.clone();
147    }
148
149    default_order.to_vec()
150}
151
152fn build_planned_gates(gate_names: Vec<String>, skip_gates: &BTreeSet<String>) -> Vec<PlannedGate> {
153    gate_names
154        .into_iter()
155        .map(|name| PlannedGate {
156            policy: gate_policy_for_name(skip_gates, &name),
157            name,
158        })
159        .collect()
160}
161
162fn gate_policy_for_name(skip_gates: &BTreeSet<String>, gate_name: &str) -> GatePolicy {
163    if skip_gates.contains(gate_name) {
164        return GatePolicy::Skip;
165    }
166
167    GatePolicy::Run
168}
169
170fn topo_sort(deps: &BTreeMap<String, BTreeSet<String>>) -> CoreResult<Vec<String>> {
171    let mut indegree: BTreeMap<String, usize> = BTreeMap::new();
172    let mut reverse: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
173
174    for (node, node_deps) in deps {
175        indegree.insert(node.clone(), node_deps.len());
176        for dep in node_deps {
177            reverse.entry(dep.clone()).or_default().insert(node.clone());
178        }
179    }
180
181    let mut q: VecDeque<String> = VecDeque::new();
182    for (node, d) in &indegree {
183        if *d == 0 {
184            q.push_back(node.clone());
185        }
186    }
187
188    let mut out = Vec::new();
189    let mut indegree = indegree;
190    while let Some(n) = q.pop_front() {
191        out.push(n.clone());
192        let Some(children) = reverse.get(&n) else {
193            continue;
194        };
195        for child in children {
196            if let Some(d) = indegree.get_mut(child) {
197                *d = d.saturating_sub(1);
198                if *d == 0 {
199                    q.push_back(child.clone());
200                }
201            }
202        }
203    }
204
205    if out.len() == deps.len() {
206        return Ok(out);
207    }
208
209    // Cycle detection for a clearer error message.
210    let cycle = find_cycle(deps).unwrap_or_else(|| vec!["<unknown>".to_string()]);
211    Err(CoreError::Validation(format!(
212        "circular depends_on cycle detected: {}",
213        cycle.join(" -> ")
214    )))
215}
216
217fn find_cycle(deps: &BTreeMap<String, BTreeSet<String>>) -> Option<Vec<String>> {
218    #[derive(Clone, Copy, PartialEq, Eq)]
219    enum Mark {
220        Temp,
221        Perm,
222    }
223
224    fn dfs(
225        node: &str,
226        deps: &BTreeMap<String, BTreeSet<String>>,
227        marks: &mut BTreeMap<String, Mark>,
228        stack: &mut Vec<String>,
229    ) -> Option<Vec<String>> {
230        match marks.get(node) {
231            Some(Mark::Temp) => {
232                return Some(build_cycle(stack, node));
233            }
234            Some(Mark::Perm) => {
235                return None;
236            }
237            None => {}
238        }
239
240        marks.insert(node.to_string(), Mark::Temp);
241        stack.push(node.to_string());
242        if let Some(next) = deps.get(node) {
243            for dep in next {
244                if let Some(cycle) = dfs(dep, deps, marks, stack) {
245                    return Some(cycle);
246                }
247            }
248        }
249        stack.pop();
250        marks.insert(node.to_string(), Mark::Perm);
251        None
252    }
253
254    let mut marks: BTreeMap<String, Mark> = BTreeMap::new();
255    for node in deps.keys() {
256        let mut stack = Vec::new();
257        if let Some(cycle) = dfs(node, deps, &mut marks, &mut stack) {
258            return Some(cycle);
259        }
260    }
261    None
262}
263
264fn build_cycle(stack: &[String], node: &str) -> Vec<String> {
265    let start = stack
266        .iter()
267        .position(|candidate| candidate == node)
268        .unwrap_or(0);
269    let mut cycle = stack[start..].to_vec();
270    cycle.push(node.to_string());
271    cycle
272}