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::config::WorkflowConfig;
11use crate::dag::WorkflowDag;
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
79            .iter()
80            .any(|i| i.severity == ValidationSeverity::Error);
81        Self {
82            is_valid: !has_errors,
83            issues,
84        }
85    }
86
87    /// Get only error-level issues.
88    pub fn errors(&self) -> Vec<&ValidationIssue> {
89        self.issues
90            .iter()
91            .filter(|i| i.severity == ValidationSeverity::Error)
92            .collect()
93    }
94
95    /// Get only warning-level issues.
96    pub fn warnings(&self) -> Vec<&ValidationIssue> {
97        self.issues
98            .iter()
99            .filter(|i| i.severity == ValidationSeverity::Warning)
100            .collect()
101    }
102}
103
104/// Validate a WorkflowConfig before compilation.
105///
106/// Checks for:
107/// - Duplicate step IDs
108/// - Undefined dependencies (references to non-existent steps)
109/// - Self-dependencies
110/// - Empty steps (no tool or prompt)
111pub fn validate_config(config: &WorkflowConfig) -> ValidationResult {
112    let mut issues = Vec::new();
113
114    // Check for duplicate step IDs
115    let mut seen_ids = std::collections::HashSet::new();
116    for step in &config.steps {
117        if seen_ids.contains(&step.id) {
118            issues.push(ValidationIssue {
119                severity: ValidationSeverity::Error,
120                code: ValidationCode::DuplicateStepId,
121                description: format!("Duplicate step ID: '{}'", step.id),
122                step_ids: vec![step.id.clone()],
123            });
124        }
125        seen_ids.insert(step.id.clone());
126    }
127
128    let step_ids: std::collections::HashSet<String> =
129        config.steps.iter().map(|s| s.id.clone()).collect();
130
131    // Check for undefined dependencies
132    for step in &config.steps {
133        for dep_id in &step.depends_on {
134            if !step_ids.contains(dep_id) {
135                issues.push(ValidationIssue {
136                    severity: ValidationSeverity::Error,
137                    code: ValidationCode::UndefinedDependency,
138                    description: format!(
139                        "Step '{}' depends on undefined step '{}'",
140                        step.id, dep_id
141                    ),
142                    step_ids: vec![step.id.clone(), dep_id.clone()],
143                });
144            }
145        }
146    }
147
148    // Check for self-dependencies
149    for step in &config.steps {
150        if step.depends_on.contains(&step.id) {
151            issues.push(ValidationIssue {
152                severity: ValidationSeverity::Error,
153                code: ValidationCode::SelfDependency,
154                description: format!("Step '{}' depends on itself", step.id),
155                step_ids: vec![step.id.clone()],
156            });
157        }
158    }
159
160    // Check for empty steps (no tool or prompt)
161    for step in &config.steps {
162        if step.tool.is_none() && step.prompt.is_none() && !step.requires_approval {
163            issues.push(ValidationIssue {
164                severity: ValidationSeverity::Warning,
165                code: ValidationCode::EmptyStep,
166                description: format!(
167                    "Step '{}' has no tool, prompt, or approval checkpoint",
168                    step.id
169                ),
170                step_ids: vec![step.id.clone()],
171            });
172        }
173    }
174
175    ValidationResult::with_issues(issues)
176}
177
178/// Validate a compiled WorkflowDAG.
179///
180/// Checks for:
181/// - Cycles in the dependency graph
182/// - Orphan nodes (unreachable from roots)
183pub fn validate_dag(dag: &WorkflowDag) -> ValidationResult {
184    let mut issues = Vec::new();
185
186    // Check for cycles
187    if dag.has_cycle() {
188        issues.push(ValidationIssue {
189            severity: ValidationSeverity::Error,
190            code: ValidationCode::CycleDetected,
191            description: "Workflow contains a circular dependency cycle".to_string(),
192            step_ids: vec![],
193        });
194    }
195
196    // Check for orphan nodes (not reachable from any root)
197    if !dag.roots.is_empty() {
198        // All nodes reachable from roots
199        let reachable: std::collections::HashSet<String> = dag
200            .roots
201            .iter()
202            .flat_map(|root_id| {
203                let mut visited = std::collections::HashSet::new();
204                let mut queue = std::collections::VecDeque::new();
205                queue.push_back(root_id.clone());
206                while let Some(id) = queue.pop_front() {
207                    if visited.contains(&id) {
208                        continue;
209                    }
210                    visited.insert(id.clone());
211                    // Visit this node AND nodes it depends on (for full reachability)
212                    if let Some(node) = dag.get_node(&id) {
213                        for dep_id in &node.depends_on {
214                            if !visited.contains(dep_id) {
215                                queue.push_back(dep_id.clone());
216                            }
217                        }
218                        for child_id in &node.children {
219                            if !visited.contains(child_id) {
220                                queue.push_back(child_id.clone());
221                            }
222                        }
223                    }
224                }
225                visited
226            })
227            .collect();
228
229        for id in dag.nodes.keys() {
230            if !reachable.contains(id) {
231                issues.push(ValidationIssue {
232                    severity: ValidationSeverity::Warning,
233                    code: ValidationCode::OrphanNode,
234                    description: format!("Node '{}' is unreachable from any root", id),
235                    step_ids: vec![id.clone()],
236                });
237            }
238        }
239    } else if !dag.nodes.is_empty() && dag.roots.is_empty() {
240        // No roots but nodes exist — likely all in a cycle
241        issues.push(ValidationIssue {
242            severity: ValidationSeverity::Error,
243            code: ValidationCode::CycleDetected,
244            description: "No root nodes found — all nodes may be in a cycle".to_string(),
245            step_ids: vec![],
246        });
247    }
248
249    // Check that nodes at the same level don't depend on each other
250    for level in &dag.levels {
251        for node_id in level {
252            if let Some(node) = dag.get_node(node_id) {
253                for dep_id in &node.depends_on {
254                    if level.contains(dep_id) {
255                        issues.push(ValidationIssue {
256                            severity: ValidationSeverity::Error,
257                            code: ValidationCode::CycleDetected,
258                            description: format!(
259                                "Node '{}' at same level as its dependency '{}'",
260                                node_id, dep_id
261                            ),
262                            step_ids: vec![node_id.clone(), dep_id.clone()],
263                        });
264                    }
265                }
266            }
267        }
268    }
269
270    ValidationResult::with_issues(issues)
271}
272
273/// Full validation: config + DAG.
274pub fn validate(config: &WorkflowConfig, dag: &WorkflowDag) -> ValidationResult {
275    let config_result = validate_config(config);
276    let dag_result = validate_dag(dag);
277
278    let all_issues: Vec<ValidationIssue> = config_result
279        .issues
280        .iter()
281        .chain(dag_result.issues.iter())
282        .cloned()
283        .collect();
284
285    ValidationResult::with_issues(all_issues)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::compiler::compile;
292    use crate::config::{StepConfig, WorkflowConfig};
293    use crate::dag::{DagNode, WorkflowDag};
294    use std::collections::HashMap;
295
296    fn make_step(id: &str, depends_on: Vec<&str>) -> StepConfig {
297        StepConfig {
298            id: id.to_string(),
299            description: format!("Step {}", id),
300            depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
301            tool: Some("test_tool".to_string()),
302            tool_args: None,
303            prompt: None,
304            requires_approval: false,
305            timeout_secs: None,
306            retry_policy: None,
307            metadata: HashMap::new(),
308        }
309    }
310
311    #[test]
312    fn test_validate_valid_config() {
313        let config = WorkflowConfig::new(
314            "valid",
315            vec![
316                make_step("step1", vec![]),
317                make_step("step2", vec!["step1"]),
318            ],
319        );
320
321        let result = validate_config(&config);
322        assert!(result.is_valid);
323        assert!(result.issues.is_empty());
324    }
325
326    #[test]
327    fn test_validate_duplicate_ids() {
328        let config = WorkflowConfig::new(
329            "invalid",
330            vec![make_step("step1", vec![]), make_step("step1", vec![])],
331        );
332
333        let result = validate_config(&config);
334        assert!(!result.is_valid);
335        assert!(result
336            .issues
337            .iter()
338            .any(|i| i.code == ValidationCode::DuplicateStepId));
339    }
340
341    #[test]
342    fn test_validate_undefined_dependency() {
343        let config = WorkflowConfig::new("invalid", vec![make_step("step1", vec!["nonexistent"])]);
344
345        let result = validate_config(&config);
346        assert!(!result.is_valid);
347        assert!(result
348            .issues
349            .iter()
350            .any(|i| i.code == ValidationCode::UndefinedDependency));
351    }
352
353    #[test]
354    fn test_validate_self_dependency() {
355        let config = WorkflowConfig::new(
356            "invalid",
357            vec![StepConfig {
358                id: "loop".to_string(),
359                description: "Self-loop".to_string(),
360                depends_on: vec!["loop".to_string()],
361                tool: Some("test".to_string()),
362                tool_args: None,
363                prompt: None,
364                requires_approval: false,
365                timeout_secs: None,
366                retry_policy: None,
367                metadata: HashMap::new(),
368            }],
369        );
370
371        let result = validate_config(&config);
372        assert!(!result.is_valid);
373        assert!(result
374            .issues
375            .iter()
376            .any(|i| i.code == ValidationCode::SelfDependency));
377    }
378
379    #[test]
380    fn test_validate_empty_step_warning() {
381        let config = WorkflowConfig::new(
382            "warn",
383            vec![StepConfig {
384                id: "empty".to_string(),
385                description: "Empty step".to_string(),
386                depends_on: vec![],
387                tool: None,
388                tool_args: None,
389                prompt: None,
390                requires_approval: false,
391                timeout_secs: None,
392                retry_policy: None,
393                metadata: HashMap::new(),
394            }],
395        );
396
397        let result = validate_config(&config);
398        // Empty step is a warning, not an error
399        assert!(result.is_valid); // Warnings don't make it invalid
400        assert!(result
401            .issues
402            .iter()
403            .any(|i| i.code == ValidationCode::EmptyStep));
404    }
405
406    #[test]
407    fn test_validate_dag_cycle() {
408        let mut dag = WorkflowDag::new("cycle");
409        dag.add_node(DagNode {
410            id: "a".to_string(),
411            step: make_step("a", vec!["b"]),
412            depends_on: vec!["b".to_string()],
413            children: Vec::new(),
414            level: 0,
415        });
416        dag.add_node(DagNode {
417            id: "b".to_string(),
418            step: make_step("b", vec!["a"]),
419            depends_on: vec!["a".to_string()],
420            children: Vec::new(),
421            level: 0,
422        });
423        dag.build();
424
425        let result = validate_dag(&dag);
426        assert!(!result.is_valid);
427        assert!(result
428            .issues
429            .iter()
430            .any(|i| i.code == ValidationCode::CycleDetected));
431    }
432
433    #[test]
434    fn test_validate_dag_no_cycle() {
435        let config = WorkflowConfig::new(
436            "valid",
437            vec![
438                make_step("step1", vec![]),
439                make_step("step2", vec!["step1"]),
440            ],
441        );
442        let dag = compile(&config);
443
444        let result = validate_dag(&dag);
445        assert!(result.is_valid);
446    }
447
448    #[test]
449    fn test_full_validation_valid() {
450        let config = WorkflowConfig::new(
451            "full_valid",
452            vec![
453                make_step("step1", vec![]),
454                make_step("step2", vec!["step1"]),
455                make_step("step3", vec!["step2"]),
456            ],
457        );
458        let dag = compile(&config);
459
460        let result = validate(&config, &dag);
461        assert!(result.is_valid);
462    }
463
464    #[test]
465    fn test_full_validation_invalid() {
466        let config = WorkflowConfig::new(
467            "full_invalid",
468            vec![make_step("step1", vec!["nonexistent"])],
469        );
470        let dag = compile(&config);
471
472        let result = validate(&config, &dag);
473        assert!(!result.is_valid);
474    }
475}