Skip to main content

oximedia_workflow/
dag.rs

1//! DAG-based workflow definition with `WorkflowNode`, `WorkflowEdge`, `WorkflowDag`,
2//! `WorkflowEngine` with node-level status tracking, and `WorkflowTemplate`.
3
4#![allow(dead_code)]
5#![allow(clippy::missing_errors_doc)]
6#![allow(clippy::type_complexity)]
7
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::sync::{Arc, Mutex};
11use uuid::Uuid;
12
13// ---------------------------------------------------------------------------
14// WorkflowNode
15// ---------------------------------------------------------------------------
16
17/// Unique identifier for a workflow node.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct NodeId(Uuid);
20
21impl NodeId {
22    /// Create a new random node ID.
23    #[must_use]
24    pub fn new() -> Self {
25        Self(Uuid::new_v4())
26    }
27
28    /// Get underlying UUID.
29    #[must_use]
30    pub const fn as_uuid(&self) -> &Uuid {
31        &self.0
32    }
33}
34
35impl Default for NodeId {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl std::fmt::Display for NodeId {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "{}", self.0)
44    }
45}
46
47/// Execution status of a single workflow node.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub enum NodeStatus {
50    /// Not yet started.
51    Pending,
52    /// Waiting for dependencies.
53    Waiting,
54    /// Currently executing.
55    Running,
56    /// Completed successfully.
57    Completed,
58    /// Execution failed.
59    Failed(String),
60    /// Skipped (e.g., due to conditional edge).
61    Skipped,
62}
63
64impl NodeStatus {
65    /// Returns `true` if the node finished (completed, failed, or skipped).
66    #[must_use]
67    pub fn is_terminal(&self) -> bool {
68        matches!(self, Self::Completed | Self::Failed(_) | Self::Skipped)
69    }
70
71    /// Returns `true` if the node completed successfully.
72    #[must_use]
73    pub fn is_success(&self) -> bool {
74        matches!(self, Self::Completed)
75    }
76}
77
78/// A node inside a `WorkflowDag`.
79///
80/// Each node represents a single processing step with typed inputs, typed
81/// outputs, an opaque parameter bag, and an optional task-type tag.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct WorkflowNode {
84    /// Unique node identifier.
85    pub node_id: NodeId,
86    /// Human-readable task type / name.
87    pub task_type: String,
88    /// Named input ports and their current values (optional).
89    pub inputs: HashMap<String, serde_json::Value>,
90    /// Named output ports and their produced values (optional).
91    pub outputs: HashMap<String, serde_json::Value>,
92    /// Opaque parameter bag for task configuration.
93    pub parameters: HashMap<String, serde_json::Value>,
94    /// Current execution status.
95    pub status: NodeStatus,
96}
97
98impl WorkflowNode {
99    /// Create a new node with the given task type.
100    #[must_use]
101    pub fn new(task_type: impl Into<String>) -> Self {
102        Self {
103            node_id: NodeId::new(),
104            task_type: task_type.into(),
105            inputs: HashMap::new(),
106            outputs: HashMap::new(),
107            parameters: HashMap::new(),
108            status: NodeStatus::Pending,
109        }
110    }
111
112    /// Attach an input value.
113    #[must_use]
114    pub fn with_input(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
115        self.inputs.insert(key.into(), value);
116        self
117    }
118
119    /// Attach a parameter.
120    #[must_use]
121    pub fn with_parameter(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
122        self.parameters.insert(key.into(), value);
123        self
124    }
125
126    /// Record an output produced by this node.
127    pub fn set_output(&mut self, key: impl Into<String>, value: serde_json::Value) {
128        self.outputs.insert(key.into(), value);
129    }
130}
131
132// ---------------------------------------------------------------------------
133// WorkflowEdge
134// ---------------------------------------------------------------------------
135
136/// An edge connecting two nodes in a `WorkflowDag`.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct WorkflowEdge {
139    /// Source node.
140    pub from_node: NodeId,
141    /// Destination node.
142    pub to_node: NodeId,
143    /// Data type flowing along this edge (e.g. `"video/mp4"`, `"audio/pcm"`).
144    pub data_type: String,
145    /// Optional condition expression.
146    pub condition: Option<String>,
147}
148
149impl WorkflowEdge {
150    /// Create an edge without a condition.
151    #[must_use]
152    pub fn new(from_node: NodeId, to_node: NodeId, data_type: impl Into<String>) -> Self {
153        Self {
154            from_node,
155            to_node,
156            data_type: data_type.into(),
157            condition: None,
158        }
159    }
160
161    /// Create a conditional edge.
162    #[must_use]
163    pub fn with_condition(
164        from_node: NodeId,
165        to_node: NodeId,
166        data_type: impl Into<String>,
167        condition: impl Into<String>,
168    ) -> Self {
169        Self {
170            from_node,
171            to_node,
172            data_type: data_type.into(),
173            condition: Some(condition.into()),
174        }
175    }
176}
177
178// ---------------------------------------------------------------------------
179// WorkflowDag
180// ---------------------------------------------------------------------------
181
182/// Error type for DAG operations.
183#[derive(Debug, thiserror::Error)]
184pub enum DagError {
185    /// Node not found in the DAG.
186    #[error("Node not found: {0}")]
187    NodeNotFound(NodeId),
188
189    /// A cycle was detected in the DAG.
190    #[error("Cycle detected in DAG")]
191    CycleDetected,
192
193    /// Duplicate node.
194    #[error("Node already exists: {0}")]
195    DuplicateNode(NodeId),
196}
197
198/// A directed acyclic graph of `WorkflowNode`s connected by `WorkflowEdge`s.
199///
200/// Provides cycle detection and Kahn's-algorithm topological sort.
201#[derive(Debug, Clone, Serialize, Deserialize, Default)]
202pub struct WorkflowDag {
203    /// Nodes, keyed by their ID.
204    pub nodes: HashMap<NodeId, WorkflowNode>,
205    /// Edges in insertion order.
206    pub edges: Vec<WorkflowEdge>,
207}
208
209impl WorkflowDag {
210    /// Create an empty DAG.
211    #[must_use]
212    pub fn new() -> Self {
213        Self::default()
214    }
215
216    /// Add a node. Returns an error if the node ID already exists.
217    pub fn add_node(&mut self, node: WorkflowNode) -> Result<NodeId, DagError> {
218        let id = node.node_id;
219        if self.nodes.contains_key(&id) {
220            return Err(DagError::DuplicateNode(id));
221        }
222        self.nodes.insert(id, node);
223        Ok(id)
224    }
225
226    /// Add an edge. Both nodes must already exist.
227    pub fn add_edge(&mut self, edge: WorkflowEdge) -> Result<(), DagError> {
228        if !self.nodes.contains_key(&edge.from_node) {
229            return Err(DagError::NodeNotFound(edge.from_node));
230        }
231        if !self.nodes.contains_key(&edge.to_node) {
232            return Err(DagError::NodeNotFound(edge.to_node));
233        }
234        self.edges.push(edge);
235
236        // Eagerly detect cycles.
237        if self.has_cycle() {
238            self.edges.pop();
239            return Err(DagError::CycleDetected);
240        }
241        Ok(())
242    }
243
244    /// Returns `true` if the DAG contains a cycle (DFS-based).
245    #[must_use]
246    pub fn has_cycle(&self) -> bool {
247        let mut visited = HashSet::new();
248        let mut stack = HashSet::new();
249        for &id in self.nodes.keys() {
250            if self.dfs_cycle(id, &mut visited, &mut stack) {
251                return true;
252            }
253        }
254        false
255    }
256
257    fn dfs_cycle(
258        &self,
259        id: NodeId,
260        visited: &mut HashSet<NodeId>,
261        stack: &mut HashSet<NodeId>,
262    ) -> bool {
263        if stack.contains(&id) {
264            return true;
265        }
266        if visited.contains(&id) {
267            return false;
268        }
269        visited.insert(id);
270        stack.insert(id);
271        for edge in &self.edges {
272            if edge.from_node == id && self.dfs_cycle(edge.to_node, visited, stack) {
273                return true;
274            }
275        }
276        stack.remove(&id);
277        false
278    }
279
280    /// Topological sort using Kahn's algorithm.
281    ///
282    /// Returns nodes in an order where every dependency appears before its
283    /// dependents.
284    pub fn topological_sort(&self) -> Result<Vec<NodeId>, DagError> {
285        if self.has_cycle() {
286            return Err(DagError::CycleDetected);
287        }
288
289        // Build in-degree map.
290        let mut in_degree: HashMap<NodeId, usize> = self.nodes.keys().map(|&k| (k, 0)).collect();
291        for edge in &self.edges {
292            *in_degree.entry(edge.to_node).or_insert(0) += 1;
293        }
294
295        let mut queue: VecDeque<NodeId> = in_degree
296            .iter()
297            .filter(|(_, &deg)| deg == 0)
298            .map(|(&id, _)| id)
299            .collect();
300
301        let mut result = Vec::with_capacity(self.nodes.len());
302        while let Some(id) = queue.pop_front() {
303            result.push(id);
304            for edge in &self.edges {
305                if edge.from_node == id {
306                    let deg = in_degree.entry(edge.to_node).or_insert(0);
307                    *deg = deg.saturating_sub(1);
308                    if *deg == 0 {
309                        queue.push_back(edge.to_node);
310                    }
311                }
312            }
313        }
314
315        Ok(result)
316    }
317
318    /// Return immediate predecessors of `node_id`.
319    #[must_use]
320    pub fn predecessors(&self, node_id: NodeId) -> Vec<NodeId> {
321        self.edges
322            .iter()
323            .filter(|e| e.to_node == node_id)
324            .map(|e| e.from_node)
325            .collect()
326    }
327
328    /// Return immediate successors of `node_id`.
329    #[must_use]
330    pub fn successors(&self, node_id: NodeId) -> Vec<NodeId> {
331        self.edges
332            .iter()
333            .filter(|e| e.from_node == node_id)
334            .map(|e| e.to_node)
335            .collect()
336    }
337}
338
339// ---------------------------------------------------------------------------
340// DagWorkflowEngine
341// ---------------------------------------------------------------------------
342
343/// Per-run node status snapshot produced by `DagWorkflowEngine::execute`.
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct DagRunStatus {
346    /// Node statuses at the end of the run.
347    pub node_statuses: HashMap<NodeId, NodeStatus>,
348    /// Whether the overall run succeeded.
349    pub succeeded: bool,
350    /// Total number of nodes executed.
351    pub nodes_executed: usize,
352    /// Total number of nodes that failed.
353    pub nodes_failed: usize,
354}
355
356/// Lightweight DAG workflow engine.
357///
358/// Executes nodes in topological order.  For each node it calls the registered
359/// `executor` closure (if any) and tracks per-node status.
360pub struct DagWorkflowEngine {
361    /// Node executor: receives the mutable node and returns Ok or error string.
362    executor: Option<Arc<dyn Fn(&mut WorkflowNode) -> Result<(), String> + Send + Sync>>,
363    /// Status registry shared across callers.
364    statuses: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
365}
366
367impl DagWorkflowEngine {
368    /// Create an engine without an executor (nodes are marked Completed immediately).
369    #[must_use]
370    pub fn new() -> Self {
371        Self {
372            executor: None,
373            statuses: Arc::new(Mutex::new(HashMap::new())),
374        }
375    }
376
377    /// Attach a node executor closure.
378    #[must_use]
379    pub fn with_executor<F>(mut self, f: F) -> Self
380    where
381        F: Fn(&mut WorkflowNode) -> Result<(), String> + Send + Sync + 'static,
382    {
383        self.executor = Some(Arc::new(f));
384        self
385    }
386
387    /// Execute a DAG and return a status snapshot.
388    ///
389    /// # Errors
390    ///
391    /// Returns a `DagError` if the DAG contains a cycle.
392    pub fn execute(&self, dag: &mut WorkflowDag) -> Result<DagRunStatus, DagError> {
393        let order = dag.topological_sort()?;
394
395        let mut statuses: HashMap<NodeId, NodeStatus> = HashMap::new();
396        let mut nodes_executed = 0usize;
397        let mut nodes_failed = 0usize;
398
399        for node_id in &order {
400            let Some(node) = dag.nodes.get_mut(node_id) else {
401                continue;
402            };
403
404            node.status = NodeStatus::Running;
405            statuses.insert(*node_id, NodeStatus::Running);
406
407            let result = if let Some(ref exec) = self.executor {
408                exec(node)
409            } else {
410                Ok(())
411            };
412
413            match result {
414                Ok(()) => {
415                    node.status = NodeStatus::Completed;
416                    statuses.insert(*node_id, NodeStatus::Completed);
417                    nodes_executed += 1;
418                }
419                Err(msg) => {
420                    node.status = NodeStatus::Failed(msg.clone());
421                    statuses.insert(*node_id, NodeStatus::Failed(msg));
422                    nodes_failed += 1;
423                }
424            }
425        }
426
427        // Persist in shared registry.
428        if let Ok(mut guard) = self.statuses.lock() {
429            guard.extend(statuses.clone());
430        }
431
432        let succeeded = nodes_failed == 0;
433
434        Ok(DagRunStatus {
435            node_statuses: statuses,
436            succeeded,
437            nodes_executed,
438            nodes_failed,
439        })
440    }
441
442    /// Get the current status of a node (across all runs).
443    #[must_use]
444    pub fn node_status(&self, node_id: NodeId) -> Option<NodeStatus> {
445        self.statuses.lock().ok()?.get(&node_id).cloned()
446    }
447}
448
449impl Default for DagWorkflowEngine {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455// ---------------------------------------------------------------------------
456// WorkflowTemplate
457// ---------------------------------------------------------------------------
458
459/// A named, reusable workflow configuration that can be instantiated into a
460/// `WorkflowDag`.
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct WorkflowTemplate {
463    /// Unique template name.
464    pub name: String,
465    /// Human-readable description.
466    pub description: String,
467    /// Default parameters shared by all nodes.
468    pub default_parameters: HashMap<String, serde_json::Value>,
469    /// Pre-built node definitions (without IDs – IDs are assigned on instantiation).
470    node_specs: Vec<NodeSpec>,
471    /// Edge definitions as `(from-index, to-index, data_type)`.
472    edge_specs: Vec<(usize, usize, String)>,
473}
474
475/// Lightweight spec used inside a `WorkflowTemplate`.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477struct NodeSpec {
478    task_type: String,
479    parameters: HashMap<String, serde_json::Value>,
480}
481
482impl WorkflowTemplate {
483    /// Create a new empty template.
484    #[must_use]
485    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
486        Self {
487            name: name.into(),
488            description: description.into(),
489            default_parameters: HashMap::new(),
490            node_specs: Vec::new(),
491            edge_specs: Vec::new(),
492        }
493    }
494
495    /// Add a default parameter.
496    #[must_use]
497    pub fn with_default_parameter(
498        mut self,
499        key: impl Into<String>,
500        value: serde_json::Value,
501    ) -> Self {
502        self.default_parameters.insert(key.into(), value);
503        self
504    }
505
506    /// Append a node spec; returns the spec index for use in `add_edge`.
507    pub fn add_node(
508        &mut self,
509        task_type: impl Into<String>,
510        parameters: HashMap<String, serde_json::Value>,
511    ) -> usize {
512        self.node_specs.push(NodeSpec {
513            task_type: task_type.into(),
514            parameters,
515        });
516        self.node_specs.len() - 1
517    }
518
519    /// Append an edge spec between two node indices.
520    pub fn add_edge(&mut self, from_idx: usize, to_idx: usize, data_type: impl Into<String>) {
521        self.edge_specs.push((from_idx, to_idx, data_type.into()));
522    }
523
524    /// Instantiate the template into a fresh `WorkflowDag`.
525    ///
526    /// `overrides` can supply parameter overrides (merged with defaults).
527    pub fn instantiate(
528        &self,
529        overrides: &HashMap<String, serde_json::Value>,
530    ) -> Result<WorkflowDag, DagError> {
531        let mut dag = WorkflowDag::new();
532        let mut ids: Vec<NodeId> = Vec::with_capacity(self.node_specs.len());
533
534        for spec in &self.node_specs {
535            let mut params = self.default_parameters.clone();
536            params.extend(spec.parameters.clone());
537            params.extend(overrides.clone());
538
539            let node = WorkflowNode {
540                node_id: NodeId::new(),
541                task_type: spec.task_type.clone(),
542                inputs: HashMap::new(),
543                outputs: HashMap::new(),
544                parameters: params,
545                status: NodeStatus::Pending,
546            };
547            let id = dag.add_node(node)?;
548            ids.push(id);
549        }
550
551        for &(from_idx, to_idx, ref dt) in &self.edge_specs {
552            let edge = WorkflowEdge::new(ids[from_idx], ids[to_idx], dt.clone());
553            dag.add_edge(edge)?;
554        }
555
556        Ok(dag)
557    }
558
559    /// Return the number of node specs.
560    #[must_use]
561    pub fn node_count(&self) -> usize {
562        self.node_specs.len()
563    }
564}
565
566// ---------------------------------------------------------------------------
567// Conditional branching
568// ---------------------------------------------------------------------------
569
570/// Type of branch node for conditional execution paths.
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub enum BranchType {
573    /// If-else: evaluate condition on predecessor output, choose `then_branch`
574    /// or `else_branch` node.
575    IfElse {
576        /// Condition expression evaluated against the predecessor's outputs.
577        condition: String,
578        /// Node to execute when condition is true.
579        then_branch: NodeId,
580        /// Node to execute when condition is false.
581        else_branch: NodeId,
582    },
583    /// Switch-case: match a key against multiple values, each mapping to a
584    /// different successor node.
585    Switch {
586        /// The output key whose value is inspected.
587        key: String,
588        /// Mapping from expected string value to target node.
589        cases: HashMap<String, NodeId>,
590        /// Fallback node if no case matches.
591        default: Option<NodeId>,
592    },
593}
594
595/// A branch node that selects which successor(s) to execute.
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct BranchNode {
598    /// The node ID of this branch decision point.
599    pub node_id: NodeId,
600    /// Which predecessor's output to evaluate.
601    pub predecessor: NodeId,
602    /// Branch logic.
603    pub branch_type: BranchType,
604}
605
606/// Evaluator for branch conditions.
607pub struct BranchEvaluator;
608
609impl BranchEvaluator {
610    /// Evaluate a simple condition expression against a set of outputs.
611    ///
612    /// Supported expressions:
613    /// - `key == value`  (string equality)
614    /// - `key != value`
615    /// - `key > number`  (numeric comparison)
616    /// - `key >= number`
617    /// - `key < number`
618    /// - `key <= number`
619    /// - `key exists`    (key is present)
620    /// - `key not_exists` (key is absent)
621    ///
622    /// Returns `None` if the expression cannot be parsed.
623    #[must_use]
624    pub fn evaluate_condition(
625        condition: &str,
626        outputs: &HashMap<String, serde_json::Value>,
627    ) -> Option<bool> {
628        let parts: Vec<&str> = condition.trim().splitn(3, ' ').collect();
629        if parts.len() < 2 {
630            return None;
631        }
632
633        let key = parts[0];
634
635        // Unary operators
636        if parts.len() == 2 {
637            return match parts[1] {
638                "exists" => Some(outputs.contains_key(key)),
639                "not_exists" => Some(!outputs.contains_key(key)),
640                _ => None,
641            };
642        }
643
644        let op = parts[1];
645        let rhs = parts[2];
646        let lhs_val = outputs.get(key)?;
647
648        match op {
649            "==" => {
650                let rhs_trimmed = rhs.trim_matches('"');
651                if let Some(s) = lhs_val.as_str() {
652                    Some(s == rhs_trimmed)
653                } else if let Some(n) = lhs_val.as_f64() {
654                    rhs.parse::<f64>()
655                        .ok()
656                        .map(|r| (n - r).abs() < f64::EPSILON)
657                } else if let Some(b) = lhs_val.as_bool() {
658                    rhs.parse::<bool>().ok().map(|r| b == r)
659                } else {
660                    None
661                }
662            }
663            "!=" => {
664                let eq = Self::evaluate_condition(&format!("{key} == {rhs}"), outputs)?;
665                Some(!eq)
666            }
667            ">" | ">=" | "<" | "<=" => {
668                let lhs_num = lhs_val.as_f64()?;
669                let rhs_num = rhs.parse::<f64>().ok()?;
670                Some(match op {
671                    ">" => lhs_num > rhs_num,
672                    ">=" => lhs_num >= rhs_num,
673                    "<" => lhs_num < rhs_num,
674                    "<=" => lhs_num <= rhs_num,
675                    _ => return None,
676                })
677            }
678            _ => None,
679        }
680    }
681
682    /// Resolve which node(s) should be activated based on a branch node
683    /// and the predecessor's outputs.
684    #[must_use]
685    pub fn resolve_branch(
686        branch: &BranchNode,
687        predecessor_outputs: &HashMap<String, serde_json::Value>,
688    ) -> Vec<NodeId> {
689        match &branch.branch_type {
690            BranchType::IfElse {
691                condition,
692                then_branch,
693                else_branch,
694            } => {
695                let result =
696                    Self::evaluate_condition(condition, predecessor_outputs).unwrap_or(false);
697                if result {
698                    vec![*then_branch]
699                } else {
700                    vec![*else_branch]
701                }
702            }
703            BranchType::Switch {
704                key,
705                cases,
706                default,
707            } => {
708                if let Some(val) = predecessor_outputs.get(key) {
709                    let val_str = if let Some(s) = val.as_str() {
710                        s.to_string()
711                    } else {
712                        val.to_string()
713                    };
714                    if let Some(&target) = cases.get(&val_str) {
715                        return vec![target];
716                    }
717                }
718                default.map_or_else(Vec::new, |d| vec![d])
719            }
720        }
721    }
722}
723
724impl WorkflowDag {
725    /// Execute the DAG with branch support.
726    ///
727    /// For each branch node found (identified by node IDs in `branches`),
728    /// only the selected successor path is activated; the other paths'
729    /// nodes are marked `Skipped`.
730    ///
731    /// # Errors
732    ///
733    /// Returns `DagError` if the DAG contains a cycle.
734    pub fn execute_with_branches(
735        &mut self,
736        branches: &HashMap<NodeId, BranchNode>,
737        executor: Option<&dyn Fn(&mut WorkflowNode) -> Result<(), String>>,
738    ) -> Result<DagRunStatus, DagError> {
739        let order = self.topological_sort()?;
740
741        let mut statuses: HashMap<NodeId, NodeStatus> = HashMap::new();
742        let mut nodes_executed = 0usize;
743        let mut nodes_failed = 0usize;
744        let mut skipped_nodes: HashSet<NodeId> = HashSet::new();
745
746        for &node_id in &order {
747            // Skip nodes that have been excluded by branch decisions
748            if skipped_nodes.contains(&node_id) {
749                if let Some(node) = self.nodes.get_mut(&node_id) {
750                    node.status = NodeStatus::Skipped;
751                }
752                statuses.insert(node_id, NodeStatus::Skipped);
753                continue;
754            }
755
756            let Some(node) = self.nodes.get_mut(&node_id) else {
757                continue;
758            };
759
760            node.status = NodeStatus::Running;
761            statuses.insert(node_id, NodeStatus::Running);
762
763            let result = if let Some(exec) = &executor {
764                exec(node)
765            } else {
766                Ok(())
767            };
768
769            match result {
770                Ok(()) => {
771                    node.status = NodeStatus::Completed;
772                    statuses.insert(node_id, NodeStatus::Completed);
773                    nodes_executed += 1;
774
775                    // Check if this node is a branch decision point
776                    if let Some(branch) = branches.get(&node_id) {
777                        let predecessor_outputs = self
778                            .nodes
779                            .get(&branch.predecessor)
780                            .map(|n| n.outputs.clone())
781                            .unwrap_or_default();
782                        let selected =
783                            BranchEvaluator::resolve_branch(branch, &predecessor_outputs);
784
785                        // Mark non-selected successors for skipping
786                        let all_successors = self.successors(node_id);
787                        for succ_id in &all_successors {
788                            if !selected.contains(succ_id) {
789                                Self::collect_descendants_static(
790                                    &self.edges,
791                                    *succ_id,
792                                    &mut skipped_nodes,
793                                );
794                                skipped_nodes.insert(*succ_id);
795                            }
796                        }
797                    }
798                }
799                Err(msg) => {
800                    node.status = NodeStatus::Failed(msg.clone());
801                    statuses.insert(node_id, NodeStatus::Failed(msg));
802                    nodes_failed += 1;
803                }
804            }
805        }
806
807        let succeeded = nodes_failed == 0;
808        Ok(DagRunStatus {
809            node_statuses: statuses,
810            succeeded,
811            nodes_executed,
812            nodes_failed,
813        })
814    }
815
816    /// Collect all transitive descendants of a node.
817    fn collect_descendants_static(
818        edges: &[WorkflowEdge],
819        node_id: NodeId,
820        result: &mut HashSet<NodeId>,
821    ) {
822        for edge in edges {
823            if edge.from_node == node_id && !result.contains(&edge.to_node) {
824                result.insert(edge.to_node);
825                Self::collect_descendants_static(edges, edge.to_node, result);
826            }
827        }
828    }
829
830    /// Return all transitive descendants of the given node.
831    #[must_use]
832    pub fn descendants(&self, node_id: NodeId) -> HashSet<NodeId> {
833        let mut result = HashSet::new();
834        Self::collect_descendants_static(&self.edges, node_id, &mut result);
835        result
836    }
837}
838
839// ---------------------------------------------------------------------------
840// Pre-built templates
841// ---------------------------------------------------------------------------
842
843/// Pre-built template: ingest and transcode.
844///
845/// Nodes: ingest → probe → transcode → package
846#[must_use]
847pub fn ingest_transcode() -> WorkflowTemplate {
848    let mut tmpl = WorkflowTemplate::new(
849        "ingest_transcode",
850        "Ingest a source file, probe it, transcode, then package for delivery",
851    )
852    .with_default_parameter("output_format", serde_json::json!("mp4"))
853    .with_default_parameter("preset", serde_json::json!("medium"));
854
855    let i0 = tmpl.add_node("ingest", HashMap::new());
856    let i1 = tmpl.add_node("probe", HashMap::new());
857    let i2 = tmpl.add_node("transcode", HashMap::new());
858    let i3 = tmpl.add_node("package", HashMap::new());
859
860    tmpl.add_edge(i0, i1, "raw_media");
861    tmpl.add_edge(i1, i2, "media_info");
862    tmpl.add_edge(i2, i3, "encoded_video");
863
864    tmpl
865}
866
867/// Pre-built template: burn subtitles into video.
868///
869/// Nodes: ingest → `subtitle_parse` → `burn_subtitles` → encode → deliver
870#[must_use]
871pub fn subtitle_burn() -> WorkflowTemplate {
872    let mut tmpl = WorkflowTemplate::new(
873        "subtitle_burn",
874        "Parse subtitle file and burn it into the video stream",
875    )
876    .with_default_parameter("subtitle_format", serde_json::json!("srt"))
877    .with_default_parameter("font_size", serde_json::json!(24));
878
879    let i0 = tmpl.add_node("ingest", HashMap::new());
880    let i1 = tmpl.add_node("subtitle_parse", HashMap::new());
881    let i2 = tmpl.add_node("burn_subtitles", HashMap::new());
882    let i3 = tmpl.add_node("encode", HashMap::new());
883    let i4 = tmpl.add_node("deliver", HashMap::new());
884
885    tmpl.add_edge(i0, i1, "raw_media");
886    tmpl.add_edge(i1, i2, "subtitle_events");
887    tmpl.add_edge(i2, i3, "filtered_video");
888    tmpl.add_edge(i3, i4, "encoded_video");
889
890    tmpl
891}
892
893/// Pre-built template: audio normalization.
894///
895/// Nodes: ingest → `audio_analyze` → normalize → encode → deliver
896#[must_use]
897pub fn audio_normalize() -> WorkflowTemplate {
898    let mut tmpl = WorkflowTemplate::new(
899        "audio_normalize",
900        "Analyze audio loudness and normalize to a target LUFS level",
901    )
902    .with_default_parameter("target_lufs", serde_json::json!(-23.0))
903    .with_default_parameter("true_peak_dbtp", serde_json::json!(-1.0));
904
905    let i0 = tmpl.add_node("ingest", HashMap::new());
906    let i1 = tmpl.add_node("audio_analyze", HashMap::new());
907    let i2 = tmpl.add_node("normalize", HashMap::new());
908    let i3 = tmpl.add_node("encode", HashMap::new());
909    let i4 = tmpl.add_node("deliver", HashMap::new());
910
911    tmpl.add_edge(i0, i1, "raw_audio");
912    tmpl.add_edge(i1, i2, "loudness_stats");
913    tmpl.add_edge(i2, i3, "normalized_audio");
914    tmpl.add_edge(i3, i4, "encoded_audio");
915
916    tmpl
917}
918
919// ---------------------------------------------------------------------------
920// Tests
921// ---------------------------------------------------------------------------
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    fn make_node(task_type: &str) -> WorkflowNode {
928        WorkflowNode::new(task_type)
929    }
930
931    // --- WorkflowNode ---
932
933    #[test]
934    fn test_node_creation() {
935        let node = make_node("transcode");
936        assert_eq!(node.task_type, "transcode");
937        assert_eq!(node.status, NodeStatus::Pending);
938        assert!(node.inputs.is_empty());
939        assert!(node.outputs.is_empty());
940        assert!(node.parameters.is_empty());
941    }
942
943    #[test]
944    fn test_node_with_input_and_parameter() {
945        let node = make_node("encode")
946            .with_input("src", serde_json::json!("/tmp/in.mp4"))
947            .with_parameter("preset", serde_json::json!("slow"));
948        assert_eq!(node.inputs["src"], serde_json::json!("/tmp/in.mp4"));
949        assert_eq!(node.parameters["preset"], serde_json::json!("slow"));
950    }
951
952    #[test]
953    fn test_node_set_output() {
954        let mut node = make_node("transcode");
955        node.set_output("dst", serde_json::json!("/tmp/out.mp4"));
956        assert!(node.outputs.contains_key("dst"));
957    }
958
959    #[test]
960    fn test_node_status_terminal() {
961        assert!(!NodeStatus::Pending.is_terminal());
962        assert!(!NodeStatus::Running.is_terminal());
963        assert!(NodeStatus::Completed.is_terminal());
964        assert!(NodeStatus::Failed("err".to_string()).is_terminal());
965        assert!(NodeStatus::Skipped.is_terminal());
966    }
967
968    // --- WorkflowEdge ---
969
970    #[test]
971    fn test_edge_creation() {
972        let a = NodeId::new();
973        let b = NodeId::new();
974        let edge = WorkflowEdge::new(a, b, "video/mp4");
975        assert_eq!(edge.from_node, a);
976        assert_eq!(edge.to_node, b);
977        assert_eq!(edge.data_type, "video/mp4");
978        assert!(edge.condition.is_none());
979    }
980
981    #[test]
982    fn test_edge_with_condition() {
983        let a = NodeId::new();
984        let b = NodeId::new();
985        let edge = WorkflowEdge::with_condition(a, b, "audio/pcm", "bitrate > 128");
986        assert_eq!(edge.condition, Some("bitrate > 128".to_string()));
987    }
988
989    // --- WorkflowDag ---
990
991    #[test]
992    fn test_dag_add_node_and_edge() {
993        let mut dag = WorkflowDag::new();
994        let a = dag
995            .add_node(make_node("ingest"))
996            .expect("should succeed in test");
997        let b = dag
998            .add_node(make_node("transcode"))
999            .expect("should succeed in test");
1000        dag.add_edge(WorkflowEdge::new(a, b, "raw_media"))
1001            .expect("should succeed in test");
1002
1003        assert_eq!(dag.nodes.len(), 2);
1004        assert_eq!(dag.edges.len(), 1);
1005        assert!(!dag.has_cycle());
1006    }
1007
1008    #[test]
1009    fn test_dag_cycle_detection() {
1010        let mut dag = WorkflowDag::new();
1011        let a = dag
1012            .add_node(make_node("a"))
1013            .expect("should succeed in test");
1014        let b = dag
1015            .add_node(make_node("b"))
1016            .expect("should succeed in test");
1017        dag.add_edge(WorkflowEdge::new(a, b, "x"))
1018            .expect("should succeed in test");
1019        let result = dag.add_edge(WorkflowEdge::new(b, a, "x"));
1020        assert!(matches!(result, Err(DagError::CycleDetected)));
1021    }
1022
1023    #[test]
1024    fn test_dag_topological_sort() {
1025        let mut dag = WorkflowDag::new();
1026        let a = dag
1027            .add_node(make_node("a"))
1028            .expect("should succeed in test");
1029        let b = dag
1030            .add_node(make_node("b"))
1031            .expect("should succeed in test");
1032        let c = dag
1033            .add_node(make_node("c"))
1034            .expect("should succeed in test");
1035        dag.add_edge(WorkflowEdge::new(a, b, "x"))
1036            .expect("should succeed in test");
1037        dag.add_edge(WorkflowEdge::new(b, c, "x"))
1038            .expect("should succeed in test");
1039
1040        let order = dag.topological_sort().expect("should succeed in test");
1041        let pos_a = order
1042            .iter()
1043            .position(|&x| x == a)
1044            .expect("should succeed in test");
1045        let pos_b = order
1046            .iter()
1047            .position(|&x| x == b)
1048            .expect("should succeed in test");
1049        let pos_c = order
1050            .iter()
1051            .position(|&x| x == c)
1052            .expect("should succeed in test");
1053        assert!(pos_a < pos_b && pos_b < pos_c);
1054    }
1055
1056    #[test]
1057    fn test_dag_predecessors_successors() {
1058        let mut dag = WorkflowDag::new();
1059        let a = dag
1060            .add_node(make_node("a"))
1061            .expect("should succeed in test");
1062        let b = dag
1063            .add_node(make_node("b"))
1064            .expect("should succeed in test");
1065        let c = dag
1066            .add_node(make_node("c"))
1067            .expect("should succeed in test");
1068        dag.add_edge(WorkflowEdge::new(a, c, "x"))
1069            .expect("should succeed in test");
1070        dag.add_edge(WorkflowEdge::new(b, c, "x"))
1071            .expect("should succeed in test");
1072
1073        let preds = dag.predecessors(c);
1074        assert_eq!(preds.len(), 2);
1075        assert!(preds.contains(&a));
1076        assert!(preds.contains(&b));
1077
1078        let succs = dag.successors(a);
1079        assert_eq!(succs.len(), 1);
1080        assert_eq!(succs[0], c);
1081    }
1082
1083    // --- DagWorkflowEngine ---
1084
1085    #[test]
1086    fn test_engine_execute_no_executor() {
1087        let mut dag = WorkflowDag::new();
1088        let a = dag
1089            .add_node(make_node("a"))
1090            .expect("should succeed in test");
1091        let b = dag
1092            .add_node(make_node("b"))
1093            .expect("should succeed in test");
1094        dag.add_edge(WorkflowEdge::new(a, b, "x"))
1095            .expect("should succeed in test");
1096
1097        let engine = DagWorkflowEngine::new();
1098        let result = engine.execute(&mut dag).expect("should succeed in test");
1099        assert!(result.succeeded);
1100        assert_eq!(result.nodes_executed, 2);
1101        assert_eq!(result.nodes_failed, 0);
1102    }
1103
1104    #[test]
1105    fn test_engine_execute_with_executor() {
1106        let mut dag = WorkflowDag::new();
1107        let a = dag
1108            .add_node(make_node("a"))
1109            .expect("should succeed in test");
1110        let b = dag
1111            .add_node(make_node("b"))
1112            .expect("should succeed in test");
1113        dag.add_edge(WorkflowEdge::new(a, b, "x"))
1114            .expect("should succeed in test");
1115
1116        let engine = DagWorkflowEngine::new().with_executor(|node| {
1117            node.set_output("done", serde_json::json!(true));
1118            Ok(())
1119        });
1120
1121        let result = engine.execute(&mut dag).expect("should succeed in test");
1122        assert!(result.succeeded);
1123        assert_eq!(result.nodes_executed, 2);
1124        // Outputs were set.
1125        assert!(dag.nodes[&a].outputs.contains_key("done"));
1126    }
1127
1128    #[test]
1129    fn test_engine_node_failure() {
1130        let mut dag = WorkflowDag::new();
1131        let a = dag
1132            .add_node(make_node("failing"))
1133            .expect("should succeed in test");
1134        dag.add_node(make_node("b"))
1135            .expect("should succeed in test"); // isolated node
1136
1137        let engine = DagWorkflowEngine::new().with_executor(|node| {
1138            if node.task_type == "failing" {
1139                Err("intentional failure".to_string())
1140            } else {
1141                Ok(())
1142            }
1143        });
1144
1145        let result = engine.execute(&mut dag).expect("should succeed in test");
1146        assert!(!result.succeeded);
1147        assert!(result.nodes_failed > 0);
1148        assert!(matches!(result.node_statuses[&a], NodeStatus::Failed(_)));
1149    }
1150
1151    #[test]
1152    fn test_engine_node_status_accessor() {
1153        let mut dag = WorkflowDag::new();
1154        let a = dag
1155            .add_node(make_node("a"))
1156            .expect("should succeed in test");
1157
1158        let engine = DagWorkflowEngine::new();
1159        engine.execute(&mut dag).expect("should succeed in test");
1160
1161        assert_eq!(engine.node_status(a), Some(NodeStatus::Completed));
1162        assert_eq!(engine.node_status(NodeId::new()), None);
1163    }
1164
1165    // --- WorkflowTemplate ---
1166
1167    #[test]
1168    fn test_template_instantiate_ingest_transcode() {
1169        let tmpl = ingest_transcode();
1170        assert_eq!(tmpl.name, "ingest_transcode");
1171        assert_eq!(tmpl.node_count(), 4);
1172
1173        let dag = tmpl
1174            .instantiate(&HashMap::new())
1175            .expect("should succeed in test");
1176        assert_eq!(dag.nodes.len(), 4);
1177        assert_eq!(dag.edges.len(), 3);
1178        assert!(!dag.has_cycle());
1179    }
1180
1181    #[test]
1182    fn test_template_instantiate_subtitle_burn() {
1183        let tmpl = subtitle_burn();
1184        let dag = tmpl
1185            .instantiate(&HashMap::new())
1186            .expect("should succeed in test");
1187        assert_eq!(dag.nodes.len(), 5);
1188        assert_eq!(dag.edges.len(), 4);
1189        assert!(!dag.has_cycle());
1190    }
1191
1192    #[test]
1193    fn test_template_instantiate_audio_normalize() {
1194        let tmpl = audio_normalize();
1195        let dag = tmpl
1196            .instantiate(&HashMap::new())
1197            .expect("should succeed in test");
1198        assert_eq!(dag.nodes.len(), 5);
1199        assert_eq!(dag.edges.len(), 4);
1200        assert!(!dag.has_cycle());
1201    }
1202
1203    #[test]
1204    fn test_template_parameter_override() {
1205        let tmpl = ingest_transcode();
1206        let mut overrides = HashMap::new();
1207        overrides.insert("preset".to_string(), serde_json::json!("ultrafast"));
1208
1209        let dag = tmpl
1210            .instantiate(&overrides)
1211            .expect("should succeed in test");
1212        // Every node should have the overridden preset.
1213        for node in dag.nodes.values() {
1214            assert_eq!(node.parameters["preset"], serde_json::json!("ultrafast"));
1215        }
1216    }
1217
1218    #[test]
1219    fn test_template_default_parameters() {
1220        let tmpl = audio_normalize();
1221        assert_eq!(
1222            tmpl.default_parameters["target_lufs"],
1223            serde_json::json!(-23.0)
1224        );
1225        assert_eq!(
1226            tmpl.default_parameters["true_peak_dbtp"],
1227            serde_json::json!(-1.0)
1228        );
1229    }
1230
1231    #[test]
1232    fn test_dag_error_node_not_found() {
1233        let mut dag = WorkflowDag::new();
1234        let a = dag
1235            .add_node(make_node("a"))
1236            .expect("should succeed in test");
1237        let ghost = NodeId::new();
1238        let result = dag.add_edge(WorkflowEdge::new(a, ghost, "x"));
1239        assert!(matches!(result, Err(DagError::NodeNotFound(_))));
1240    }
1241
1242    // --- BranchEvaluator ---
1243
1244    #[test]
1245    fn test_branch_evaluator_equality() {
1246        let mut outputs = HashMap::new();
1247        outputs.insert("codec".to_string(), serde_json::json!("h264"));
1248
1249        assert_eq!(
1250            BranchEvaluator::evaluate_condition("codec == h264", &outputs),
1251            Some(true)
1252        );
1253        assert_eq!(
1254            BranchEvaluator::evaluate_condition("codec == vp9", &outputs),
1255            Some(false)
1256        );
1257        assert_eq!(
1258            BranchEvaluator::evaluate_condition("codec != vp9", &outputs),
1259            Some(true)
1260        );
1261    }
1262
1263    #[test]
1264    fn test_branch_evaluator_numeric_comparison() {
1265        let mut outputs = HashMap::new();
1266        outputs.insert("bitrate".to_string(), serde_json::json!(5000.0));
1267
1268        assert_eq!(
1269            BranchEvaluator::evaluate_condition("bitrate > 3000", &outputs),
1270            Some(true)
1271        );
1272        assert_eq!(
1273            BranchEvaluator::evaluate_condition("bitrate < 3000", &outputs),
1274            Some(false)
1275        );
1276        assert_eq!(
1277            BranchEvaluator::evaluate_condition("bitrate >= 5000", &outputs),
1278            Some(true)
1279        );
1280        assert_eq!(
1281            BranchEvaluator::evaluate_condition("bitrate <= 5000", &outputs),
1282            Some(true)
1283        );
1284    }
1285
1286    #[test]
1287    fn test_branch_evaluator_exists() {
1288        let mut outputs = HashMap::new();
1289        outputs.insert("result".to_string(), serde_json::json!(42));
1290
1291        assert_eq!(
1292            BranchEvaluator::evaluate_condition("result exists", &outputs),
1293            Some(true)
1294        );
1295        assert_eq!(
1296            BranchEvaluator::evaluate_condition("missing not_exists", &outputs),
1297            Some(true)
1298        );
1299        assert_eq!(
1300            BranchEvaluator::evaluate_condition("result not_exists", &outputs),
1301            Some(false)
1302        );
1303    }
1304
1305    #[test]
1306    fn test_branch_evaluator_boolean() {
1307        let mut outputs = HashMap::new();
1308        outputs.insert("success".to_string(), serde_json::json!(true));
1309
1310        assert_eq!(
1311            BranchEvaluator::evaluate_condition("success == true", &outputs),
1312            Some(true)
1313        );
1314        assert_eq!(
1315            BranchEvaluator::evaluate_condition("success == false", &outputs),
1316            Some(false)
1317        );
1318    }
1319
1320    #[test]
1321    fn test_branch_evaluator_numeric_equality() {
1322        let mut outputs = HashMap::new();
1323        outputs.insert("count".to_string(), serde_json::json!(42.0));
1324
1325        assert_eq!(
1326            BranchEvaluator::evaluate_condition("count == 42", &outputs),
1327            Some(true)
1328        );
1329    }
1330
1331    #[test]
1332    fn test_branch_evaluator_invalid_expression() {
1333        let outputs = HashMap::new();
1334        assert_eq!(BranchEvaluator::evaluate_condition("", &outputs), None);
1335        assert_eq!(
1336            BranchEvaluator::evaluate_condition("single", &outputs),
1337            None
1338        );
1339    }
1340
1341    #[test]
1342    fn test_resolve_branch_if_else_true() {
1343        let then_id = NodeId::new();
1344        let else_id = NodeId::new();
1345        let pred_id = NodeId::new();
1346        let branch_id = NodeId::new();
1347
1348        let branch = BranchNode {
1349            node_id: branch_id,
1350            predecessor: pred_id,
1351            branch_type: BranchType::IfElse {
1352                condition: "quality > 90".to_string(),
1353                then_branch: then_id,
1354                else_branch: else_id,
1355            },
1356        };
1357
1358        let mut outputs = HashMap::new();
1359        outputs.insert("quality".to_string(), serde_json::json!(95.0));
1360
1361        let result = BranchEvaluator::resolve_branch(&branch, &outputs);
1362        assert_eq!(result, vec![then_id]);
1363    }
1364
1365    #[test]
1366    fn test_resolve_branch_if_else_false() {
1367        let then_id = NodeId::new();
1368        let else_id = NodeId::new();
1369        let pred_id = NodeId::new();
1370        let branch_id = NodeId::new();
1371
1372        let branch = BranchNode {
1373            node_id: branch_id,
1374            predecessor: pred_id,
1375            branch_type: BranchType::IfElse {
1376                condition: "quality > 90".to_string(),
1377                then_branch: then_id,
1378                else_branch: else_id,
1379            },
1380        };
1381
1382        let mut outputs = HashMap::new();
1383        outputs.insert("quality".to_string(), serde_json::json!(50.0));
1384
1385        let result = BranchEvaluator::resolve_branch(&branch, &outputs);
1386        assert_eq!(result, vec![else_id]);
1387    }
1388
1389    #[test]
1390    fn test_resolve_branch_switch() {
1391        let av1_id = NodeId::new();
1392        let h264_id = NodeId::new();
1393        let default_id = NodeId::new();
1394        let pred_id = NodeId::new();
1395        let branch_id = NodeId::new();
1396
1397        let mut cases = HashMap::new();
1398        cases.insert("av1".to_string(), av1_id);
1399        cases.insert("h264".to_string(), h264_id);
1400
1401        let branch = BranchNode {
1402            node_id: branch_id,
1403            predecessor: pred_id,
1404            branch_type: BranchType::Switch {
1405                key: "codec".to_string(),
1406                cases,
1407                default: Some(default_id),
1408            },
1409        };
1410
1411        let mut outputs = HashMap::new();
1412        outputs.insert("codec".to_string(), serde_json::json!("av1"));
1413        assert_eq!(
1414            BranchEvaluator::resolve_branch(&branch, &outputs),
1415            vec![av1_id]
1416        );
1417
1418        outputs.insert("codec".to_string(), serde_json::json!("h264"));
1419        assert_eq!(
1420            BranchEvaluator::resolve_branch(&branch, &outputs),
1421            vec![h264_id]
1422        );
1423
1424        outputs.insert("codec".to_string(), serde_json::json!("vp9"));
1425        assert_eq!(
1426            BranchEvaluator::resolve_branch(&branch, &outputs),
1427            vec![default_id]
1428        );
1429    }
1430
1431    #[test]
1432    fn test_resolve_branch_switch_no_default() {
1433        let av1_id = NodeId::new();
1434        let pred_id = NodeId::new();
1435        let branch_id = NodeId::new();
1436
1437        let mut cases = HashMap::new();
1438        cases.insert("av1".to_string(), av1_id);
1439
1440        let branch = BranchNode {
1441            node_id: branch_id,
1442            predecessor: pred_id,
1443            branch_type: BranchType::Switch {
1444                key: "codec".to_string(),
1445                cases,
1446                default: None,
1447            },
1448        };
1449
1450        let mut outputs = HashMap::new();
1451        outputs.insert("codec".to_string(), serde_json::json!("vp9"));
1452        assert!(BranchEvaluator::resolve_branch(&branch, &outputs).is_empty());
1453    }
1454
1455    #[test]
1456    fn test_execute_with_branches_if_else() {
1457        // Build DAG: probe -> branch_decision
1458        //   branch_decision -> high_res_encode
1459        //   branch_decision -> low_res_encode
1460        let mut dag = WorkflowDag::new();
1461        let probe = dag.add_node(make_node("probe")).expect("add node");
1462        let decision = dag.add_node(make_node("branch")).expect("add node");
1463        let high = dag.add_node(make_node("high_res")).expect("add node");
1464        let low = dag.add_node(make_node("low_res")).expect("add node");
1465
1466        dag.add_edge(WorkflowEdge::new(probe, decision, "media_info"))
1467            .expect("add edge");
1468        dag.add_edge(WorkflowEdge::new(decision, high, "video"))
1469            .expect("add edge");
1470        dag.add_edge(WorkflowEdge::new(decision, low, "video"))
1471            .expect("add edge");
1472
1473        let branch = BranchNode {
1474            node_id: decision,
1475            predecessor: probe,
1476            branch_type: BranchType::IfElse {
1477                condition: "resolution > 1080".to_string(),
1478                then_branch: high,
1479                else_branch: low,
1480            },
1481        };
1482
1483        let mut branches = HashMap::new();
1484        branches.insert(decision, branch);
1485
1486        // Executor that sets probe output to resolution=4000
1487        let result = dag
1488            .execute_with_branches(
1489                &branches,
1490                Some(&|node: &mut WorkflowNode| {
1491                    if node.task_type == "probe" {
1492                        node.set_output("resolution", serde_json::json!(4000.0));
1493                    }
1494                    Ok(())
1495                }),
1496            )
1497            .expect("execute");
1498
1499        assert!(result.succeeded);
1500        assert_eq!(
1501            result.node_statuses.get(&high),
1502            Some(&NodeStatus::Completed)
1503        );
1504        assert_eq!(result.node_statuses.get(&low), Some(&NodeStatus::Skipped));
1505    }
1506
1507    #[test]
1508    fn test_descendants() {
1509        let mut dag = WorkflowDag::new();
1510        let a = dag.add_node(make_node("a")).expect("add node");
1511        let b = dag.add_node(make_node("b")).expect("add node");
1512        let c = dag.add_node(make_node("c")).expect("add node");
1513        let d = dag.add_node(make_node("d")).expect("add node");
1514
1515        dag.add_edge(WorkflowEdge::new(a, b, "x")).expect("edge");
1516        dag.add_edge(WorkflowEdge::new(b, c, "x")).expect("edge");
1517        dag.add_edge(WorkflowEdge::new(b, d, "x")).expect("edge");
1518
1519        let desc = dag.descendants(a);
1520        assert_eq!(desc.len(), 3);
1521        assert!(desc.contains(&b));
1522        assert!(desc.contains(&c));
1523        assert!(desc.contains(&d));
1524
1525        let desc_b = dag.descendants(b);
1526        assert_eq!(desc_b.len(), 2);
1527        assert!(!desc_b.contains(&a));
1528    }
1529
1530    #[test]
1531    fn test_dag_error_duplicate_node() {
1532        let mut dag = WorkflowDag::new();
1533        let node = make_node("a");
1534        let id = node.node_id;
1535        dag.add_node(node).expect("should succeed in test");
1536        let dup = WorkflowNode {
1537            node_id: id,
1538            task_type: "b".to_string(),
1539            inputs: HashMap::new(),
1540            outputs: HashMap::new(),
1541            parameters: HashMap::new(),
1542            status: NodeStatus::Pending,
1543        };
1544        assert!(matches!(dag.add_node(dup), Err(DagError::DuplicateNode(_))));
1545    }
1546}