275 lines
6.8 KiB
Rust
275 lines
6.8 KiB
Rust
|
|
// vapora-backend: Workflow YAML parser
|
||
|
|
// Phase 3: Parse workflow definitions from YAML
|
||
|
|
|
||
|
|
use crate::workflow::state::{Phase, StepStatus, Workflow, WorkflowStep};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use std::fs;
|
||
|
|
use thiserror::Error;
|
||
|
|
|
||
|
|
#[derive(Debug, Error)]
|
||
|
|
pub enum ParserError {
|
||
|
|
#[error("Failed to read file: {0}")]
|
||
|
|
FileError(#[from] std::io::Error),
|
||
|
|
|
||
|
|
#[error("Failed to parse YAML: {0}")]
|
||
|
|
YamlError(#[from] serde_yaml::Error),
|
||
|
|
|
||
|
|
#[error("Invalid workflow definition: {0}")]
|
||
|
|
ValidationError(String),
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Serialize)]
|
||
|
|
pub struct WorkflowYaml {
|
||
|
|
pub workflow: WorkflowDef,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Serialize)]
|
||
|
|
pub struct WorkflowDef {
|
||
|
|
pub id: String,
|
||
|
|
pub title: String,
|
||
|
|
pub phases: Vec<PhaseDef>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Serialize)]
|
||
|
|
pub struct PhaseDef {
|
||
|
|
pub id: String,
|
||
|
|
pub name: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub parallel: bool,
|
||
|
|
#[serde(default = "default_estimated_hours")]
|
||
|
|
pub estimated_hours: f32,
|
||
|
|
pub steps: Vec<StepDef>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Serialize)]
|
||
|
|
pub struct StepDef {
|
||
|
|
pub id: String,
|
||
|
|
pub name: String,
|
||
|
|
pub agent: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub depends_on: Vec<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub parallelizable: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_estimated_hours() -> f32 {
|
||
|
|
1.0
|
||
|
|
}
|
||
|
|
|
||
|
|
pub struct WorkflowParser;
|
||
|
|
|
||
|
|
impl WorkflowParser {
|
||
|
|
/// Parse workflow from YAML file
|
||
|
|
pub fn parse_file(path: &str) -> Result<Workflow, ParserError> {
|
||
|
|
let content = fs::read_to_string(path)?;
|
||
|
|
Self::parse_string(&content)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse workflow from YAML string
|
||
|
|
pub fn parse_string(yaml: &str) -> Result<Workflow, ParserError> {
|
||
|
|
let workflow_yaml: WorkflowYaml = serde_yaml::from_str(yaml)?;
|
||
|
|
Self::validate_and_convert(workflow_yaml)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Validate and convert YAML definition to runtime Workflow
|
||
|
|
fn validate_and_convert(yaml: WorkflowYaml) -> Result<Workflow, ParserError> {
|
||
|
|
let def = yaml.workflow;
|
||
|
|
|
||
|
|
// Validate workflow has phases
|
||
|
|
if def.phases.is_empty() {
|
||
|
|
return Err(ParserError::ValidationError(
|
||
|
|
"Workflow must have at least one phase".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Convert phases
|
||
|
|
let mut phases = Vec::new();
|
||
|
|
for phase_def in def.phases {
|
||
|
|
// Validate phase has steps
|
||
|
|
if phase_def.steps.is_empty() {
|
||
|
|
return Err(ParserError::ValidationError(format!(
|
||
|
|
"Phase '{}' must have at least one step",
|
||
|
|
phase_def.id
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Convert steps
|
||
|
|
let steps: Vec<WorkflowStep> = phase_def
|
||
|
|
.steps
|
||
|
|
.into_iter()
|
||
|
|
.map(|step_def| WorkflowStep {
|
||
|
|
id: step_def.id,
|
||
|
|
name: step_def.name,
|
||
|
|
agent_role: step_def.agent,
|
||
|
|
status: StepStatus::Pending,
|
||
|
|
depends_on: step_def.depends_on,
|
||
|
|
can_parallelize: step_def.parallelizable,
|
||
|
|
started_at: None,
|
||
|
|
completed_at: None,
|
||
|
|
result: None,
|
||
|
|
error: None,
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
// Validate dependencies exist
|
||
|
|
Self::validate_dependencies(&steps)?;
|
||
|
|
|
||
|
|
phases.push(Phase {
|
||
|
|
id: phase_def.id,
|
||
|
|
name: phase_def.name,
|
||
|
|
status: StepStatus::Pending,
|
||
|
|
steps,
|
||
|
|
parallel: phase_def.parallel,
|
||
|
|
estimated_hours: phase_def.estimated_hours,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(Workflow::new(def.id, def.title, phases))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Validate that all step dependencies exist
|
||
|
|
fn validate_dependencies(steps: &[WorkflowStep]) -> Result<(), ParserError> {
|
||
|
|
let step_ids: std::collections::HashSet<_> = steps.iter().map(|s| &s.id).collect();
|
||
|
|
|
||
|
|
for step in steps {
|
||
|
|
for dep in &step.depends_on {
|
||
|
|
if !step_ids.contains(dep) {
|
||
|
|
return Err(ParserError::ValidationError(format!(
|
||
|
|
"Step '{}' depends on non-existent step '{}'",
|
||
|
|
step.id, dep
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_parse_workflow_yaml() {
|
||
|
|
let yaml = r#"
|
||
|
|
workflow:
|
||
|
|
id: feature-auth
|
||
|
|
title: Implement MFA
|
||
|
|
phases:
|
||
|
|
- id: phase_1
|
||
|
|
name: Design
|
||
|
|
parallel: false
|
||
|
|
estimated_hours: 2.0
|
||
|
|
steps:
|
||
|
|
- id: step_1_1
|
||
|
|
name: Architect Design
|
||
|
|
agent: architect
|
||
|
|
depends_on: []
|
||
|
|
parallelizable: false
|
||
|
|
- id: phase_2
|
||
|
|
name: Implementation
|
||
|
|
parallel: true
|
||
|
|
estimated_hours: 8.0
|
||
|
|
steps:
|
||
|
|
- id: step_2_1
|
||
|
|
name: Backend API
|
||
|
|
agent: developer
|
||
|
|
depends_on: []
|
||
|
|
parallelizable: true
|
||
|
|
- id: step_2_2
|
||
|
|
name: Frontend UI
|
||
|
|
agent: developer
|
||
|
|
depends_on: []
|
||
|
|
parallelizable: true
|
||
|
|
"#;
|
||
|
|
|
||
|
|
let result = WorkflowParser::parse_string(yaml);
|
||
|
|
assert!(result.is_ok());
|
||
|
|
|
||
|
|
let workflow = result.unwrap();
|
||
|
|
assert_eq!(workflow.id, "feature-auth");
|
||
|
|
assert_eq!(workflow.title, "Implement MFA");
|
||
|
|
assert_eq!(workflow.phases.len(), 2);
|
||
|
|
assert_eq!(workflow.phases[0].steps.len(), 1);
|
||
|
|
assert_eq!(workflow.phases[1].steps.len(), 2);
|
||
|
|
assert!(workflow.phases[1].parallel);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_empty_phases_error() {
|
||
|
|
let yaml = r#"
|
||
|
|
workflow:
|
||
|
|
id: test
|
||
|
|
title: Test
|
||
|
|
phases: []
|
||
|
|
"#;
|
||
|
|
|
||
|
|
let result = WorkflowParser::parse_string(yaml);
|
||
|
|
assert!(result.is_err());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_empty_steps_error() {
|
||
|
|
let yaml = r#"
|
||
|
|
workflow:
|
||
|
|
id: test
|
||
|
|
title: Test
|
||
|
|
phases:
|
||
|
|
- id: phase_1
|
||
|
|
name: Phase
|
||
|
|
steps: []
|
||
|
|
"#;
|
||
|
|
|
||
|
|
let result = WorkflowParser::parse_string(yaml);
|
||
|
|
assert!(result.is_err());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_invalid_dependency() {
|
||
|
|
let yaml = r#"
|
||
|
|
workflow:
|
||
|
|
id: test
|
||
|
|
title: Test
|
||
|
|
phases:
|
||
|
|
- id: phase_1
|
||
|
|
name: Phase
|
||
|
|
steps:
|
||
|
|
- id: step_1
|
||
|
|
name: Step 1
|
||
|
|
agent: developer
|
||
|
|
depends_on: [nonexistent]
|
||
|
|
"#;
|
||
|
|
|
||
|
|
let result = WorkflowParser::parse_string(yaml);
|
||
|
|
assert!(result.is_err());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_valid_dependencies() {
|
||
|
|
let yaml = r#"
|
||
|
|
workflow:
|
||
|
|
id: test
|
||
|
|
title: Test
|
||
|
|
phases:
|
||
|
|
- id: phase_1
|
||
|
|
name: Phase
|
||
|
|
steps:
|
||
|
|
- id: step_1
|
||
|
|
name: Step 1
|
||
|
|
agent: developer
|
||
|
|
depends_on: []
|
||
|
|
- id: step_2
|
||
|
|
name: Step 2
|
||
|
|
agent: developer
|
||
|
|
depends_on: [step_1]
|
||
|
|
"#;
|
||
|
|
|
||
|
|
let result = WorkflowParser::parse_string(yaml);
|
||
|
|
assert!(result.is_ok());
|
||
|
|
|
||
|
|
let workflow = result.unwrap();
|
||
|
|
assert_eq!(workflow.phases[0].steps[1].depends_on, vec!["step_1"]);
|
||
|
|
}
|
||
|
|
}
|