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 {}