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}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum AgentKind {
79 Claude,
81 Codex,
83 OpenCode,
85}
86
87impl fmt::Display for AgentKind {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 let name = match self {
90 AgentKind::Claude => "claude",
91 AgentKind::Codex => "codex",
92 AgentKind::OpenCode => "opencode",
93 };
94 f.write_str(name)
95 }
96}
97
98impl FromStr for AgentKind {
99 type Err = AgentParseError;
100
101 fn from_str(value: &str) -> Result<Self, Self::Err> {
102 match value.to_ascii_lowercase().as_str() {
103 "claude" => Ok(AgentKind::Claude),
104 "codex" => Ok(AgentKind::Codex),
105 "opencode" | "open-code" => Ok(AgentKind::OpenCode),
106 other => Err(AgentParseError(other.to_string())),
107 }
108 }
109}
110
111#[derive(Debug, Clone, thiserror::Error)]
113#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
114pub struct AgentParseError(String);
115
116impl State {
117 pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
119 State {
120 stage: Stage::Define,
121 phase,
122 agent,
123 mode,
124 gate_pending: false,
125 consecutive_failures: 0,
126 infra_failures: 0,
127 preflight_retries: 0,
128 started_at: timestamp_now(),
129 project_root,
130 worktree_path: None,
131 monitor_pid: None,
132 }
133 }
134}
135
136fn timestamp_now() -> String {
137 match SystemTime::now().duration_since(UNIX_EPOCH) {
138 Ok(duration) => format!("{}", duration.as_secs()),
139 Err(_) => String::from("0"),
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::path::PathBuf;
147
148 #[test]
149 fn agent_name_and_display() {
150 use crate::agents::adapter_for;
151 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
152 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
153 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
154
155 assert_eq!(AgentKind::Claude.to_string(), "claude");
156 assert_eq!(AgentKind::Codex.to_string(), "codex");
157 assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
158 }
159
160 #[test]
161 fn agent_from_str_accepts_canonical_and_aliases() {
162 assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
163 assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
164 assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
165 assert_eq!(
166 "opencode".parse::<AgentKind>().unwrap(),
167 AgentKind::OpenCode
168 );
169 assert_eq!(
170 "open-code".parse::<AgentKind>().unwrap(),
171 AgentKind::OpenCode
172 );
173 }
174
175 #[test]
176 fn agent_from_str_rejects_unknown() {
177 let err = "aider".parse::<AgentKind>().unwrap_err();
178 assert!(err.to_string().contains("aider"));
179 }
180
181 #[test]
182 fn new_state_starts_at_define() {
183 let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
184 assert_eq!(state.stage, Stage::Define);
185 assert_eq!(state.phase, 2);
186 assert_eq!(state.agent, AgentKind::Claude);
187 assert_eq!(state.mode, Mode::Auto);
188 assert!(!state.gate_pending);
189 assert_eq!(state.consecutive_failures, 0);
190 assert_eq!(state.infra_failures, 0);
191 assert_eq!(state.preflight_retries, 0);
192 assert!(!state.started_at.is_empty());
193 assert_eq!(state.monitor_pid, None);
194 }
195
196 #[test]
197 fn state_serde_round_trips() {
198 let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
199 let json = serde_json::to_string(&state).unwrap();
200 let back: State = serde_json::from_str(&json).unwrap();
201 assert_eq!(back.phase, 9);
202 assert_eq!(back.agent, AgentKind::Codex);
203 assert_eq!(back.stage, Stage::Define);
204 assert_eq!(back.mode, Mode::Supervise);
205 }
206
207 #[test]
208 fn consecutive_failures_persists_across_advance_calls() {
209 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
210 state.consecutive_failures = 3;
211 let json = serde_json::to_string(&state).unwrap();
212 assert!(
213 json.contains("consecutive_failures"),
214 "consecutive_failures must appear in persisted JSON"
215 );
216 let loaded: State = serde_json::from_str(&json).unwrap();
217 assert_eq!(
218 loaded.consecutive_failures, 3,
219 "consecutive_failures must round-trip through serde"
220 );
221 }
222
223 #[test]
226 fn infra_failures_round_trips_through_serde() {
227 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
228 state.infra_failures = 4;
229 let json = serde_json::to_string(&state).unwrap();
230 assert!(
231 json.contains("infra_failures"),
232 "infra_failures must appear in persisted JSON"
233 );
234 let loaded: State = serde_json::from_str(&json).unwrap();
235 assert_eq!(
236 loaded.infra_failures, 4,
237 "infra_failures must round-trip through serde"
238 );
239 }
240
241 #[test]
244 fn infra_failures_absent_from_json_defaults_to_zero() {
245 let json = r#"{
246 "stage": "code",
247 "phase": 1,
248 "agent": "claude",
249 "mode": "auto",
250 "started_at": "0",
251 "project_root": "/repo"
252 }"#;
253 let loaded: State = serde_json::from_str(json).unwrap();
254 assert_eq!(loaded.infra_failures, 0);
255 }
256
257 #[test]
263 fn preflight_retries_round_trips_through_serde() {
264 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
265 state.preflight_retries = 2;
266 let json = serde_json::to_string(&state).unwrap();
267 assert!(
268 json.contains("preflight_retries"),
269 "preflight_retries must appear in persisted JSON"
270 );
271 let loaded: State = serde_json::from_str(&json).unwrap();
272 assert_eq!(
273 loaded.preflight_retries, 2,
274 "preflight_retries must round-trip through serde"
275 );
276
277 let absent_json = r#"{
278 "stage": "code",
279 "phase": 1,
280 "agent": "claude",
281 "mode": "auto",
282 "started_at": "0",
283 "project_root": "/repo"
284 }"#;
285 let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
286 assert_eq!(loaded_absent.preflight_retries, 0);
287 }
288
289 #[test]
291 fn monitor_pid_round_trips_through_serde() {
292 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
293 state.monitor_pid = Some(4242);
294 let json = serde_json::to_string(&state).unwrap();
295 assert!(
296 json.contains("monitor_pid"),
297 "monitor_pid must appear in persisted JSON"
298 );
299 let loaded: State = serde_json::from_str(&json).unwrap();
300 assert_eq!(
301 loaded.monitor_pid,
302 Some(4242),
303 "monitor_pid must round-trip through serde"
304 );
305 }
306
307 #[test]
311 fn monitor_pid_absent_from_json_defaults_to_none() {
312 let json = r#"{
313 "stage": "code",
314 "phase": 1,
315 "agent": "claude",
316 "mode": "auto",
317 "started_at": "0",
318 "project_root": "/repo"
319 }"#;
320 let loaded: State = serde_json::from_str(json).unwrap();
321 assert_eq!(loaded.monitor_pid, None);
322 }
323}