1use anyhow::{bail, Result};
2use serde::{Deserialize, Serialize};
3
4use crate::plan::schema::Plan;
5
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum PhaseStatus {
11 Pending,
12 Running,
13 Checking,
14 Passed,
15 Failed,
16 Skipped,
17 Stale,
18}
19
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "snake_case")]
22pub enum PlanStatus {
23 Pending,
24 Running,
25 Blocked,
26 Completed,
27 Aborted,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PlanState {
34 pub plan_name: String,
35 pub plan_file: String,
36 pub plan_status: PlanStatus,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub started_at: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub completed_at: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub aborted_at: Option<String>,
43 #[serde(default)]
44 pub total_cost_usd: f64,
45 pub phases: Vec<PhaseState>,
46 #[serde(default)]
47 pub version: u32,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PhaseState {
52 pub id: String,
53 pub status: PhaseStatus,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub started_at: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub completed_at: Option<String>,
58 #[serde(default)]
59 pub attempts: u32,
60 #[serde(default)]
61 pub checks: Vec<CheckResult>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub error: Option<ErrorInfo>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub skip_reason: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub retry_context: Option<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ErrorInfo {
73 pub error_type: ErrorType,
74 pub message: String,
75 pub retryable: bool,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub check_index: Option<usize>,
78 pub timestamp: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum ErrorType {
84 AgentCrash,
85 CheckFailed,
86 Timeout,
87 BudgetExceeded,
88 UserAbort,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct CheckResult {
93 pub check_type: String,
94 pub status: CheckStatus,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub detail: Option<String>,
97 pub duration_ms: u64,
98}
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub enum CheckStatus {
103 Waiting,
104 Running,
105 Passed,
106 Failed,
107}
108
109const VALID_TRANSITIONS: &[(PhaseStatus, &[PhaseStatus])] = &[
112 (
113 PhaseStatus::Pending,
114 &[PhaseStatus::Running, PhaseStatus::Skipped],
115 ),
116 (
117 PhaseStatus::Running,
118 &[
119 PhaseStatus::Checking,
120 PhaseStatus::Failed,
121 PhaseStatus::Stale,
122 ],
123 ),
124 (
125 PhaseStatus::Checking,
126 &[PhaseStatus::Passed, PhaseStatus::Failed],
127 ),
128 (PhaseStatus::Failed, &[PhaseStatus::Pending]), (PhaseStatus::Stale, &[PhaseStatus::Pending]), ];
132
133fn is_valid_transition(from: PhaseStatus, to: PhaseStatus) -> bool {
134 VALID_TRANSITIONS
135 .iter()
136 .any(|(f, targets)| *f == from && targets.contains(&to))
137}
138
139#[derive(Debug, Clone, Default)]
143pub struct PhaseUpdate {
144 pub started_at: Option<String>,
145 pub completed_at: Option<String>,
146 pub attempts: Option<u32>,
147 pub checks: Option<Vec<CheckResult>>,
148 pub error: Option<ErrorInfo>,
149 pub skip_reason: Option<String>,
150 pub retry_context: Option<Option<String>>,
151}
152
153impl PhaseUpdate {
154 pub fn apply(self, phase: &mut PhaseState) {
155 if let Some(v) = self.started_at {
156 phase.started_at = Some(v);
157 }
158 if let Some(v) = self.completed_at {
159 phase.completed_at = Some(v);
160 }
161 if let Some(v) = self.attempts {
162 phase.attempts = v;
163 }
164 if let Some(v) = self.checks {
165 phase.checks = v;
166 }
167 if self.error.is_some() {
168 phase.error = self.error;
169 }
170 if let Some(v) = self.skip_reason {
171 phase.skip_reason = Some(v);
172 }
173 if let Some(v) = self.retry_context {
174 phase.retry_context = v;
175 }
176 }
177}
178
179pub fn transition(
184 state: &mut PlanState,
185 phase_id: &str,
186 from: PhaseStatus,
187 to: PhaseStatus,
188 side_effect: Option<PhaseUpdate>,
189) -> Result<bool> {
190 let phase = state.get_phase_mut(phase_id)?;
191 if phase.status != from {
192 return Ok(false); }
194 if !is_valid_transition(from, to) {
195 bail!("invalid transition: {phase_id} {from:?} → {to:?}");
196 }
197 phase.status = to;
198 if let Some(update) = side_effect {
199 update.apply(phase);
200 }
201 state.version += 1;
202 Ok(true)
203}
204
205impl PlanState {
208 pub fn from_plan(plan: &Plan, plan_file: &str) -> Self {
210 let phases = plan
211 .phases
212 .iter()
213 .map(|p| PhaseState {
214 id: p.id.clone(),
215 status: PhaseStatus::Pending,
216 started_at: None,
217 completed_at: None,
218 attempts: 0,
219 checks: Vec::new(),
220 error: None,
221 skip_reason: None,
222 retry_context: None,
223 })
224 .collect();
225
226 PlanState {
227 plan_name: plan.name.clone(),
228 plan_file: plan_file.to_string(),
229 plan_status: PlanStatus::Pending,
230 started_at: None,
231 completed_at: None,
232 aborted_at: None,
233 total_cost_usd: 0.0,
234 phases,
235 version: 0,
236 }
237 }
238
239 pub fn get_phase(&self, id: &str) -> Result<&PhaseState> {
240 self.phases
241 .iter()
242 .find(|p| p.id == id)
243 .ok_or_else(|| anyhow::anyhow!("phase not found: \"{id}\""))
244 }
245
246 pub fn get_phase_mut(&mut self, id: &str) -> Result<&mut PhaseState> {
247 self.phases
248 .iter_mut()
249 .find(|p| p.id == id)
250 .ok_or_else(|| anyhow::anyhow!("phase not found: \"{id}\""))
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::plan::parser::parse_plan;
258
259 fn test_plan() -> Plan {
260 parse_plan(
261 r#"
262name: test
263phases:
264 - id: a
265 prompt: "x"
266 - id: b
267 prompt: "x"
268 depends_on: [a]
269"#,
270 )
271 .unwrap()
272 }
273
274 #[test]
275 fn from_plan_initializes_pending() {
276 let plan = test_plan();
277 let state = PlanState::from_plan(&plan, "plan.yaml");
278 assert_eq!(state.plan_status, PlanStatus::Pending);
279 assert_eq!(state.phases.len(), 2);
280 assert!(state
281 .phases
282 .iter()
283 .all(|p| p.status == PhaseStatus::Pending));
284 assert_eq!(state.version, 0);
285 }
286
287 #[test]
288 fn valid_transition_pending_to_running() {
289 let plan = test_plan();
290 let mut state = PlanState::from_plan(&plan, "plan.yaml");
291 let ok = transition(
292 &mut state,
293 "a",
294 PhaseStatus::Pending,
295 PhaseStatus::Running,
296 None,
297 )
298 .unwrap();
299 assert!(ok);
300 assert_eq!(state.get_phase("a").unwrap().status, PhaseStatus::Running);
301 assert_eq!(state.version, 1);
302 }
303
304 #[test]
305 fn cas_miss_returns_false() {
306 let plan = test_plan();
307 let mut state = PlanState::from_plan(&plan, "plan.yaml");
308 let ok = transition(
310 &mut state,
311 "a",
312 PhaseStatus::Running,
313 PhaseStatus::Checking,
314 None,
315 )
316 .unwrap();
317 assert!(!ok);
318 assert_eq!(state.get_phase("a").unwrap().status, PhaseStatus::Pending);
319 }
320
321 #[test]
322 fn invalid_transition_errors() {
323 let plan = test_plan();
324 let mut state = PlanState::from_plan(&plan, "plan.yaml");
325 let err = transition(
327 &mut state,
328 "a",
329 PhaseStatus::Pending,
330 PhaseStatus::Passed,
331 None,
332 );
333 assert!(err.is_err());
334 }
335
336 #[test]
337 fn side_effects_applied() {
338 let plan = test_plan();
339 let mut state = PlanState::from_plan(&plan, "plan.yaml");
340 transition(
341 &mut state,
342 "a",
343 PhaseStatus::Pending,
344 PhaseStatus::Running,
345 Some(PhaseUpdate {
346 started_at: Some("2026-01-01T00:00:00Z".into()),
347 attempts: Some(1),
348 ..Default::default()
349 }),
350 )
351 .unwrap();
352
353 let phase = state.get_phase("a").unwrap();
354 assert_eq!(phase.started_at.as_deref(), Some("2026-01-01T00:00:00Z"));
355 assert_eq!(phase.attempts, 1);
356 }
357
358 #[test]
359 fn retry_transition_failed_to_pending() {
360 let plan = test_plan();
361 let mut state = PlanState::from_plan(&plan, "plan.yaml");
362
363 transition(
365 &mut state,
366 "a",
367 PhaseStatus::Pending,
368 PhaseStatus::Running,
369 None,
370 )
371 .unwrap();
372 transition(
373 &mut state,
374 "a",
375 PhaseStatus::Running,
376 PhaseStatus::Failed,
377 None,
378 )
379 .unwrap();
380 let ok = transition(
381 &mut state,
382 "a",
383 PhaseStatus::Failed,
384 PhaseStatus::Pending,
385 None,
386 )
387 .unwrap();
388 assert!(ok);
389 assert_eq!(state.get_phase("a").unwrap().status, PhaseStatus::Pending);
390 }
391
392 #[test]
393 fn terminal_states_have_no_transitions() {
394 let plan = test_plan();
395 let mut state = PlanState::from_plan(&plan, "plan.yaml");
396
397 transition(
399 &mut state,
400 "a",
401 PhaseStatus::Pending,
402 PhaseStatus::Running,
403 None,
404 )
405 .unwrap();
406 transition(
407 &mut state,
408 "a",
409 PhaseStatus::Running,
410 PhaseStatus::Checking,
411 None,
412 )
413 .unwrap();
414 transition(
415 &mut state,
416 "a",
417 PhaseStatus::Checking,
418 PhaseStatus::Passed,
419 None,
420 )
421 .unwrap();
422
423 let err = transition(
425 &mut state,
426 "a",
427 PhaseStatus::Passed,
428 PhaseStatus::Pending,
429 None,
430 );
431 assert!(err.is_err());
432 }
433
434 #[test]
435 fn unknown_phase_errors() {
436 let plan = test_plan();
437 let mut state = PlanState::from_plan(&plan, "plan.yaml");
438 let err = transition(
439 &mut state,
440 "nonexistent",
441 PhaseStatus::Pending,
442 PhaseStatus::Running,
443 None,
444 );
445 assert!(err.is_err());
446 }
447
448 #[test]
449 fn state_roundtrip_json() {
450 let plan = test_plan();
451 let state = PlanState::from_plan(&plan, "plan.yaml");
452 let json = serde_json::to_string_pretty(&state).unwrap();
453 let restored: PlanState = serde_json::from_str(&json).unwrap();
454 assert_eq!(restored.plan_name, "test");
455 assert_eq!(restored.phases.len(), 2);
456 }
457}