1use crate::state::State;
8use crate::workflow::{self, WorkflowError};
9use std::path::Path;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12pub const STALE_THRESHOLD: Duration = Duration::from_secs(24 * 60 * 60);
14
15#[derive(Debug, thiserror::Error)]
17pub enum RecoverError {
18 #[error("no state to recover — project is idle")]
20 NothingToRecover,
21 #[error("{0}")]
23 Io(#[from] std::io::Error),
24 #[error("{0}")]
26 Workflow(#[from] WorkflowError),
27}
28
29#[derive(Debug)]
31pub struct RecoveryStatus {
32 pub state: State,
34 pub agent_running: bool,
36 pub is_stale: bool,
38 pub age: String,
40 pub lock_held: Option<String>,
42}
43
44pub fn inspect_all(project_root: &Path) -> Result<Vec<RecoveryStatus>, RecoverError> {
48 let states = workflow::list_states(project_root);
49 if states.is_empty() {
50 return Err(RecoverError::NothingToRecover);
51 }
52 Ok(states
53 .into_iter()
54 .map(|state| inspect_state(project_root, state))
55 .collect())
56}
57
58fn inspect_state(project_root: &Path, state: State) -> RecoveryStatus {
59 let agent_running = agent_pid_for(&state).is_some_and(crate::agent::agent_running);
60 let is_stale = is_stale_state(&state);
61 let age = format_age(state.started_at.as_str());
62 let lock_held = crate::lock::holder(project_root, state.phase).map(|(pid, _)| pid);
63
64 RecoveryStatus {
65 state,
66 agent_running,
67 is_stale,
68 age,
69 lock_held,
70 }
71}
72
73pub fn clean(project_root: &Path) -> Result<Vec<String>, RecoverError> {
87 let mut warnings = Vec::new();
88 for state in workflow::list_states(project_root) {
89 let phase = state.phase;
90 if agent_pid_for(&state).is_some_and(crate::agent::agent_running) {
91 warnings.push(format!(
92 "kept phase {phase} — its agent is still running (clear explicitly with --phase {phase})"
93 ));
94 continue;
95 }
96 if !is_stale_state(&state) {
97 warnings.push(format!(
98 "kept phase {phase} — state is not stale yet (clear explicitly with --phase {phase})"
99 ));
100 continue;
101 }
102 workflow::clear_state(project_root, phase)?;
103 }
104 match workflow::remove_corrupt_legacy_state(project_root) {
105 Ok(true) => warnings.push("removed unparsable legacy state.json".into()),
106 Ok(false) => {}
107 Err(err) => warnings.push(format!("could not remove corrupt legacy state.json: {err}")),
108 }
109 warnings.append(&mut crate::lock::remove_stale_locks(project_root));
110 for instructions in crate::ship::list_cron_instructions(project_root) {
113 if workflow::state_path(project_root, instructions.phase).exists() {
114 continue;
115 }
116 if let Err(err) = crate::ship::delete_cron_instructions(project_root, instructions.phase) {
117 warnings.push(format!(
118 "could not remove cron-instructions for phase {}: {err}",
119 instructions.phase
120 ));
121 }
122 }
123 Ok(warnings)
124}
125
126pub fn clean_phase(project_root: &Path, phase: u32) -> Result<Vec<String>, RecoverError> {
130 let mut warnings = Vec::new();
131 if let Ok(state) = workflow::load_state(project_root, phase)
132 && agent_pid_for(&state).is_some_and(crate::agent::agent_running)
133 {
134 warnings.push(format!(
135 "phase {phase}'s agent appears to still be running — cleared anyway (explicit --phase)"
136 ));
137 }
138 workflow::clear_state(project_root, phase)?;
139 if let Err(err) = crate::ship::delete_cron_instructions(project_root, phase) {
140 warnings.push(format!("could not remove cron-instructions: {err}"));
141 }
142 warnings.append(&mut crate::lock::remove_stale_locks(project_root));
143 Ok(warnings)
144}
145
146pub fn is_stale_state(state: &State) -> bool {
148 let age_secs = match state_age_secs(&state.started_at) {
149 Some(a) => a,
150 None => return false,
151 };
152
153 if age_secs < STALE_THRESHOLD.as_secs() {
154 return false;
155 }
156
157 if let Some(pid) = agent_pid_for(state)
159 && crate::agent::agent_running(pid)
160 {
161 return false;
162 }
163
164 true
165}
166
167fn agent_pid_for(state: &State) -> Option<u32> {
170 let path = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
171 std::fs::read_to_string(path).ok()?.trim().parse().ok()
172}
173
174fn state_age_secs(started_at: &str) -> Option<u64> {
176 let started: u64 = started_at.parse().ok()?;
177 let now = SystemTime::now()
178 .duration_since(UNIX_EPOCH)
179 .unwrap_or_default()
180 .as_secs();
181 now.checked_sub(started)
182}
183
184pub fn format_age(started_at: &str) -> String {
188 match state_age_secs(started_at) {
189 Some(s) if s < 60 => format!("{s}s ago"),
190 Some(s) if s < 3600 => format!("{}m ago", s / 60),
191 Some(s) if s < 86400 => format!("{}h ago", s / 3600),
192 Some(s) => format!("{}d ago", s / 86400),
193 None => "unknown".into(),
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::mode::Mode;
201 use crate::state::{AgentKind, State};
202
203 fn state_aged(root: &Path, age_secs: u64, agent_pid: Option<u32>) -> State {
206 state_aged_phase(root, 1, age_secs, agent_pid)
207 }
208
209 fn state_aged_phase(root: &Path, phase: u32, age_secs: u64, agent_pid: Option<u32>) -> State {
210 let now = SystemTime::now()
211 .duration_since(UNIX_EPOCH)
212 .unwrap_or_default()
213 .as_secs();
214 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
215 state.started_at = now.saturating_sub(age_secs).to_string();
216 if let Some(pid) = agent_pid {
217 let path = crate::agent_result::agent_pid_path(root, state.phase);
218 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
219 std::fs::write(path, pid.to_string()).unwrap();
220 }
221 state
222 }
223
224 const DEAD_PID: u32 = 0x7FFF_FFFE;
226
227 #[test]
228 fn fresh_state_is_not_stale() {
229 let dir = tempfile::tempdir().unwrap();
231 let state = state_aged(dir.path(), 3600, None);
232 assert!(!is_stale_state(&state));
233 }
234
235 #[test]
236 fn old_state_with_no_agent_is_stale() {
237 let dir = tempfile::tempdir().unwrap();
238 let state = state_aged(dir.path(), STALE_THRESHOLD.as_secs() + 60, None);
239 assert!(is_stale_state(&state));
240 }
241
242 #[test]
243 fn old_state_with_dead_agent_is_stale() {
244 let dir = tempfile::tempdir().unwrap();
245 let state = state_aged(dir.path(), STALE_THRESHOLD.as_secs() + 60, Some(DEAD_PID));
246 assert!(is_stale_state(&state));
247 }
248
249 #[test]
250 fn old_state_with_live_agent_is_not_stale() {
251 let dir = tempfile::tempdir().unwrap();
253 let own_pid = std::process::id();
254 let state = state_aged(dir.path(), STALE_THRESHOLD.as_secs() + 60, Some(own_pid));
255 assert!(!is_stale_state(&state));
256 }
257
258 #[test]
259 fn unparseable_timestamp_is_never_stale() {
260 let dir = tempfile::tempdir().unwrap();
261 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, dir.path().to_path_buf());
262 state.started_at = "not-a-number".into();
263 assert!(!is_stale_state(&state));
264 assert_eq!(state_age_secs(&state.started_at), None);
265 }
266
267 #[test]
268 fn state_age_secs_parses_epoch() {
269 let now = SystemTime::now()
270 .duration_since(UNIX_EPOCH)
271 .unwrap_or_default()
272 .as_secs();
273 let started = (now - 120).to_string();
274 let age = state_age_secs(&started).expect("age");
275 assert!((118..=125).contains(&age), "unexpected age: {age}");
277 }
278
279 #[test]
280 fn format_age_buckets_by_magnitude() {
281 let now = SystemTime::now()
282 .duration_since(UNIX_EPOCH)
283 .unwrap_or_default()
284 .as_secs();
285 let ago = |secs: u64| format_age(&(now - secs).to_string());
286 assert!(ago(30).ends_with("s ago"));
287 assert!(ago(120).ends_with("m ago"));
288 assert!(ago(7200).ends_with("h ago"));
289 assert!(ago(2 * 86400).ends_with("d ago"));
290 assert_eq!(format_age("garbage"), "unknown");
291 }
292
293 #[test]
294 fn inspect_all_missing_state_reports_nothing_to_recover() {
295 let dir = std::env::temp_dir().join(format!("devflow-recover-{}", std::process::id()));
296 let _ = std::fs::remove_dir_all(&dir);
297 std::fs::create_dir_all(&dir).expect("create temp dir");
298 let err = inspect_all(&dir).expect_err("should have no state");
299 assert!(matches!(err, RecoverError::NothingToRecover));
300 let _ = std::fs::remove_dir_all(&dir);
301 }
302
303 #[test]
306 fn inspect_all_enumerates_every_active_phase() {
307 let dir = tempfile::tempdir().unwrap();
308 workflow::save_state(&state_aged(dir.path(), 60, None)).unwrap();
309 let mut other = state_aged(dir.path(), 60, None);
310 other.phase = 2;
311 workflow::save_state(&other).unwrap();
312
313 let statuses = inspect_all(dir.path()).expect("two phases active");
314 assert_eq!(
315 statuses.iter().map(|s| s.state.phase).collect::<Vec<_>>(),
316 vec![1, 2]
317 );
318 }
319
320 #[test]
324 fn clean_keeps_phase_with_live_agent() {
325 let dir = tempfile::tempdir().unwrap();
326 let live = state_aged_phase(
328 dir.path(),
329 1,
330 STALE_THRESHOLD.as_secs() + 60,
331 Some(std::process::id()),
332 );
333 workflow::save_state(&live).unwrap();
334 let stale = state_aged_phase(
336 dir.path(),
337 2,
338 STALE_THRESHOLD.as_secs() + 60,
339 Some(DEAD_PID),
340 );
341 workflow::save_state(&stale).unwrap();
342
343 let warnings = clean(dir.path()).expect("clean");
344
345 let remaining: Vec<u32> = workflow::list_states(dir.path())
346 .iter()
347 .map(|s| s.phase)
348 .collect();
349 assert_eq!(remaining, vec![1], "live phase must survive, stale cleared");
350 assert!(
351 warnings.iter().any(|w| w.contains("phase 1")),
352 "keeping a live phase must be reported: {warnings:?}"
353 );
354 }
355
356 #[test]
359 fn clean_keeps_fresh_phase() {
360 let dir = tempfile::tempdir().unwrap();
361 workflow::save_state(&state_aged_phase(dir.path(), 3, 60, None)).unwrap();
362
363 let warnings = clean(dir.path()).expect("clean");
364
365 assert_eq!(workflow::list_states(dir.path()).len(), 1);
366 assert!(warnings.iter().any(|w| w.contains("--phase 3")));
367 }
368
369 #[test]
370 fn clean_clears_stale_phase_state() {
371 let dir = tempfile::tempdir().unwrap();
372 workflow::save_state(&state_aged_phase(
373 dir.path(),
374 2,
375 STALE_THRESHOLD.as_secs() + 60,
376 Some(DEAD_PID),
377 ))
378 .unwrap();
379
380 clean(dir.path()).expect("clean");
381
382 assert!(workflow::list_states(dir.path()).is_empty());
383 }
384
385 #[test]
389 fn clean_removes_corrupt_legacy_state_json() {
390 let dir = tempfile::tempdir().unwrap();
391 let legacy = dir.path().join(".devflow/state.json");
392 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
393 std::fs::write(&legacy, "{\"stage\":").unwrap();
394
395 clean(dir.path()).expect("clean");
396
397 assert!(
398 !legacy.exists(),
399 "recover --clean must remove an unparsable legacy state.json"
400 );
401 }
402
403 #[test]
406 fn clean_phase_clears_only_the_named_phase() {
407 let dir = tempfile::tempdir().unwrap();
408 workflow::save_state(&state_aged_phase(dir.path(), 4, 60, None)).unwrap();
409 workflow::save_state(&state_aged_phase(dir.path(), 5, 60, None)).unwrap();
410
411 clean_phase(dir.path(), 4).expect("clean_phase");
412
413 let remaining: Vec<u32> = workflow::list_states(dir.path())
414 .iter()
415 .map(|s| s.phase)
416 .collect();
417 assert_eq!(remaining, vec![5]);
418 }
419}