1use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct WorkflowDefinition {
18 pub name: String,
20 #[serde(default)]
22 pub description: String,
23 pub steps: Vec<WorkflowStepDef>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum WorkflowStepDef {
31 Run {
33 agent: String,
35 task: String,
37 #[serde(default)]
39 output: Option<String>,
40 },
41 Parallel {
43 agents: Vec<String>,
45 task: String,
47 #[serde(default)]
49 concurrency: Option<usize>,
50 },
51 Chain {
53 steps: Vec<WorkflowStepDef>,
55 },
56 ForEach {
58 items_key: String,
60 #[serde(default)]
62 namespace: Option<String>,
63 agent: String,
65 task_template: String,
67 #[serde(default)]
69 concurrency: Option<usize>,
70 },
71 Vote {
73 agents: Vec<String>,
75 question: String,
77 #[serde(default)]
79 threshold: Option<f32>,
80 },
81 SetState {
83 key: String,
85 #[serde(default)]
87 namespace: Option<String>,
88 value: Value,
90 },
91}
92
93impl WorkflowDefinition {
94 pub fn from_yaml_file(path: &str) -> Result<Self> {
96 let content = std::fs::read_to_string(path)
97 .with_context(|| format!("Failed to read workflow file: {}", path))?;
98 Self::from_yaml_str(&content)
99 }
100
101 pub fn from_yaml_str(yaml: &str) -> Result<Self> {
103 let def: WorkflowDefinition =
104 serde_yaml::from_str(yaml).with_context(|| "Failed to parse workflow YAML")?;
105 def.validate()?;
106 Ok(def)
107 }
108
109 fn validate(&self) -> Result<()> {
111 if self.name.is_empty() {
112 anyhow::bail!("Workflow name must not be empty");
113 }
114 if self.steps.is_empty() {
115 anyhow::bail!("Workflow must have at least one step");
116 }
117 for (i, step) in self.steps.iter().enumerate() {
119 Self::validate_step(step, i)?;
120 }
121 Ok(())
122 }
123
124 fn validate_step(step: &WorkflowStepDef, index: usize) -> Result<()> {
125 match step {
126 WorkflowStepDef::Run { agent, task, .. } => {
127 if agent.is_empty() {
128 anyhow::bail!("Step {}: agent name must not be empty", index);
129 }
130 if task.is_empty() {
131 anyhow::bail!("Step {}: task must not be empty", index);
132 }
133 }
134 WorkflowStepDef::Parallel {
135 agents,
136 task,
137 concurrency,
138 ..
139 } => {
140 if agents.is_empty() {
141 anyhow::bail!("Step {}: parallel must have at least one agent", index);
142 }
143 if task.is_empty() {
144 anyhow::bail!("Step {}: task must not be empty", index);
145 }
146 if let Some(c) = concurrency
147 && *c == 0
148 {
149 anyhow::bail!("Step {}: concurrency must be > 0", index);
150 }
151 }
152 WorkflowStepDef::Chain { steps } => {
153 if steps.is_empty() {
154 anyhow::bail!("Step {}: chain must have at least one sub-step", index);
155 }
156 for (j, sub) in steps.iter().enumerate() {
157 Self::validate_step(sub, j)?;
158 }
159 }
160 WorkflowStepDef::ForEach {
161 agent,
162 task_template,
163 ..
164 } => {
165 if agent.is_empty() {
166 anyhow::bail!("Step {}: agent must not be empty", index);
167 }
168 if task_template.is_empty() {
169 anyhow::bail!("Step {}: task_template must not be empty", index);
170 }
171 }
172 WorkflowStepDef::Vote {
173 agents, question, ..
174 } => {
175 if agents.is_empty() {
176 anyhow::bail!("Step {}: vote must have at least one agent", index);
177 }
178 if question.is_empty() {
179 anyhow::bail!("Step {}: question must not be empty", index);
180 }
181 }
182 WorkflowStepDef::SetState { key, .. } => {
183 if key.is_empty() {
184 anyhow::bail!("Step {}: key must not be empty", index);
185 }
186 }
187 }
188 Ok(())
189 }
190
191 pub fn step_count(&self) -> usize {
193 self.steps.iter().map(Self::count_step).sum()
194 }
195
196 fn count_step(step: &WorkflowStepDef) -> usize {
197 match step {
198 WorkflowStepDef::Chain { steps } => {
199 1 + steps.iter().map(Self::count_step).sum::<usize>()
200 }
201 _ => 1,
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn test_parse_simple_workflow() {
212 let yaml = r#"
213name: code-review
214steps:
215 - type: run
216 agent: reviewer
217 task: "Review the code changes"
218 - type: set_state
219 key: review_result
220 value: "pending"
221"#;
222 let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
223 assert_eq!(wf.name, "code-review");
224 assert_eq!(wf.steps.len(), 2);
225 assert_eq!(wf.step_count(), 2);
226 }
227
228 #[test]
229 fn test_parse_parallel_workflow() {
230 let yaml = r#"
231name: parallel-test
232steps:
233 - type: parallel
234 agents: [agent-a, agent-b, agent-c]
235 task: "Analyze the codebase"
236 concurrency: 2
237"#;
238 let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
239 assert_eq!(wf.name, "parallel-test");
240 match &wf.steps[0] {
241 WorkflowStepDef::Parallel {
242 agents,
243 concurrency,
244 ..
245 } => {
246 assert_eq!(agents.len(), 3);
247 assert_eq!(*concurrency, Some(2));
248 }
249 _ => panic!("Expected Parallel step"),
250 }
251 }
252
253 #[test]
254 fn test_parse_chain_workflow() {
255 let yaml = r#"
256name: pipeline
257steps:
258 - type: chain
259 steps:
260 - type: run
261 agent: designer
262 task: "Design the API"
263 - type: run
264 agent: implementer
265 task: "Implement the design"
266"#;
267 let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
268 assert_eq!(wf.step_count(), 3); }
270
271 #[test]
272 fn test_parse_vote_workflow() {
273 let yaml = r#"
274name: consensus
275steps:
276 - type: vote
277 agents: [agent-a, agent-b, agent-c]
278 question: "Which approach is best?"
279 threshold: 0.66
280"#;
281 let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
282 match &wf.steps[0] {
283 WorkflowStepDef::Vote {
284 agents,
285 question,
286 threshold,
287 } => {
288 assert_eq!(agents.len(), 3);
289 assert_eq!(question, "Which approach is best?");
290 assert_eq!(*threshold, Some(0.66));
291 }
292 _ => panic!("Expected Vote step"),
293 }
294 }
295
296 #[test]
297 fn test_validation_empty_name() {
298 let yaml = r#"
299name: ""
300steps:
301 - type: run
302 agent: a
303 task: t
304"#;
305 assert!(WorkflowDefinition::from_yaml_str(yaml).is_err());
306 }
307
308 #[test]
309 fn test_validation_empty_steps() {
310 let yaml = r#"
311name: empty
312steps: []
313"#;
314 assert!(WorkflowDefinition::from_yaml_str(yaml).is_err());
315 }
316
317 #[test]
318 fn test_foreach_workflow() {
319 let yaml = r#"
320name: batch-process
321steps:
322 - type: for_each
323 items_key: file_list
324 agent: file-processor
325 task_template: "Process file: {item}"
326 concurrency: 4
327"#;
328 let wf = WorkflowDefinition::from_yaml_str(yaml).unwrap();
329 match &wf.steps[0] {
330 WorkflowStepDef::ForEach {
331 items_key,
332 agent,
333 task_template,
334 concurrency,
335 ..
336 } => {
337 assert_eq!(items_key, "file_list");
338 assert_eq!(agent, "file-processor");
339 assert_eq!(task_template, "Process file: {item}");
340 assert_eq!(*concurrency, Some(4));
341 }
342 _ => panic!("Expected ForEach step"),
343 }
344 }
345}