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