Skip to main content

devflow_core/
workflow.rs

1//! Workflow state persistence helpers.
2//!
3//! State is per-phase: each active phase persists to
4//! `.devflow/state-{phase:02}.json` (mirroring the per-phase lock naming), so
5//! `devflow parallel` sibling phases never clobber one another
6//! (13-DEFERRED-CR-03). A legacy single-slot `.devflow/state.json` from an
7//! older binary is migrated to its per-phase name on first read.
8
9use crate::state::State;
10use std::path::{Path, PathBuf};
11use tracing::{debug, warn};
12
13/// Errors produced while reading or writing workflow state.
14#[derive(Debug, thiserror::Error)]
15pub enum WorkflowError {
16    /// Filesystem operation failed.
17    #[error("state I/O failed: {0}")]
18    Io(#[from] std::io::Error),
19    /// JSON parse or serialization failed.
20    #[error("state JSON failed: {0}")]
21    Json(#[from] serde_json::Error),
22    /// No active state file exists.
23    #[error("no active DevFlow state at {0}")]
24    MissingState(PathBuf),
25}
26
27/// Filename prefix shared by every per-phase state file. Owned here so
28/// listing/migration never hardcode the naming scheme.
29const STATE_FILE_PREFIX: &str = "state-";
30const CORRUPT_LEGACY_STATE_HINT: &str = "devflow recover --clean";
31
32/// Return the `.devflow` directory for a project.
33pub fn devflow_dir(project_root: &Path) -> PathBuf {
34    project_root.join(".devflow")
35}
36
37/// Return the persisted state path for a phase of a project.
38pub fn state_path(project_root: &Path, phase: u32) -> PathBuf {
39    devflow_dir(project_root).join(format!("{STATE_FILE_PREFIX}{phase:02}.json"))
40}
41
42/// Path of the legacy single-slot state file written by pre-14a binaries.
43pub(crate) fn legacy_state_path(project_root: &Path) -> PathBuf {
44    devflow_dir(project_root).join("state.json")
45}
46
47/// One-shot migration: move a legacy `.devflow/state.json` to its per-phase
48/// name. Called before any read so an upgrade mid-run keeps its state. A
49/// per-phase file that already exists wins — the legacy file is then stale
50/// and removed without overwriting.
51fn migrate_legacy_state(project_root: &Path) {
52    let legacy = legacy_state_path(project_root);
53    let Ok(contents) = std::fs::read_to_string(&legacy) else {
54        return;
55    };
56    let Ok(state) = serde_json::from_str::<State>(&contents) else {
57        warn!(
58            "legacy state at {} is unparsable — leaving it in place; remove it with `{CORRUPT_LEGACY_STATE_HINT}`",
59            legacy.display()
60        );
61        return;
62    };
63    let target = state_path(project_root, state.phase);
64    if target.exists() {
65        debug!(
66            "per-phase state already exists for phase {} — dropping stale legacy file",
67            state.phase
68        );
69    } else if let Err(err) = std::fs::rename(&legacy, &target) {
70        warn!(
71            "could not migrate legacy state to {}: {err}",
72            target.display()
73        );
74        return;
75    } else {
76        debug!("migrated legacy state.json to {}", target.display());
77        return;
78    }
79    let _ = std::fs::remove_file(&legacy);
80}
81
82/// Save workflow state to `.devflow/state-{NN}.json`, keyed by `state.phase`.
83pub fn save_state(state: &State) -> Result<(), WorkflowError> {
84    debug!("saving state: phase={} stage={}", state.phase, state.stage);
85    let path = state_path(&state.project_root, state.phase);
86    let contents = serde_json::to_string_pretty(state)?;
87    write_state_atomic(&path, &contents)?;
88    Ok(())
89}
90
91/// Write state through a sibling temporary file so readers never observe a
92/// truncated or partially written state file.
93fn write_state_atomic(path: &Path, contents: &str) -> Result<(), WorkflowError> {
94    if let Some(parent) = path.parent() {
95        std::fs::create_dir_all(parent)?;
96    }
97    let tmp = path.with_extension("tmp");
98    std::fs::write(&tmp, contents)?;
99    std::fs::rename(&tmp, path)?;
100    Ok(())
101}
102
103/// Load workflow state for a phase from `.devflow/state-{NN}.json`.
104pub fn load_state(project_root: &Path, phase: u32) -> Result<State, WorkflowError> {
105    migrate_legacy_state(project_root);
106    let path = state_path(project_root, phase);
107    debug!("loading state from {}", path.display());
108    if !path.exists() {
109        return Err(WorkflowError::MissingState(path));
110    }
111    let contents = std::fs::read_to_string(&path)?;
112    Ok(serde_json::from_str(&contents)?)
113}
114
115/// Enumerate every active phase state, sorted by phase number.
116///
117/// Unparsable state files are skipped with a warning rather than failing the
118/// whole listing — `status`/`recover` must degrade, not die, on one corrupt
119/// file.
120pub fn list_states(project_root: &Path) -> Vec<State> {
121    migrate_legacy_state(project_root);
122    let mut states = Vec::new();
123    let Ok(entries) = std::fs::read_dir(devflow_dir(project_root)) else {
124        return states;
125    };
126    for entry in entries.flatten() {
127        let name = entry.file_name();
128        let Some(name) = name.to_str() else { continue };
129        if !name.starts_with(STATE_FILE_PREFIX) || !name.ends_with(".json") {
130            continue;
131        }
132        match std::fs::read_to_string(entry.path())
133            .map_err(WorkflowError::from)
134            .and_then(|c| Ok(serde_json::from_str::<State>(&c)?))
135        {
136            Ok(state) => states.push(state),
137            Err(err) => warn!("skipping unreadable state file {name}: {err}"),
138        }
139    }
140    states.sort_by_key(|s| s.phase);
141    states
142}
143
144/// Delete a legacy `state.json` that cannot be parsed — and therefore can
145/// never be migrated by [`migrate_legacy_state`] or matched by
146/// [`clear_state`]'s phase check (14-CR-04). Ordinary reads deliberately
147/// leave such a file in place; only the operator-driven reset
148/// (`recover --clean`) is sanctioned to call this. Returns whether a file
149/// was removed.
150pub fn remove_corrupt_legacy_state(project_root: &Path) -> Result<bool, WorkflowError> {
151    let legacy = legacy_state_path(project_root);
152    let Ok(contents) = std::fs::read_to_string(&legacy) else {
153        return Ok(false);
154    };
155    if serde_json::from_str::<State>(&contents).is_ok() {
156        // Parsable: the normal migration path owns it.
157        return Ok(false);
158    }
159    std::fs::remove_file(&legacy)?;
160    warn!("removed unparsable legacy state at {}", legacy.display());
161    Ok(true)
162}
163
164/// Remove a phase's persisted state if present.
165pub fn clear_state(project_root: &Path, phase: u32) -> Result<(), WorkflowError> {
166    let path = state_path(project_root, phase);
167    if path.exists() {
168        debug!("clearing state at {}", path.display());
169        std::fs::remove_file(path)?;
170    }
171    // A legacy single-slot file for this phase is the same state under its
172    // old name — clearing must not leave it behind to be re-migrated.
173    let legacy = legacy_state_path(project_root);
174    if let Ok(contents) = std::fs::read_to_string(&legacy)
175        && let Ok(state) = serde_json::from_str::<State>(&contents)
176        && state.phase == phase
177    {
178        std::fs::remove_file(&legacy)?;
179    }
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn migrate_legacy_state_warning_names_recovery_command() {
189        assert!(CORRUPT_LEGACY_STATE_HINT.contains("recover --clean"));
190    }
191    use crate::mode::Mode;
192    use crate::stage::Stage;
193    use crate::state::AgentKind;
194
195    fn state_in(root: &Path, phase: u32, stage: Stage) -> State {
196        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
197        state.stage = stage;
198        state
199    }
200
201    #[test]
202    fn paths_are_per_phase_under_devflow_dir() {
203        let root = Path::new("/repo");
204        assert_eq!(devflow_dir(root), Path::new("/repo/.devflow"));
205        assert_eq!(
206            state_path(root, 7),
207            Path::new("/repo/.devflow/state-07.json")
208        );
209        assert_eq!(
210            state_path(root, 14),
211            Path::new("/repo/.devflow/state-14.json")
212        );
213    }
214
215    #[test]
216    fn save_then_load_round_trips() {
217        let dir = tempfile::tempdir().unwrap();
218        let state = state_in(dir.path(), 1, Stage::Code);
219        save_state(&state).expect("save");
220
221        let loaded = load_state(dir.path(), 1).expect("load");
222        assert_eq!(loaded.stage, Stage::Code);
223        assert_eq!(loaded.phase, 1);
224        assert_eq!(loaded.agent, AgentKind::Claude);
225        assert_eq!(loaded.mode, Mode::Auto);
226    }
227
228    /// 13-DEFERRED-CR-03 scenario 1: two phases' states must coexist — the
229    /// second `start` no longer clobbers the first phase's state.
230    #[test]
231    fn two_phases_states_coexist_without_clobbering() {
232        let dir = tempfile::tempdir().unwrap();
233        save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
234        save_state(&state_in(dir.path(), 14, Stage::Validate)).unwrap();
235
236        let a = load_state(dir.path(), 13).expect("phase 13 state");
237        let b = load_state(dir.path(), 14).expect("phase 14 state");
238        assert_eq!(a.phase, 13);
239        assert_eq!(a.stage, Stage::Code);
240        assert_eq!(b.phase, 14);
241        assert_eq!(b.stage, Stage::Validate);
242    }
243
244    #[test]
245    fn save_state_writes_atomically_and_leaves_no_temp() {
246        let dir = tempfile::tempdir().unwrap();
247        let state = state_in(dir.path(), 1, Stage::Validate);
248
249        save_state(&state).expect("save");
250
251        let path = state_path(dir.path(), 1);
252        assert!(path.exists());
253        let loaded = load_state(dir.path(), 1).expect("load");
254        assert_eq!(loaded.stage, Stage::Validate);
255        assert_eq!(loaded.phase, state.phase);
256        assert!(!path.with_extension("tmp").exists());
257    }
258
259    #[test]
260    fn load_missing_state_errors() {
261        let dir = tempfile::tempdir().unwrap();
262        let err = load_state(dir.path(), 1).unwrap_err();
263        assert!(matches!(err, WorkflowError::MissingState(_)));
264    }
265
266    #[test]
267    fn clear_removes_state_and_is_idempotent() {
268        let dir = tempfile::tempdir().unwrap();
269        let state = state_in(dir.path(), 1, Stage::Validate);
270        save_state(&state).unwrap();
271        assert!(state_path(dir.path(), 1).exists());
272
273        clear_state(dir.path(), 1).expect("clear");
274        assert!(!state_path(dir.path(), 1).exists());
275        // Clearing when nothing is present is a no-op success.
276        clear_state(dir.path(), 1).expect("clear again");
277    }
278
279    #[test]
280    fn clear_only_touches_its_own_phase() {
281        let dir = tempfile::tempdir().unwrap();
282        save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
283        save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
284
285        clear_state(dir.path(), 13).unwrap();
286
287        assert!(!state_path(dir.path(), 13).exists());
288        assert!(load_state(dir.path(), 14).is_ok(), "phase 14 must survive");
289    }
290
291    #[test]
292    fn list_states_enumerates_sorted_by_phase() {
293        let dir = tempfile::tempdir().unwrap();
294        save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
295        save_state(&state_in(dir.path(), 3, Stage::Code)).unwrap();
296
297        let states = list_states(dir.path());
298        assert_eq!(
299            states.iter().map(|s| s.phase).collect::<Vec<_>>(),
300            vec![3, 14]
301        );
302    }
303
304    #[test]
305    fn list_states_empty_when_no_devflow_dir() {
306        let dir = tempfile::tempdir().unwrap();
307        assert!(list_states(dir.path()).is_empty());
308    }
309
310    #[test]
311    fn list_states_skips_corrupt_files() {
312        let dir = tempfile::tempdir().unwrap();
313        save_state(&state_in(dir.path(), 5, Stage::Code)).unwrap();
314        std::fs::write(state_path(dir.path(), 6), "not json").unwrap();
315
316        let states = list_states(dir.path());
317        assert_eq!(states.len(), 1);
318        assert_eq!(states[0].phase, 5);
319    }
320
321    /// Upgrade path: a legacy single-slot `state.json` written by an older
322    /// binary must be readable after upgrading — migrated to its per-phase
323    /// name on first load/list, with the legacy file removed.
324    #[test]
325    fn legacy_state_json_migrates_on_load() {
326        let dir = tempfile::tempdir().unwrap();
327        let state = state_in(dir.path(), 9, Stage::Validate);
328        let legacy = legacy_state_path(dir.path());
329        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
330        std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
331
332        let loaded = load_state(dir.path(), 9).expect("legacy state must migrate");
333        assert_eq!(loaded.phase, 9);
334        assert_eq!(loaded.stage, Stage::Validate);
335        assert!(!legacy.exists(), "legacy file must be gone after migration");
336        assert!(state_path(dir.path(), 9).exists());
337    }
338
339    #[test]
340    fn legacy_state_json_migrates_on_list() {
341        let dir = tempfile::tempdir().unwrap();
342        let state = state_in(dir.path(), 4, Stage::Code);
343        let legacy = legacy_state_path(dir.path());
344        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
345        std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
346
347        let states = list_states(dir.path());
348        assert_eq!(states.len(), 1);
349        assert_eq!(states[0].phase, 4);
350        assert!(!legacy.exists());
351    }
352
353    #[test]
354    fn legacy_migration_never_overwrites_existing_per_phase_state() {
355        let dir = tempfile::tempdir().unwrap();
356        // Newer per-phase state at Ship...
357        save_state(&state_in(dir.path(), 9, Stage::Ship)).unwrap();
358        // ...and a stale legacy file for the same phase still at Code.
359        let legacy = legacy_state_path(dir.path());
360        std::fs::write(
361            &legacy,
362            serde_json::to_string_pretty(&state_in(dir.path(), 9, Stage::Code)).unwrap(),
363        )
364        .unwrap();
365
366        let loaded = load_state(dir.path(), 9).unwrap();
367        assert_eq!(loaded.stage, Stage::Ship, "per-phase state must win");
368        assert!(!legacy.exists(), "stale legacy file must be dropped");
369    }
370}