1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Plan {
7 pub name: String,
9 #[serde(default)]
10 pub description: Option<String>,
11 #[serde(default)]
13 pub purpose: Option<String>,
14 #[serde(default)]
15 pub cwd: Option<String>,
16 #[serde(default)]
17 pub budget_usd: Option<f64>,
18 #[serde(default = "default_max_attempts")]
19 pub max_attempts: u32,
20 #[serde(default = "default_timeout_sec")]
21 pub timeout_sec: u64,
22 #[serde(default)]
23 pub on_fail: OnFail,
24 #[serde(default)]
25 pub env: HashMap<String, String>,
26 #[serde(default)]
27 pub tags: Vec<String>,
28 pub phases: Vec<Phase>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Phase {
34 pub id: String,
36 pub prompt: String,
37 #[serde(default)]
38 pub cwd: Option<String>,
39 #[serde(default)]
40 pub depends_on: Vec<String>,
41 #[serde(default)]
42 pub check: Vec<CheckSpec>,
43 #[serde(default)]
44 pub max_attempts: Option<u32>,
45 #[serde(default)]
46 pub timeout_sec: Option<u64>,
47 #[serde(default)]
48 pub on_fail: Option<OnFail>,
49 #[serde(default)]
50 pub context: Option<String>,
51 #[serde(default)]
52 pub env: HashMap<String, String>,
53 #[serde(default)]
54 pub budget_usd: Option<f64>,
55 #[serde(default)]
56 pub allowed_tools: Option<Vec<String>>,
57 #[serde(default = "default_permission_mode")]
58 pub permission_mode: String,
59}
60
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum OnFail {
65 #[default]
68 AutoRetry,
69 Ask,
70 Skip,
71 Abort,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
80#[serde(tag = "type", rename_all = "snake_case")]
81pub enum CheckSpec {
82 FileExists {
83 path: String,
84 },
85 CmdSucceeds {
86 cmd: String,
87 #[serde(default = "default_cmd_timeout")]
88 timeout_sec: u64,
89 },
90 FileContains {
91 path: String,
92 pattern: String,
93 },
94 GitClean {
95 #[serde(default)]
96 allow_untracked: bool,
97 },
98 EddaEvent {
99 event_type: String,
100 #[serde(default)]
101 after: Option<String>,
102 },
103 WaitUntil {
104 check: Box<CheckSpec>,
105 #[serde(default = "default_wait_interval")]
106 interval_sec: u64,
107 #[serde(default = "default_wait_timeout")]
108 timeout_sec: u64,
109 #[serde(default)]
110 backoff: BackoffStrategy,
111 },
112}
113
114impl CheckSpec {
115 pub fn type_name(&self) -> &'static str {
117 match self {
118 CheckSpec::FileExists { .. } => "file_exists",
119 CheckSpec::CmdSucceeds { .. } => "cmd_succeeds",
120 CheckSpec::FileContains { .. } => "file_contains",
121 CheckSpec::GitClean { .. } => "git_clean",
122 CheckSpec::EddaEvent { .. } => "edda_event",
123 CheckSpec::WaitUntil { .. } => "wait_until",
124 }
125 }
126
127 pub fn is_retryable(&self) -> bool {
129 match self {
130 CheckSpec::CmdSucceeds { .. } => true,
131 CheckSpec::FileExists { .. } => true,
132 CheckSpec::FileContains { .. } => true,
133 CheckSpec::GitClean { .. } => true,
134 CheckSpec::EddaEvent { .. } => true,
135 CheckSpec::WaitUntil { .. } => false, }
137 }
138}
139
140#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142pub enum BackoffStrategy {
143 None,
144 #[default]
145 Linear,
146 Exponential,
147}
148
149fn default_max_attempts() -> u32 {
150 3
151}
152fn default_timeout_sec() -> u64 {
153 1800
154}
155fn default_permission_mode() -> String {
156 "bypassPermissions".into()
157}
158fn default_cmd_timeout() -> u64 {
159 120
160}
161fn default_wait_interval() -> u64 {
162 30
163}
164fn default_wait_timeout() -> u64 {
165 600
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn on_fail_default_is_auto_retry() {
174 assert_eq!(OnFail::default(), OnFail::AutoRetry);
175 }
176
177 #[test]
178 fn check_spec_type_names() {
179 let c = CheckSpec::CmdSucceeds {
180 cmd: "echo ok".into(),
181 timeout_sec: 120,
182 };
183 assert_eq!(c.type_name(), "cmd_succeeds");
184 assert!(c.is_retryable());
185
186 let w = CheckSpec::WaitUntil {
187 check: Box::new(c),
188 interval_sec: 30,
189 timeout_sec: 600,
190 backoff: BackoffStrategy::Linear,
191 };
192 assert_eq!(w.type_name(), "wait_until");
193 assert!(!w.is_retryable());
194 }
195
196 #[test]
197 fn plan_deserialize_minimal() {
198 let yaml = r#"
199name: test-plan
200phases:
201 - id: step-one
202 prompt: "Do something"
203"#;
204 let plan: Plan = serde_yml::from_str(yaml).unwrap();
205 assert_eq!(plan.name, "test-plan");
206 assert_eq!(plan.phases.len(), 1);
207 assert_eq!(plan.max_attempts, 3);
208 assert_eq!(plan.on_fail, OnFail::AutoRetry);
209 assert_eq!(plan.phases[0].permission_mode, "bypassPermissions");
210 assert!(plan.purpose.is_none());
211 }
212
213 #[test]
214 fn plan_deserialize_with_purpose() {
215 let yaml = r#"
216name: todo-app
217purpose: "Simple todo app, keep it minimal"
218phases:
219 - id: db
220 prompt: "Build schema"
221"#;
222 let plan: Plan = serde_yml::from_str(yaml).unwrap();
223 assert_eq!(
224 plan.purpose.as_deref(),
225 Some("Simple todo app, keep it minimal")
226 );
227 }
228
229 #[test]
230 fn phase_deserialize_full() {
231 let yaml = r#"
232name: full
233phases:
234 - id: build
235 prompt: "Build it"
236 depends_on: []
237 max_attempts: 5
238 timeout_sec: 600
239 on_fail: abort
240 context: "Phase 1"
241 env:
242 FOO: bar
243 allowed_tools: [Read, Write]
244 permission_mode: default
245 check:
246 - type: cmd_succeeds
247 cmd: "cargo build"
248 - type: file_exists
249 path: "target/debug/main"
250"#;
251 let plan: Plan = serde_yml::from_str(yaml).unwrap();
252 let phase = &plan.phases[0];
253 assert_eq!(phase.max_attempts, Some(5));
254 assert_eq!(phase.on_fail, Some(OnFail::Abort));
255 assert_eq!(phase.check.len(), 2);
256 assert_eq!(phase.env.get("FOO").unwrap(), "bar");
257 }
258}