1use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::mode::Mode;
13use crate::stage::Stage;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct State {
18 pub stage: Stage,
20 pub phase: u32,
22 pub agent: AgentKind,
24 pub mode: Mode,
26 #[serde(default)]
28 pub gate_pending: bool,
29 #[serde(default)]
33 pub consecutive_failures: u32,
34 #[serde(default)]
43 pub infra_failures: u32,
44 #[serde(default)]
55 pub preflight_retries: u32,
56 pub started_at: String,
58 pub project_root: PathBuf,
60 #[serde(default)]
65 pub worktree_path: Option<PathBuf>,
66 #[serde(default)]
72 pub monitor_pid: Option<u32>,
73 #[serde(default)]
79 pub stop_until: Option<Stage>,
80 #[serde(default)]
85 pub stopped: bool,
86 #[serde(default)]
89 pub stop_reason: Option<String>,
90 #[serde(default)]
101 pub yes_ship: bool,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum AgentKind {
108 Claude,
110 Codex,
112 OpenCode,
114}
115
116impl fmt::Display for AgentKind {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 let name = match self {
119 AgentKind::Claude => "claude",
120 AgentKind::Codex => "codex",
121 AgentKind::OpenCode => "opencode",
122 };
123 f.write_str(name)
124 }
125}
126
127impl FromStr for AgentKind {
128 type Err = AgentParseError;
129
130 fn from_str(value: &str) -> Result<Self, Self::Err> {
131 match value.to_ascii_lowercase().as_str() {
132 "claude" => Ok(AgentKind::Claude),
133 "codex" => Ok(AgentKind::Codex),
134 "opencode" | "open-code" => Ok(AgentKind::OpenCode),
135 other => Err(AgentParseError(other.to_string())),
136 }
137 }
138}
139
140#[derive(Debug, Clone, thiserror::Error)]
142#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
143pub struct AgentParseError(String);
144
145impl State {
146 pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
148 State {
149 stage: Stage::Define,
150 phase,
151 agent,
152 mode,
153 gate_pending: false,
154 consecutive_failures: 0,
155 infra_failures: 0,
156 preflight_retries: 0,
157 started_at: timestamp_now(),
158 project_root,
159 worktree_path: None,
160 monitor_pid: None,
161 stop_until: None,
162 stopped: false,
163 stop_reason: None,
164 yes_ship: false,
165 }
166 }
167}
168
169fn timestamp_now() -> String {
170 match SystemTime::now().duration_since(UNIX_EPOCH) {
171 Ok(duration) => format!("{}", duration.as_secs()),
172 Err(_) => String::from("0"),
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::path::PathBuf;
180
181 #[test]
182 fn agent_name_and_display() {
183 use crate::agents::adapter_for;
184 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
185 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
186 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
187
188 assert_eq!(AgentKind::Claude.to_string(), "claude");
189 assert_eq!(AgentKind::Codex.to_string(), "codex");
190 assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
191 }
192
193 #[test]
194 fn agent_from_str_accepts_canonical_and_aliases() {
195 assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
196 assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
197 assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
198 assert_eq!(
199 "opencode".parse::<AgentKind>().unwrap(),
200 AgentKind::OpenCode
201 );
202 assert_eq!(
203 "open-code".parse::<AgentKind>().unwrap(),
204 AgentKind::OpenCode
205 );
206 }
207
208 #[test]
209 fn agent_from_str_rejects_unknown() {
210 let err = "aider".parse::<AgentKind>().unwrap_err();
211 assert!(err.to_string().contains("aider"));
212 }
213
214 #[test]
215 fn new_state_starts_at_define() {
216 let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
217 assert_eq!(state.stage, Stage::Define);
218 assert_eq!(state.phase, 2);
219 assert_eq!(state.agent, AgentKind::Claude);
220 assert_eq!(state.mode, Mode::Auto);
221 assert!(!state.gate_pending);
222 assert_eq!(state.consecutive_failures, 0);
223 assert_eq!(state.infra_failures, 0);
224 assert_eq!(state.preflight_retries, 0);
225 assert!(!state.started_at.is_empty());
226 assert_eq!(state.monitor_pid, None);
227 assert_eq!(state.stop_until, None);
228 assert!(!state.stopped);
229 assert_eq!(state.stop_reason, None);
230 assert!(!state.yes_ship);
231 }
232
233 #[test]
234 fn state_serde_round_trips() {
235 let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
236 let json = serde_json::to_string(&state).unwrap();
237 let back: State = serde_json::from_str(&json).unwrap();
238 assert_eq!(back.phase, 9);
239 assert_eq!(back.agent, AgentKind::Codex);
240 assert_eq!(back.stage, Stage::Define);
241 assert_eq!(back.mode, Mode::Supervise);
242 }
243
244 #[test]
245 fn consecutive_failures_persists_across_advance_calls() {
246 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
247 state.consecutive_failures = 3;
248 let json = serde_json::to_string(&state).unwrap();
249 assert!(
250 json.contains("consecutive_failures"),
251 "consecutive_failures must appear in persisted JSON"
252 );
253 let loaded: State = serde_json::from_str(&json).unwrap();
254 assert_eq!(
255 loaded.consecutive_failures, 3,
256 "consecutive_failures must round-trip through serde"
257 );
258 }
259
260 #[test]
263 fn infra_failures_round_trips_through_serde() {
264 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
265 state.infra_failures = 4;
266 let json = serde_json::to_string(&state).unwrap();
267 assert!(
268 json.contains("infra_failures"),
269 "infra_failures must appear in persisted JSON"
270 );
271 let loaded: State = serde_json::from_str(&json).unwrap();
272 assert_eq!(
273 loaded.infra_failures, 4,
274 "infra_failures must round-trip through serde"
275 );
276 }
277
278 #[test]
281 fn infra_failures_absent_from_json_defaults_to_zero() {
282 let json = r#"{
283 "stage": "code",
284 "phase": 1,
285 "agent": "claude",
286 "mode": "auto",
287 "started_at": "0",
288 "project_root": "/repo"
289 }"#;
290 let loaded: State = serde_json::from_str(json).unwrap();
291 assert_eq!(loaded.infra_failures, 0);
292 }
293
294 #[test]
300 fn preflight_retries_round_trips_through_serde() {
301 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
302 state.preflight_retries = 2;
303 let json = serde_json::to_string(&state).unwrap();
304 assert!(
305 json.contains("preflight_retries"),
306 "preflight_retries must appear in persisted JSON"
307 );
308 let loaded: State = serde_json::from_str(&json).unwrap();
309 assert_eq!(
310 loaded.preflight_retries, 2,
311 "preflight_retries must round-trip through serde"
312 );
313
314 let absent_json = r#"{
315 "stage": "code",
316 "phase": 1,
317 "agent": "claude",
318 "mode": "auto",
319 "started_at": "0",
320 "project_root": "/repo"
321 }"#;
322 let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
323 assert_eq!(loaded_absent.preflight_retries, 0);
324 }
325
326 #[test]
328 fn monitor_pid_round_trips_through_serde() {
329 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
330 state.monitor_pid = Some(4242);
331 let json = serde_json::to_string(&state).unwrap();
332 assert!(
333 json.contains("monitor_pid"),
334 "monitor_pid must appear in persisted JSON"
335 );
336 let loaded: State = serde_json::from_str(&json).unwrap();
337 assert_eq!(
338 loaded.monitor_pid,
339 Some(4242),
340 "monitor_pid must round-trip through serde"
341 );
342 }
343
344 #[test]
348 fn monitor_pid_absent_from_json_defaults_to_none() {
349 let json = r#"{
350 "stage": "code",
351 "phase": 1,
352 "agent": "claude",
353 "mode": "auto",
354 "started_at": "0",
355 "project_root": "/repo"
356 }"#;
357 let loaded: State = serde_json::from_str(json).unwrap();
358 assert_eq!(loaded.monitor_pid, None);
359 }
360
361 #[test]
365 fn yes_ship_round_trips_through_serde() {
366 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
367 state.yes_ship = true;
368 let json = serde_json::to_string(&state).unwrap();
369 assert!(
370 json.contains("yes_ship"),
371 "yes_ship must appear in persisted JSON"
372 );
373 let loaded: State = serde_json::from_str(&json).unwrap();
374 assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
375 }
376
377 #[test]
382 fn yes_ship_absent_from_json_defaults_to_false() {
383 let json = r#"{
384 "stage": "code",
385 "phase": 1,
386 "agent": "claude",
387 "mode": "auto",
388 "started_at": "0",
389 "project_root": "/repo"
390 }"#;
391 let loaded: State = serde_json::from_str(json).unwrap();
392 assert!(!loaded.yes_ship);
393 }
394
395 #[test]
399 fn stop_fields_round_trip_through_serde() {
400 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
401 state.stop_until = Some(Stage::Plan);
402 state.stopped = true;
403 state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
404 let json = serde_json::to_string(&state).unwrap();
405 assert!(
406 json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
407 "all three stop fields must appear in persisted JSON: {json}"
408 );
409 let loaded: State = serde_json::from_str(&json).unwrap();
410 assert_eq!(
411 loaded.stop_until,
412 Some(Stage::Plan),
413 "stop_until must round-trip through serde"
414 );
415 assert!(loaded.stopped, "stopped must round-trip through serde");
416 assert_eq!(
417 loaded.stop_reason.as_deref(),
418 Some("stopped after plan completed (--until plan)"),
419 "stop_reason must round-trip through serde"
420 );
421 }
422
423 #[test]
428 fn stop_fields_absent_from_json_default() {
429 let json = r#"{
430 "stage": "code",
431 "phase": 1,
432 "agent": "claude",
433 "mode": "auto",
434 "started_at": "0",
435 "project_root": "/repo"
436 }"#;
437 let loaded: State = serde_json::from_str(json).unwrap();
438 assert_eq!(loaded.stop_until, None);
439 assert!(!loaded.stopped);
440 assert_eq!(loaded.stop_reason, None);
441 }
442}