Skip to main content

oneai_workflow/
validator.rs

1//! Workflow validator — detects cycles, orphan nodes, undefined dependencies.
2//!
3//! Validates a compiled WorkflowDAG before execution to prevent errors:
4//! - Cycle detection: no circular dependencies
5//! - Orphan detection: no unreachable nodes
6//! - Dependency integrity: all `depends_on` references are valid step IDs
7//! - Empty step detection: steps must have either a tool or a prompt
8//! - Duplicate ID detection: no two steps can share an ID
9
10use crate::dag::WorkflowDag;
11use crate::config::WorkflowConfig;
12
13/// A validation issue found in the workflow.
14#[derive(Debug, Clone, PartialEq)]
15pub struct ValidationIssue {
16    /// The severity of the issue.
17    pub severity: ValidationSeverity,
18
19    /// The issue code (for programmatic handling).
20    pub code: ValidationCode,
21
22    /// A description of the issue.
23    pub description: String,
24
25    /// The step ID(s) involved (if applicable).
26    pub step_ids: Vec<String>,
27}
28
29/// Severity of a validation issue.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ValidationSeverity {
32    /// Critical — the workflow cannot be executed.
33    Error,
34    /// Warning — the workflow can be executed but may have problems.
35    Warning,
36}
37
38/// Code identifying the type of validation issue.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ValidationCode {
41    /// Circular dependency detected.
42    CycleDetected,
43    /// A step references a dependency that doesn't exist.
44    UndefinedDependency,
45    /// Duplicate step IDs.
46    DuplicateStepId,
47    /// A step has no tool or prompt defined.
48    EmptyStep,
49    /// A step is unreachable (no path from root).
50    OrphanNode,
51    /// A step depends on itself.
52    SelfDependency,
53    /// Missing approval gate for high-risk tool.
54    MissingApprovalGate,
55}
56
57/// Result of workflow validation.
58#[derive(Debug, Clone)]
59pub struct ValidationResult {
60    /// Whether the workflow is valid (no errors).
61    pub is_valid: bool,
62
63    /// All issues found during validation.
64    pub issues: Vec<ValidationIssue>,
65}
66
67impl ValidationResult {
68    /// Create a valid result with no issues.
69    pub fn valid() -> Self {
70        Self {
71            is_valid: true,
72            issues: Vec::new(),
73        }
74    }
75
76    /// Create an invalid result with issues.
77    pub fn with_issues(issues: Vec<ValidationIssue>) -> Self {
78        let has_errors = issues.iter().any(|i| i.severity == ValidationSeverity::Error);
79        Self {
80            is_valid: !has_errors,
81            issues,
82        }
83    }
84
85    /// Get only error-level issues.
86    pub fn errors(&self) -> Vec<&ValidationIssue> {
87        self.issues.iter().filter(|i| i.severity == ValidationSeverity::Error).collect()
88    }
89
90    /// Get only warning-level issues.
91    pub fn warnings(&self) -> Vec<&ValidationIssue> {
92        self.issues.iter().filter(|i| i.severity == ValidationSeverity::Warning).collect()
93    }
94}
95
96/// Validate a WorkflowConfig before compilation.
97///
98/// Checks for:
99/// - Duplicate step IDs
100/// - Undefined dependencies (references to non-existent steps)
101/// - Self-dependencies
102/// - Empty steps (no tool or prompt)
103pub fn validate_config(config: &WorkflowConfig) -> ValidationResult {
104    let mut issues = Vec::new();
105
106    // Check for duplicate step IDs
107    let mut seen_ids = std::collections::HashSet::new();
108    for step in &config.steps {
109        if seen_ids.contains(&step.id) {
110            issues.push(ValidationIssue {
111                severity: ValidationSeverity::Error,
112                code: ValidationCode::DuplicateStepId,
113                description: format!("Duplicate step ID: '{}'", step.id),
114                step_ids: vec![step.id.clone()],
115            });
116        }
117        seen_ids.insert(step.id.clone());
118    }
119
120    let step_ids: std::collections::HashSet<String> = config.steps.iter()
121        .map(|s| s.id.clone())
122        .collect();
123
124    // Check for undefined dependencies
125    for step in &config.steps {
126        for dep_id in &step.depends_on {
127            if !step_ids.contains(dep_id) {
128                issues.push(ValidationIssue {
129                    severity: ValidationSeverity::Error,
130                    code: ValidationCode::UndefinedDependency,
131                    description: format!(
132                        "Step '{}' depends on undefined step '{}'",
133                        step.id, dep_id
134                    ),
135                    step_ids: vec![step.id.clone(), dep_id.clone()],
136                });
137            }
138        }
139    }
140
141    // Check for self-dependencies
142    for step in &config.steps {
143        if step.depends_on.contains(&step.id) {
144            issues.push(ValidationIssue {
145                severity: ValidationSeverity::Error,
146                code: ValidationCode::SelfDependency,
147                description: format!("Step '{}' depends on itself", step.id),
148                step_ids: vec![step.id.clone()],
149            });
150        }
151    }
152
153    // Check for empty steps (no tool or prompt)
154    for step in &config.steps {
155        if step.tool.is_none() && step.prompt.is_none() && !step.requires_approval {
156            issues.push(ValidationIssue {
157                severity: ValidationSeverity::Warning,
158                code: ValidationCode::EmptyStep,
159                description: format!(
160                    "Step '{}' has no tool, prompt, or approval checkpoint",
161                    step.id
162                ),
163                step_ids: vec![step.id.clone()],
164            });
165        }
166    }
167
168    ValidationResult::with_issues(issues)
169}
170
171/// Validate a compiled WorkflowDAG.
172///
173/// Checks for:
174/// - Cycles in the dependency graph
175/// - Orphan nodes (unreachable from roots)
176pub fn validate_dag(dag: &WorkflowDag) -> ValidationResult {
177    let mut issues = Vec::new();
178
179    // Check for cycles
180    if dag.has_cycle() {
181        issues.push(ValidationIssue {
182            severity: ValidationSeverity::Error,
183            code: ValidationCode::CycleDetected,
184            description: "Workflow contains a circular dependency cycle".to_string(),
185            step_ids: vec![],
186        });
187    }
188
189    // Check for orphan nodes (not reachable from any root)
190    if !dag.roots.is_empty() {
191        // All nodes reachable from roots
192        let reachable: std::collections::HashSet<String> = dag.roots.iter()
193            .flat_map(|root_id| {
194                let mut visited = std::collections::HashSet::new();
195                let mut queue = std::collections::VecDeque::new();
196                queue.push_back(root_id.clone());
197                while let Some(id) = queue.pop_front() {
198                    if visited.contains(&id) {
199                        continue;
200                    }
201                    visited.insert(id.clone());
202                    // Visit this node AND nodes it depends on (for full reachability)
203                    if let Some(node) = dag.get_node(&id) {
204                        for dep_id in &node.depends_on {
205                            if !visited.contains(dep_id) {
206                                queue.push_back(dep_id.clone());
207                            }
208                        }
209                        for child_id in &node.children {
210                            if !visited.contains(child_id) {
211                                queue.push_back(child_id.clone());
212                            }
213                        }
214                    }
215                }
216                visited
217            })
218            .collect();
219
220        for (id, _) in &dag.nodes {
221            if !reachable.contains(id) {
222                issues.push(ValidationIssue {
223                    severity: ValidationSeverity::Warning,
224                    code: ValidationCode::OrphanNode,
225                    description: format!("Node '{}' is unreachable from any root", id),
226                    step_ids: vec![id.clone()],
227                });
228            }
229        }
230    } else if !dag.nodes.is_empty() && dag.roots.is_empty() {
231        // No roots but nodes exist — likely all in a cycle
232        issues.push(ValidationIssue {
233            severity: ValidationSeverity::Error,
234            code: ValidationCode::CycleDetected,
235            description: "No root nodes found — all nodes may be in a cycle".to_string(),
236            step_ids: vec![],
237        });
238    }
239
240    // Check that nodes at the same level don't depend on each other
241    for level in &dag.levels {
242        for node_id in level {
243            if let Some(node) = dag.get_node(node_id) {
244                for dep_id in &node.depends_on {
245                    if level.contains(dep_id) {
246                        issues.push(ValidationIssue {
247                            severity: ValidationSeverity::Error,
248                            code: ValidationCode::CycleDetected,
249                            description: format!(
250                                "Node '{}' at same level as its dependency '{}'",
251                                node_id, dep_id
252                            ),
253                            step_ids: vec![node_id.clone(), dep_id.clone()],
254                        });
255                    }
256                }
257            }
258        }
259    }
260
261    ValidationResult::with_issues(issues)
262}
263
264/// Full validation: config + DAG.
265pub fn validate(config: &WorkflowConfig, dag: &WorkflowDag) -> ValidationResult {
266    let config_result = validate_config(config);
267    let dag_result = validate_dag(dag);
268
269    let all_issues: Vec<ValidationIssue> = config_result.issues.iter()
270        .chain(dag_result.issues.iter())
271        .cloned()
272        .collect();
273
274    ValidationResult::with_issues(all_issues)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::config::{StepConfig, WorkflowConfig};
281    use crate::dag::{DagNode, WorkflowDag};
282    use crate::compiler::compile;
283    use std::collections::HashMap;
284
285    fn make_step(id: &str, depends_on: Vec<&str>) -> StepConfig {
286        StepConfig {
287            id: id.to_string(),
288            description: format!("Step {}", id),
289            depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
290            tool: Some("test_tool".to_string()),
291            tool_args: None,
292            prompt: None,
293            requires_approval: false,
294            timeout_secs: None,
295            retry_policy: None,
296            metadata: HashMap::new(),
297        }
298    }
299
300    #[test]
301    fn test_validate_valid_config() {
302        let config = WorkflowConfig::new("valid", vec![
303            make_step("step1", vec![]),
304            make_step("step2", vec!["step1"]),
305        ]);
306
307        let result = validate_config(&config);
308        assert!(result.is_valid);
309        assert!(result.issues.is_empty());
310    }
311
312    #[test]
313    fn test_validate_duplicate_ids() {
314        let config = WorkflowConfig::new("invalid", vec![
315            make_step("step1", vec![]),
316            make_step("step1", vec![]),
317        ]);
318
319        let result = validate_config(&config);
320        assert!(!result.is_valid);
321        assert!(result.issues.iter().any(|i| i.code == ValidationCode::DuplicateStepId));
322    }
323
324    #[test]
325    fn test_validate_undefined_dependency() {
326        let config = WorkflowConfig::new("invalid", vec![
327            make_step("step1", vec!["nonexistent"]),
328        ]);
329
330        let result = validate_config(&config);
331        assert!(!result.is_valid);
332        assert!(result.issues.iter().any(|i| i.code == ValidationCode::UndefinedDependency));
333    }
334
335    #[test]
336    fn test_validate_self_dependency() {
337        let config = WorkflowConfig::new("invalid", vec![
338            StepConfig {
339                id: "loop".to_string(),
340                description: "Self-loop".to_string(),
341                depends_on: vec!["loop".to_string()],
342                tool: Some("test".to_string()),
343                tool_args: None,
344                prompt: None,
345                requires_approval: false,
346                timeout_secs: None,
347                retry_policy: None,
348                metadata: HashMap::new(),
349            },
350        ]);
351
352        let result = validate_config(&config);
353        assert!(!result.is_valid);
354        assert!(result.issues.iter().any(|i| i.code == ValidationCode::SelfDependency));
355    }
356
357    #[test]
358    fn test_validate_empty_step_warning() {
359        let config = WorkflowConfig::new("warn", vec![
360            StepConfig {
361                id: "empty".to_string(),
362                description: "Empty step".to_string(),
363                depends_on: vec![],
364                tool: None,
365                tool_args: None,
366                prompt: None,
367                requires_approval: false,
368                timeout_secs: None,
369                retry_policy: None,
370                metadata: HashMap::new(),
371            },
372        ]);
373
374        let result = validate_config(&config);
375        // Empty step is a warning, not an error
376        assert!(result.is_valid); // Warnings don't make it invalid
377        assert!(result.issues.iter().any(|i| i.code == ValidationCode::EmptyStep));
378    }
379
380    #[test]
381    fn test_validate_dag_cycle() {
382        let mut dag = WorkflowDag::new("cycle");
383        dag.add_node(DagNode {
384            id: "a".to_string(),
385            step: make_step("a", vec!["b"]),
386            depends_on: vec!["b".to_string()],
387            children: Vec::new(),
388            level: 0,
389        });
390        dag.add_node(DagNode {
391            id: "b".to_string(),
392            step: make_step("b", vec!["a"]),
393            depends_on: vec!["a".to_string()],
394            children: Vec::new(),
395            level: 0,
396        });
397        dag.build();
398
399        let result = validate_dag(&dag);
400        assert!(!result.is_valid);
401        assert!(result.issues.iter().any(|i| i.code == ValidationCode::CycleDetected));
402    }
403
404    #[test]
405    fn test_validate_dag_no_cycle() {
406        let config = WorkflowConfig::new("valid", vec![
407            make_step("step1", vec![]),
408            make_step("step2", vec!["step1"]),
409        ]);
410        let dag = compile(&config);
411
412        let result = validate_dag(&dag);
413        assert!(result.is_valid);
414    }
415
416    #[test]
417    fn test_full_validation_valid() {
418        let config = WorkflowConfig::new("full_valid", vec![
419            make_step("step1", vec![]),
420            make_step("step2", vec!["step1"]),
421            make_step("step3", vec!["step2"]),
422        ]);
423        let dag = compile(&config);
424
425        let result = validate(&config, &dag);
426        assert!(result.is_valid);
427    }
428
429    #[test]
430    fn test_full_validation_invalid() {
431        let config = WorkflowConfig::new("full_invalid", vec![
432            make_step("step1", vec!["nonexistent"]),
433        ]);
434        let dag = compile(&config);
435
436        let result = validate(&config, &dag);
437        assert!(!result.is_valid);
438    }
439}