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