Skip to main content

oneai_workflow/
dag.rs

1//! Workflow DAG data structure — dependency graph for workflow execution.
2//!
3//! The DAG (Directed Acyclic Graph) represents the compiled workflow:
4//! - Nodes are workflow steps
5//! - Edges are dependencies (parent → child means "parent must complete before child starts")
6//!
7//! The DAG supports:
8//! - Topological sorting for sequential execution order
9//! - Parallel level detection (steps at the same level can run concurrently)
10//! - Path-based dependency queries
11//! - Cycle detection
12
13use std::collections::{BTreeMap, HashSet, VecDeque};
14
15use serde::{Deserialize, Serialize};
16
17use crate::config::StepConfig;
18
19/// A node in the workflow DAG — represents a compiled step.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct DagNode {
22    /// The step ID (unique within the DAG).
23    pub id: String,
24
25    /// The step configuration.
26    pub step: StepConfig,
27
28    /// IDs of nodes this node depends on (must complete before this one).
29    #[serde(default)]
30    pub depends_on: Vec<String>,
31
32    /// IDs of nodes that depend on this node (this must complete before them).
33    #[serde(default)]
34    pub children: Vec<String>,
35
36    /// The parallel level (0 = root, 1 = first generation, etc.).
37    #[serde(default)]
38    pub level: usize,
39}
40
41/// The workflow DAG — compiled from WorkflowConfig.
42///
43/// Contains the full dependency graph and supports:
44/// - Topological ordering
45/// - Parallel level grouping
46/// - Dependency queries
47/// - Root/leaf detection
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct WorkflowDag {
50    /// The workflow name.
51    pub name: String,
52
53    /// All nodes in the DAG, keyed by step ID.
54    ///
55    /// `BTreeMap` (not `HashMap`) is intentional: it makes iteration order
56    /// deterministic across runs, so `roots`/`leaves`/`levels` are stable —
57    /// important for reproducible workflow compilation and snapshot tests.
58    pub nodes: BTreeMap<String, DagNode>,
59
60    /// The execution levels (parallel groups).
61    /// Level 0 contains root nodes (no dependencies),
62    /// Level 1 contains nodes that depend only on Level 0, etc.
63    pub levels: Vec<Vec<String>>,
64
65    /// Root node IDs (no dependencies).
66    pub roots: Vec<String>,
67
68    /// Leaf node IDs (no children).
69    pub leaves: Vec<String>,
70}
71
72impl WorkflowDag {
73    /// Create an empty DAG.
74    pub fn new(name: impl Into<String>) -> Self {
75        Self {
76            name: name.into(),
77            nodes: BTreeMap::new(),
78            levels: Vec::new(),
79            roots: Vec::new(),
80            leaves: Vec::new(),
81        }
82    }
83
84    /// Add a node to the DAG.
85    pub fn add_node(&mut self, node: DagNode) {
86        self.nodes.insert(node.id.clone(), node);
87    }
88
89    /// Build the dependency edges and compute levels.
90    ///
91    /// After all nodes are added, call this to:
92    /// 1. Resolve `depends_on` → set `children` on parent nodes
93    /// 2. Compute parallel levels via BFS
94    /// 3. Identify root and leaf nodes
95    pub fn build(&mut self) {
96        // Build children from depends_on (collect first to avoid borrow conflict)
97        let children_map: BTreeMap<String, Vec<String>> = {
98            let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
99            for node in self.nodes.values() {
100                for parent_id in &node.depends_on {
101                    map.entry(parent_id.clone())
102                        .or_default()
103                        .push(node.id.clone());
104                }
105            }
106            map
107        };
108
109        // Apply children to parent nodes
110        for (parent_id, children) in &children_map {
111            if let Some(parent) = self.nodes.get_mut(parent_id) {
112                for child_id in children {
113                    if !parent.children.contains(child_id) {
114                        parent.children.push(child_id.clone());
115                    }
116                }
117            }
118        }
119
120        // Find roots (nodes with no dependencies)
121        self.roots = self
122            .nodes
123            .values()
124            .filter(|n| n.depends_on.is_empty())
125            .map(|n| n.id.clone())
126            .collect();
127
128        // Find leaves (nodes with no children)
129        self.leaves = self
130            .nodes
131            .values()
132            .filter(|n| n.children.is_empty())
133            .map(|n| n.id.clone())
134            .collect();
135
136        // Compute parallel levels via BFS
137        self.compute_levels();
138    }
139
140    /// Compute parallel levels via topological BFS.
141    ///
142    /// Each level contains nodes that can be executed concurrently
143    /// (all their dependencies are in earlier levels).
144    fn compute_levels(&mut self) {
145        self.levels.clear();
146
147        // Track which nodes have been assigned to a level
148        let mut assigned: HashSet<String> = HashSet::new();
149        let mut current_level: Vec<String> = self.roots.clone();
150
151        if current_level.is_empty() && !self.nodes.is_empty() {
152            // No roots — this means all nodes depend on something,
153            // which is only possible if there are cycles (invalid DAG)
154            return;
155        }
156
157        while !current_level.is_empty() {
158            // Set level on nodes
159            for node_id in &current_level {
160                if let Some(node) = self.nodes.get_mut(node_id) {
161                    node.level = self.levels.len();
162                }
163                assigned.insert(node_id.clone());
164            }
165
166            self.levels.push(current_level.clone());
167
168            // Find next level: nodes whose all dependencies are in assigned
169            let mut next_level: Vec<String> = Vec::new();
170            for (id, node) in &self.nodes {
171                if assigned.contains(id) {
172                    continue;
173                }
174                // Check if all dependencies are assigned
175                let all_deps_done = node.depends_on.iter().all(|dep| assigned.contains(dep));
176                if all_deps_done {
177                    next_level.push(id.clone());
178                }
179            }
180
181            current_level = next_level;
182        }
183    }
184
185    /// Get a node by ID.
186    pub fn get_node(&self, id: &str) -> Option<&DagNode> {
187        self.nodes.get(id)
188    }
189
190    /// Get the number of nodes.
191    pub fn node_count(&self) -> usize {
192        self.nodes.len()
193    }
194
195    /// Get the number of levels (parallel groups).
196    pub fn level_count(&self) -> usize {
197        self.levels.len()
198    }
199
200    /// Get nodes at a specific level.
201    pub fn level_nodes(&self, level: usize) -> Vec<&DagNode> {
202        if level >= self.levels.len() {
203            return Vec::new();
204        }
205        self.levels[level]
206            .iter()
207            .filter_map(|id| self.nodes.get(id))
208            .collect()
209    }
210
211    /// Get all node IDs in topological order (level 0 → level N).
212    pub fn topological_order(&self) -> Vec<String> {
213        self.levels.iter().flatten().cloned().collect()
214    }
215
216    /// Check if the DAG has any cycles.
217    ///
218    /// Uses Kahn's algorithm — if after removing all nodes with no
219    /// dependencies, some nodes remain, there's a cycle.
220    pub fn has_cycle(&self) -> bool {
221        let mut in_degree: BTreeMap<String, usize> =
222            self.nodes.keys().map(|id| (id.clone(), 0)).collect();
223
224        for node in self.nodes.values() {
225            in_degree.insert(node.id.clone(), node.depends_on.len());
226        }
227
228        let mut queue: VecDeque<String> = in_degree
229            .iter()
230            .filter(|(_, deg)| **deg == 0)
231            .map(|(id, _)| id.clone())
232            .collect();
233
234        let mut visited_count = 0;
235        while let Some(id) = queue.pop_front() {
236            visited_count += 1;
237            if let Some(node) = self.nodes.get(&id) {
238                for child_id in &node.children {
239                    if let Some(deg) = in_degree.get_mut(child_id) {
240                        *deg -= 1;
241                        if *deg == 0 {
242                            queue.push_back(child_id.clone());
243                        }
244                    }
245                }
246            }
247        }
248
249        visited_count != self.nodes.len()
250    }
251
252    /// Get all dependencies (transitive) of a node.
253    pub fn transitive_deps(&self, node_id: &str) -> HashSet<String> {
254        let mut deps = HashSet::new();
255        let mut to_visit = VecDeque::new();
256
257        if let Some(node) = self.nodes.get(node_id) {
258            for dep_id in &node.depends_on {
259                to_visit.push_back(dep_id.clone());
260            }
261        }
262
263        while let Some(id) = to_visit.pop_front() {
264            if deps.contains(&id) {
265                continue;
266            }
267            deps.insert(id.clone());
268            if let Some(node) = self.nodes.get(&id) {
269                for dep_id in &node.depends_on {
270                    if !deps.contains(dep_id) {
271                        to_visit.push_back(dep_id.clone());
272                    }
273                }
274            }
275        }
276
277        deps
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::config::StepConfig;
285    use std::collections::HashMap;
286
287    fn make_step(id: &str, depends_on: Vec<&str>) -> StepConfig {
288        StepConfig {
289            id: id.to_string(),
290            description: format!("Step {}", id),
291            depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
292            tool: None,
293            tool_args: None,
294            prompt: None,
295            requires_approval: false,
296            timeout_secs: None,
297            retry_policy: None,
298            metadata: HashMap::new(),
299        }
300    }
301
302    fn make_node(id: &str, depends_on: Vec<&str>) -> DagNode {
303        let depends_on_owned: Vec<String> = depends_on.iter().map(|s| s.to_string()).collect();
304        DagNode {
305            id: id.to_string(),
306            step: make_step(id, depends_on),
307            depends_on: depends_on_owned,
308            children: Vec::new(),
309            level: 0,
310        }
311    }
312
313    #[test]
314    fn test_simple_dag() {
315        let mut dag = WorkflowDag::new("test");
316        dag.add_node(make_node("a", vec![]));
317        dag.add_node(make_node("b", vec!["a"]));
318        dag.add_node(make_node("c", vec!["b"]));
319        dag.build();
320
321        assert_eq!(dag.node_count(), 3);
322        assert_eq!(dag.level_count(), 3);
323        assert_eq!(dag.roots, vec!["a"]);
324        assert_eq!(dag.leaves, vec!["c"]);
325        assert_eq!(dag.levels[0], vec!["a"]);
326        assert_eq!(dag.levels[1], vec!["b"]);
327        assert_eq!(dag.levels[2], vec!["c"]);
328    }
329
330    #[test]
331    fn test_parallel_dag() {
332        let mut dag = WorkflowDag::new("test");
333        dag.add_node(make_node("a", vec![]));
334        dag.add_node(make_node("b", vec![]));
335        dag.add_node(make_node("c", vec!["a", "b"]));
336        dag.build();
337
338        assert_eq!(dag.level_count(), 2);
339        // Level 0 contains both "a" and "b" (deterministic alphabetical order
340        // since `nodes` is a BTreeMap).
341        assert_eq!(dag.levels[0], vec!["a", "b"]);
342        assert_eq!(dag.levels[1], vec!["c"]);
343        // Roots are a and b (deterministic alphabetical order).
344        assert_eq!(dag.roots, vec!["a", "b"]);
345        assert_eq!(dag.leaves, vec!["c"]);
346    }
347
348    #[test]
349    fn test_diamond_dag() {
350        // a → b → d
351        // a → c → d
352        let mut dag = WorkflowDag::new("test");
353        dag.add_node(make_node("a", vec![]));
354        dag.add_node(make_node("b", vec!["a"]));
355        dag.add_node(make_node("c", vec!["a"]));
356        dag.add_node(make_node("d", vec!["b", "c"]));
357        dag.build();
358
359        assert_eq!(dag.level_count(), 3);
360        assert_eq!(dag.levels[0], vec!["a"]);
361        // Level 1 contains both "b" and "c" (deterministic alphabetical order
362        // since `nodes` is a BTreeMap).
363        assert_eq!(dag.levels[1], vec!["b", "c"]);
364        assert_eq!(dag.levels[2], vec!["d"]);
365    }
366
367    #[test]
368    fn test_dag_cycle_detection() {
369        let mut dag = WorkflowDag::new("test");
370        dag.add_node(make_node("a", vec!["b"]));
371        dag.add_node(make_node("b", vec!["a"]));
372        dag.build();
373
374        assert!(dag.has_cycle());
375    }
376
377    #[test]
378    fn test_dag_no_cycle() {
379        let mut dag = WorkflowDag::new("test");
380        dag.add_node(make_node("a", vec![]));
381        dag.add_node(make_node("b", vec!["a"]));
382        dag.build();
383
384        assert!(!dag.has_cycle());
385    }
386
387    #[test]
388    fn test_dag_topological_order() {
389        let mut dag = WorkflowDag::new("test");
390        dag.add_node(make_node("a", vec![]));
391        dag.add_node(make_node("b", vec!["a"]));
392        dag.add_node(make_node("c", vec!["a"]));
393        dag.add_node(make_node("d", vec!["b", "c"]));
394        dag.build();
395
396        let order = dag.topological_order();
397        assert_eq!(order.len(), 4);
398        // a must come before b and c, which must come before d
399        let a_pos = order.iter().position(|x| x == "a").unwrap();
400        let b_pos = order.iter().position(|x| x == "b").unwrap();
401        let c_pos = order.iter().position(|x| x == "c").unwrap();
402        let d_pos = order.iter().position(|x| x == "d").unwrap();
403        assert!(a_pos < b_pos);
404        assert!(a_pos < c_pos);
405        assert!(b_pos < d_pos);
406        assert!(c_pos < d_pos);
407    }
408
409    #[test]
410    fn test_dag_transitive_deps() {
411        let mut dag = WorkflowDag::new("test");
412        dag.add_node(make_node("a", vec![]));
413        dag.add_node(make_node("b", vec!["a"]));
414        dag.add_node(make_node("c", vec!["b"]));
415        dag.build();
416
417        let deps = dag.transitive_deps("c");
418        assert!(deps.contains("a"));
419        assert!(deps.contains("b"));
420        assert_eq!(deps.len(), 2);
421    }
422
423    #[test]
424    fn test_dag_level_nodes() {
425        let mut dag = WorkflowDag::new("test");
426        dag.add_node(make_node("a", vec![]));
427        dag.add_node(make_node("b", vec!["a"]));
428        dag.build();
429
430        let level0 = dag.level_nodes(0);
431        assert_eq!(level0.len(), 1);
432        assert_eq!(level0[0].id, "a");
433
434        let level1 = dag.level_nodes(1);
435        assert_eq!(level1.len(), 1);
436        assert_eq!(level1[0].id, "b");
437    }
438}