Skip to main content

edda_conductor/plan/
schema.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// A multi-phase AI coding plan.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Plan {
7    /// Kebab-case plan name.
8    pub name: String,
9    #[serde(default)]
10    pub description: Option<String>,
11    /// High-level intent — injected into every phase so agents stay aligned.
12    #[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/// A single phase within a plan.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Phase {
34    /// Kebab-case phase ID, unique within the plan.
35    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/// Failure policy for a phase.
62#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum OnFail {
65    /// Ralph loop: auto-retry with check failure feedback until max_attempts exhausted,
66    /// then fall back to Ask.
67    #[default]
68    AutoRetry,
69    Ask,
70    Skip,
71    Abort,
72}
73
74/// Check specification — what to verify after a phase completes.
75///
76/// In YAML, checks can be written in short format (`cmd_succeeds: "cargo test"`)
77/// or tagged format (`{ type: cmd_succeeds, cmd: "cargo test" }`).
78/// Short format is normalized to tagged during parsing.
79#[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    /// Human-readable type name.
116    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    /// Whether this check type is retryable on failure.
128    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, // already has internal retry
136        }
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}