1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum Status {
11 Open,
12 InProgress,
13 Closed,
14}
15
16impl std::fmt::Display for Status {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Status::Open => write!(f, "open"),
20 Status::InProgress => write!(f, "in_progress"),
21 Status::Closed => write!(f, "closed"),
22 }
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum RunResult {
34 Pass,
35 Fail,
36 Timeout,
37 Cancelled,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct RunRecord {
43 pub attempt: u32,
44 pub started_at: DateTime<Utc>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub finished_at: Option<DateTime<Utc>>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub duration_secs: Option<f64>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub agent: Option<String>,
51 pub result: RunResult,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub exit_code: Option<i32>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub tokens: Option<u64>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub cost: Option<f64>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub output_snippet: Option<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(tag = "action", rename_all = "snake_case")]
69pub enum OnFailAction {
70 Retry {
72 #[serde(skip_serializing_if = "Option::is_none")]
73 max: Option<u32>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 delay_secs: Option<u64>,
76 },
77 Escalate {
79 #[serde(skip_serializing_if = "Option::is_none")]
80 priority: Option<u8>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 message: Option<String>,
83 },
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(tag = "action", rename_all = "snake_case")]
90pub enum OnCloseAction {
91 Run { command: String },
93 Notify { message: String },
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum AttemptOutcome {
105 Success,
106 Failed,
107 Abandoned,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct AttemptRecord {
113 pub num: u32,
114 pub outcome: AttemptOutcome,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub notes: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub agent: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub started_at: Option<DateTime<Utc>>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub finished_at: Option<DateTime<Utc>>,
123}
124
125#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn status_serializes_as_lowercase() {
135 let open = serde_yml::to_string(&Status::Open).unwrap();
136 let in_progress = serde_yml::to_string(&Status::InProgress).unwrap();
137 let closed = serde_yml::to_string(&Status::Closed).unwrap();
138
139 assert_eq!(open.trim(), "open");
140 assert_eq!(in_progress.trim(), "in_progress");
141 assert_eq!(closed.trim(), "closed");
142 }
143
144 #[test]
145 fn run_result_serializes_as_snake_case() {
146 assert_eq!(
147 serde_yml::to_string(&RunResult::Pass).unwrap().trim(),
148 "pass"
149 );
150 assert_eq!(
151 serde_yml::to_string(&RunResult::Fail).unwrap().trim(),
152 "fail"
153 );
154 assert_eq!(
155 serde_yml::to_string(&RunResult::Timeout).unwrap().trim(),
156 "timeout"
157 );
158 assert_eq!(
159 serde_yml::to_string(&RunResult::Cancelled).unwrap().trim(),
160 "cancelled"
161 );
162 }
163
164 #[test]
165 fn run_record_minimal_round_trip() {
166 let now = Utc::now();
167 let record = RunRecord {
168 attempt: 1,
169 started_at: now,
170 finished_at: None,
171 duration_secs: None,
172 agent: None,
173 result: RunResult::Pass,
174 exit_code: None,
175 tokens: None,
176 cost: None,
177 output_snippet: None,
178 };
179
180 let yaml = serde_yml::to_string(&record).unwrap();
181 let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
182 assert_eq!(record, restored);
183
184 assert!(!yaml.contains("finished_at:"));
186 assert!(!yaml.contains("duration_secs:"));
187 assert!(!yaml.contains("agent:"));
188 assert!(!yaml.contains("exit_code:"));
189 assert!(!yaml.contains("tokens:"));
190 assert!(!yaml.contains("cost:"));
191 assert!(!yaml.contains("output_snippet:"));
192 }
193
194 #[test]
195 fn run_record_full_round_trip() {
196 let now = Utc::now();
197 let record = RunRecord {
198 attempt: 3,
199 started_at: now,
200 finished_at: Some(now),
201 duration_secs: Some(12.5),
202 agent: Some("agent-42".to_string()),
203 result: RunResult::Fail,
204 exit_code: Some(1),
205 tokens: Some(5000),
206 cost: Some(0.03),
207 output_snippet: Some("FAILED: assertion error".to_string()),
208 };
209
210 let yaml = serde_yml::to_string(&record).unwrap();
211 let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
212 assert_eq!(record, restored);
213 }
214
215 #[test]
216 fn history_with_cancelled_result() {
217 let now = Utc::now();
218 let record = RunRecord {
219 attempt: 1,
220 started_at: now,
221 finished_at: None,
222 duration_secs: None,
223 agent: None,
224 result: RunResult::Cancelled,
225 exit_code: None,
226 tokens: None,
227 cost: None,
228 output_snippet: None,
229 };
230
231 let yaml = serde_yml::to_string(&record).unwrap();
232 assert!(yaml.contains("cancelled"));
233 let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
234 assert_eq!(restored.result, RunResult::Cancelled);
235 }
236}