Skip to main content

agentctl/workflow/
schema.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeSet;
3use std::fmt;
4
5pub const WORKFLOW_SCHEMA_VERSION: &str = "agentctl.workflow.v1";
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct WorkflowDocument {
9    #[serde(default = "default_schema_version")]
10    pub schema_version: String,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub name: Option<String>,
13    #[serde(default, alias = "mode")]
14    pub on_error: WorkflowOnError,
15    pub steps: Vec<WorkflowStep>,
16}
17
18impl WorkflowDocument {
19    pub fn validate(&self) -> Result<(), WorkflowSchemaError> {
20        if self.schema_version != WORKFLOW_SCHEMA_VERSION {
21            return Err(WorkflowSchemaError::new(format!(
22                "unsupported schema_version '{}'; expected '{}'",
23                self.schema_version, WORKFLOW_SCHEMA_VERSION
24            )));
25        }
26
27        if self.steps.is_empty() {
28            return Err(WorkflowSchemaError::new(
29                "workflow must define at least one step",
30            ));
31        }
32
33        let mut ids = BTreeSet::new();
34        for step in &self.steps {
35            let step_id = step.id().trim();
36            if step_id.is_empty() {
37                return Err(WorkflowSchemaError::new(
38                    "workflow step id must not be empty",
39                ));
40            }
41
42            if !ids.insert(step_id.to_string()) {
43                return Err(WorkflowSchemaError::new(format!(
44                    "duplicate workflow step id '{}'",
45                    step_id
46                )));
47            }
48
49            if step.retry().max_attempts == 0 {
50                return Err(WorkflowSchemaError::new(format!(
51                    "step '{}' retry.max_attempts must be >= 1",
52                    step_id
53                )));
54            }
55
56            if step.timeout_ms() == Some(0) {
57                return Err(WorkflowSchemaError::new(format!(
58                    "step '{}' timeout_ms must be >= 1 when provided",
59                    step_id
60                )));
61            }
62
63            if let WorkflowStep::Provider(provider) = step
64                && provider.task.trim().is_empty()
65            {
66                return Err(WorkflowSchemaError::new(format!(
67                    "provider step '{}' task must not be empty",
68                    step_id
69                )));
70            }
71        }
72
73        Ok(())
74    }
75}
76
77fn default_schema_version() -> String {
78    WORKFLOW_SCHEMA_VERSION.to_string()
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "kebab-case")]
83pub enum WorkflowOnError {
84    #[default]
85    FailFast,
86    ContinueOnError,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(tag = "type", rename_all = "kebab-case")]
91pub enum WorkflowStep {
92    Provider(ProviderStep),
93    Automation(AutomationStep),
94}
95
96impl WorkflowStep {
97    pub fn id(&self) -> &str {
98        match self {
99            Self::Provider(step) => &step.id,
100            Self::Automation(step) => &step.id,
101        }
102    }
103
104    pub fn retry(&self) -> RetryPolicy {
105        match self {
106            Self::Provider(step) => step.retry,
107            Self::Automation(step) => step.retry,
108        }
109    }
110
111    pub fn timeout_ms(&self) -> Option<u64> {
112        match self {
113            Self::Provider(step) => step.timeout_ms,
114            Self::Automation(step) => step.timeout_ms,
115        }
116    }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ProviderStep {
121    pub id: String,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub provider: Option<String>,
124    pub task: String,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub input: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub timeout_ms: Option<u64>,
129    #[serde(default)]
130    pub retry: RetryPolicy,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct AutomationStep {
135    pub id: String,
136    pub tool: AutomationTool,
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub args: Vec<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub timeout_ms: Option<u64>,
141    #[serde(default)]
142    pub retry: RetryPolicy,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "kebab-case")]
147pub enum AutomationTool {
148    MacosAgent,
149    ScreenRecord,
150    ImageProcessing,
151    FzfCli,
152}
153
154impl AutomationTool {
155    pub const fn as_id(self) -> &'static str {
156        match self {
157            Self::MacosAgent => "macos-agent",
158            Self::ScreenRecord => "screen-record",
159            Self::ImageProcessing => "image-processing",
160            Self::FzfCli => "fzf-cli",
161        }
162    }
163
164    pub const fn command(self) -> &'static str {
165        self.as_id()
166    }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170pub struct RetryPolicy {
171    #[serde(default = "default_max_attempts")]
172    pub max_attempts: u32,
173    #[serde(default)]
174    pub backoff_ms: u64,
175}
176
177impl RetryPolicy {
178    pub fn normalized_max_attempts(self) -> u32 {
179        self.max_attempts.max(1)
180    }
181}
182
183impl Default for RetryPolicy {
184    fn default() -> Self {
185        Self {
186            max_attempts: default_max_attempts(),
187            backoff_ms: 0,
188        }
189    }
190}
191
192fn default_max_attempts() -> u32 {
193    1
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct WorkflowSchemaError {
198    message: String,
199}
200
201impl WorkflowSchemaError {
202    pub fn new(message: impl Into<String>) -> Self {
203        Self {
204            message: message.into(),
205        }
206    }
207}
208
209impl fmt::Display for WorkflowSchemaError {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        f.write_str(self.message.as_str())
212    }
213}
214
215impl std::error::Error for WorkflowSchemaError {}