Skip to main content

ito_domain/schemas/
workflow.rs

1//! Workflow schema.
2//!
3//! This module contains serde models for the workflow definition file.
4//! Workflows are used to describe multi-wave execution plans for an agent tool.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10/// A workflow definition.
11///
12/// This is the user-authored input that describes waves, tasks, and required
13/// inputs.
14pub struct WorkflowDefinition {
15    /// Workflow schema version.
16    pub version: String,
17
18    /// Stable identifier for this workflow.
19    pub id: String,
20
21    /// Human-friendly name.
22    pub name: String,
23
24    /// Optional longer description.
25    #[serde(default, skip_serializing_if = "String::is_empty")]
26    pub description: String,
27
28    /// Optional prerequisites (files/variables) required to run this workflow.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub requires: Option<WorkflowRequires>,
31
32    /// Optional list of files that should be loaded into context.
33    #[serde(
34        rename = "context_files",
35        default,
36        skip_serializing_if = "Option::is_none"
37    )]
38    pub context_files: Option<Vec<String>>,
39
40    /// Ordered list of waves.
41    pub waves: Vec<WaveDefinition>,
42
43    /// Optional actions to take after the workflow completes.
44    #[serde(
45        rename = "on_complete",
46        default,
47        skip_serializing_if = "Option::is_none"
48    )]
49    pub on_complete: Option<OnComplete>,
50}
51
52impl WorkflowDefinition {
53    /// Validate semantic invariants for the workflow.
54    pub fn validate(&self) -> Result<(), String> {
55        if self.version.trim().is_empty() {
56            return Err("workflow.version must not be empty".to_string());
57        }
58        if self.id.trim().is_empty() {
59            return Err("workflow.id must not be empty".to_string());
60        }
61        if self.name.trim().is_empty() {
62            return Err("workflow.name must not be empty".to_string());
63        }
64        if self.waves.is_empty() {
65            return Err("workflow.waves must not be empty".to_string());
66        }
67
68        if let Some(requires) = &self.requires {
69            if let Some(vars) = &requires.variables {
70                for v in vars {
71                    if v.trim().is_empty() {
72                        return Err("workflow.requires.variables contains empty entry".to_string());
73                    }
74                }
75            }
76            if let Some(files) = &requires.files {
77                for f in files {
78                    if f.trim().is_empty() {
79                        return Err("workflow.requires.files contains empty entry".to_string());
80                    }
81                }
82            }
83        }
84
85        if let Some(files) = &self.context_files {
86            for f in files {
87                if f.trim().is_empty() {
88                    return Err("workflow.context_files contains empty entry".to_string());
89                }
90            }
91        }
92
93        let mut seen_waves: Vec<&str> = Vec::new();
94        for wave in &self.waves {
95            wave.validate()?;
96            if seen_waves.contains(&wave.id.as_str()) {
97                return Err(format!("workflow.waves has duplicate id: {}", wave.id));
98            }
99            seen_waves.push(wave.id.as_str());
100        }
101
102        Ok(())
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107/// Prerequisites for running a workflow.
108pub struct WorkflowRequires {
109    /// Required files (paths) that must exist.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub files: Option<Vec<String>>,
112
113    /// Required variables that must be provided.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub variables: Option<Vec<String>>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119/// Optional side effects after a workflow completes.
120pub struct OnComplete {
121    /// Whether to update the workflow state file.
122    #[serde(
123        rename = "update_state",
124        default,
125        skip_serializing_if = "Option::is_none"
126    )]
127    pub update_state: Option<bool>,
128
129    /// Whether to update the roadmap/task tracker.
130    #[serde(
131        rename = "update_roadmap",
132        default,
133        skip_serializing_if = "Option::is_none"
134    )]
135    pub update_roadmap: Option<bool>,
136
137    /// Notification targets (tool-specific).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub notify: Option<Vec<String>>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143/// A wave within a workflow.
144pub struct WaveDefinition {
145    /// Unique wave id.
146    pub id: String,
147
148    /// Optional human-friendly name.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub name: Option<String>,
151
152    /// Tasks executed in this wave.
153    pub tasks: Vec<TaskDefinition>,
154
155    /// Optional checkpoint marker.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub checkpoint: Option<bool>,
158}
159
160impl WaveDefinition {
161    /// Validate semantic invariants for the wave.
162    pub fn validate(&self) -> Result<(), String> {
163        if self.id.trim().is_empty() {
164            return Err("wave.id must not be empty".to_string());
165        }
166        if let Some(name) = &self.name
167            && name.trim().is_empty()
168        {
169            return Err(format!("wave.name must not be empty (wave {})", self.id));
170        }
171        if self.tasks.is_empty() {
172            return Err(format!("wave.tasks must not be empty (wave {})", self.id));
173        }
174
175        let mut seen_tasks: Vec<&str> = Vec::new();
176        for task in &self.tasks {
177            task.validate()?;
178            if seen_tasks.contains(&task.id.as_str()) {
179                return Err(format!(
180                    "wave.tasks has duplicate id: {} (wave {})",
181                    task.id, self.id
182                ));
183            }
184            seen_tasks.push(task.id.as_str());
185        }
186        Ok(())
187    }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191/// A task within a wave.
192pub struct TaskDefinition {
193    /// Unique task id (stable within a workflow).
194    pub id: String,
195
196    /// Human-friendly task name.
197    pub name: String,
198
199    /// Agent category the task should run under.
200    pub agent: AgentType,
201
202    /// Prompt content for the task.
203    pub prompt: String,
204
205    /// Optional additional required inputs.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub inputs: Option<Vec<String>>,
208
209    /// Optional output artifact name.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub output: Option<String>,
212
213    /// Optional task type modifier.
214    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
215    pub task_type: Option<TaskType>,
216
217    /// Optional tool-specific context key/value pairs.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub context: Option<BTreeMap<String, String>>,
220}
221
222impl TaskDefinition {
223    /// Validate semantic invariants for the task.
224    pub fn validate(&self) -> Result<(), String> {
225        if self.id.trim().is_empty() {
226            return Err("task.id must not be empty".to_string());
227        }
228        if self.name.trim().is_empty() {
229            return Err(format!("task.name must not be empty (task {})", self.id));
230        }
231        if self.prompt.trim().is_empty() {
232            return Err(format!("task.prompt must not be empty (task {})", self.id));
233        }
234        if let Some(inputs) = &self.inputs {
235            for i in inputs {
236                if i.trim().is_empty() {
237                    return Err(format!(
238                        "task.inputs contains empty entry (task {})",
239                        self.id
240                    ));
241                }
242            }
243        }
244        if let Some(out) = &self.output
245            && out.trim().is_empty()
246        {
247            return Err(format!("task.output must not be empty (task {})", self.id));
248        }
249        if let Some(ctx) = &self.context {
250            for (k, v) in ctx {
251                if k.trim().is_empty() {
252                    return Err(format!("task.context has empty key (task {})", self.id));
253                }
254                if v.trim().is_empty() {
255                    return Err(format!(
256                        "task.context has empty value for '{k}' (task {})",
257                        self.id
258                    ));
259                }
260            }
261        }
262
263        Ok(())
264    }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269/// High-level agent role used to route tasks.
270pub enum AgentType {
271    /// Research / discovery.
272    Research,
273    /// Execution / implementation.
274    Execution,
275    /// Review / validation.
276    Review,
277    /// Planning / proposal writing.
278    Planning,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283/// Optional modifiers for how a task should be run.
284pub enum TaskType {
285    /// Automatic default behavior.
286    Auto,
287    /// A checkpoint (pause for review).
288    Checkpoint,
289    /// A decision point.
290    Decision,
291}