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::io::Write;
11use std::path::{Path, PathBuf};
12use tracing::{debug, warn};
13
14/// Errors produced while reading or writing workflow state.
15#[derive(Debug, thiserror::Error)]
16pub enum WorkflowError {
17    /// Filesystem operation failed.
18    #[error("state I/O failed: {0}")]
19    Io(#[from] std::io::Error),
20    /// JSON parse or serialization failed.
21    #[error("state JSON failed: {0}")]
22    Json(#[from] serde_json::Error),
23    /// No active state file exists.
24    #[error("no active DevFlow state at {0}")]
25    MissingState(PathBuf),
26}
27
28/// Filename prefix shared by every per-phase state file. Owned here so
29/// listing/migration never hardcode the naming scheme.
30const STATE_FILE_PREFIX: &str = "state-";
31const CORRUPT_LEGACY_STATE_HINT: &str = "devflow recover --clean";
32
33/// Return the `.devflow` directory for a project.
34pub fn devflow_dir(project_root: &Path) -> PathBuf {
35    project_root.join(".devflow")
36}
37
38/// Locate the shallowest `.devflow` path component within `dir`, if any, and
39/// return the path up to and including it.
40///
41/// Walking `dir`'s path *components* — rather than string-matching each of
42/// `dir.ancestors()`'s paths — is what makes this resolve a **relative**
43/// `dir` whose `.devflow` is the leaf or near-leaf component (e.g.
44/// `.devflow/captures`, or the bare `.devflow`) correctly: the ancestor-tail
45/// approach hits an empty final ancestor `""` on those inputs and mishandles
46/// the leaf-is-`.devflow` case, whereas component-walking handles both
47/// cleanly. Shallowest-first (the first match while walking root-to-leaf) is
48/// the deterministic tie-break if `.devflow` appears twice in the path.
49fn find_devflow_marker(dir: &Path) -> Option<PathBuf> {
50    let mut acc = PathBuf::new();
51    for component in dir.components() {
52        acc.push(component.as_os_str());
53        if component.as_os_str() == std::ffi::OsStr::new(".devflow") {
54            return Some(acc);
55        }
56    }
57    None
58}
59
60/// Create `dir` (and any missing parents), then self-protect any `.devflow`
61/// directory found in its path by writing `<that-dir>/.gitignore` containing
62/// `*` — so a downstream user's routine `git add . && git commit` never
63/// sweeps DevFlow's runtime artifacts (agent stdout, gate context, state)
64/// into their repository, independent of whether their own root
65/// `.gitignore` mentions `.devflow` at all (closes 19a-WR-01,
66/// 19-CONTEXT.md D-14).
67///
68/// This is deliberately a different function from the pure path accessor
69/// [`devflow_dir`] above: `devflow_dir(project_root)` takes a **project
70/// root** and returns `project_root/.devflow` with zero filesystem I/O — it
71/// is invoked from read-only paths (`doctor`, `status`) and from tests that
72/// assert on the returned path, so giving *it* side effects would be exactly
73/// the class of behavioral change this phase exists to avoid. This function,
74/// `ensure_devflow_dir(dir)`, instead takes the **directory to create**,
75/// which may itself be `.devflow`, a subdirectory of it, or something with no
76/// `.devflow` ancestor at all. Do not confuse the two.
77///
78/// Contract:
79/// 1. `create_dir_all(dir)` — create `dir` and all missing parents.
80/// 2. Find the shallowest `.devflow` path component (see
81///    [`find_devflow_marker`]).
82/// 3. If found, write `<marker>/.gitignore` with the bytes `*\n`, using
83///    `create_new(true)` so an existing file — whatever its content — is left
84///    untouched; a lost race against a concurrent creator surfaces as
85///    `AlreadyExists`, which this function maps to `Ok(())`. Any other I/O
86///    error propagates via `?`.
87/// 4. If no `.devflow` component exists, this function is exactly equivalent
88///    to `create_dir_all`.
89///
90/// Returns `std::io::Result<()>`, not a crate-specific error enum: this
91/// plan's seven conversion sites each live in a different module with their
92/// own error enum (`WorkflowError`, `GateError`, `MonitorError`,
93/// `ResultError`, `ShipError`, `LockError`), and every one already carries an
94/// `Io(#[from] std::io::Error)` variant, so `?` converts at every call site
95/// with zero signature churn.
96///
97/// **Deleted-marker note:** if `.devflow/.gitignore` is deleted after
98/// creation, subsequent calls will not recreate it — the protection is
99/// established once per directory lifetime. Recreating a deleted marker
100/// would violate the rule that this function must never overwrite an
101/// existing `.gitignore` a user or another tool may own, and it cannot
102/// distinguish "user deleted it" from "never created."
103pub fn ensure_devflow_dir(dir: &Path) -> std::io::Result<()> {
104    std::fs::create_dir_all(dir)?;
105
106    let Some(marker_dir) = find_devflow_marker(dir) else {
107        return Ok(());
108    };
109
110    let gitignore = marker_dir.join(".gitignore");
111    match std::fs::OpenOptions::new()
112        .write(true)
113        .create_new(true)
114        .open(&gitignore)
115    {
116        Ok(mut f) => {
117            f.write_all(b"*\n")?;
118            Ok(())
119        }
120        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
121        Err(err) => Err(err),
122    }
123}
124
125/// Return the persisted state path for a phase of a project.
126pub fn state_path(project_root: &Path, phase: u32) -> PathBuf {
127    devflow_dir(project_root).join(format!("{STATE_FILE_PREFIX}{phase:02}.json"))
128}
129
130/// Path of the legacy single-slot state file written by pre-14a binaries.
131pub(crate) fn legacy_state_path(project_root: &Path) -> PathBuf {
132    devflow_dir(project_root).join("state.json")
133}
134
135/// One-shot migration: move a legacy `.devflow/state.json` to its per-phase
136/// name. Called before any read so an upgrade mid-run keeps its state. A
137/// per-phase file that already exists wins — the legacy file is then stale
138/// and removed without overwriting.
139fn migrate_legacy_state(project_root: &Path) {
140    let legacy = legacy_state_path(project_root);
141    let Ok(contents) = std::fs::read_to_string(&legacy) else {
142        return;
143    };
144    let Ok(state) = serde_json::from_str::<State>(&contents) else {
145        warn!(
146            "legacy state at {} is unparsable — leaving it in place; remove it with `{CORRUPT_LEGACY_STATE_HINT}`",
147            legacy.display()
148        );
149        return;
150    };
151    let target = state_path(project_root, state.phase);
152    if target.exists() {
153        debug!(
154            "per-phase state already exists for phase {} — dropping stale legacy file",
155            state.phase
156        );
157    } else if let Err(err) = std::fs::rename(&legacy, &target) {
158        warn!(
159            "could not migrate legacy state to {}: {err}",
160            target.display()
161        );
162        return;
163    } else {
164        debug!("migrated legacy state.json to {}", target.display());
165        return;
166    }
167    let _ = std::fs::remove_file(&legacy);
168}
169
170/// Save workflow state to `.devflow/state-{NN}.json`, keyed by `state.phase`.
171pub fn save_state(state: &State) -> Result<(), WorkflowError> {
172    debug!("saving state: phase={} stage={}", state.phase, state.stage);
173    let path = state_path(&state.project_root, state.phase);
174    let contents = serde_json::to_string_pretty(state)?;
175    write_state_atomic(&path, &contents)?;
176    Ok(())
177}
178
179/// Write state through a sibling temporary file so readers never observe a
180/// truncated or partially written state file.
181fn write_state_atomic(path: &Path, contents: &str) -> Result<(), WorkflowError> {
182    if let Some(parent) = path.parent() {
183        ensure_devflow_dir(parent)?;
184    }
185    let tmp = path.with_extension("tmp");
186    std::fs::write(&tmp, contents)?;
187    std::fs::rename(&tmp, path)?;
188    Ok(())
189}
190
191/// Load workflow state for a phase from `.devflow/state-{NN}.json`.
192pub fn load_state(project_root: &Path, phase: u32) -> Result<State, WorkflowError> {
193    migrate_legacy_state(project_root);
194    let path = state_path(project_root, phase);
195    debug!("loading state from {}", path.display());
196    if !path.exists() {
197        return Err(WorkflowError::MissingState(path));
198    }
199    let contents = std::fs::read_to_string(&path)?;
200    Ok(serde_json::from_str(&contents)?)
201}
202
203/// Enumerate every active phase state, sorted by phase number.
204///
205/// Unparsable state files are skipped with a warning rather than failing the
206/// whole listing — `status`/`recover` must degrade, not die, on one corrupt
207/// file.
208pub fn list_states(project_root: &Path) -> Vec<State> {
209    migrate_legacy_state(project_root);
210    let mut states = Vec::new();
211    let Ok(entries) = std::fs::read_dir(devflow_dir(project_root)) else {
212        return states;
213    };
214    for entry in entries.flatten() {
215        let name = entry.file_name();
216        let Some(name) = name.to_str() else { continue };
217        if !name.starts_with(STATE_FILE_PREFIX) || !name.ends_with(".json") {
218            continue;
219        }
220        match std::fs::read_to_string(entry.path())
221            .map_err(WorkflowError::from)
222            .and_then(|c| Ok(serde_json::from_str::<State>(&c)?))
223        {
224            Ok(state) => states.push(state),
225            Err(err) => warn!("skipping unreadable state file {name}: {err}"),
226        }
227    }
228    states.sort_by_key(|s| s.phase);
229    states
230}
231
232/// Delete a legacy `state.json` that cannot be parsed — and therefore can
233/// never be migrated by [`migrate_legacy_state`] or matched by
234/// [`clear_state`]'s phase check (14-CR-04). Ordinary reads deliberately
235/// leave such a file in place; only the operator-driven reset
236/// (`recover --clean`) is sanctioned to call this. Returns whether a file
237/// was removed.
238pub fn remove_corrupt_legacy_state(project_root: &Path) -> Result<bool, WorkflowError> {
239    let legacy = legacy_state_path(project_root);
240    let Ok(contents) = std::fs::read_to_string(&legacy) else {
241        return Ok(false);
242    };
243    if serde_json::from_str::<State>(&contents).is_ok() {
244        // Parsable: the normal migration path owns it.
245        return Ok(false);
246    }
247    std::fs::remove_file(&legacy)?;
248    warn!("removed unparsable legacy state at {}", legacy.display());
249    Ok(true)
250}
251
252/// Remove a phase's persisted state if present.
253pub fn clear_state(project_root: &Path, phase: u32) -> Result<(), WorkflowError> {
254    let path = state_path(project_root, phase);
255    if path.exists() {
256        debug!("clearing state at {}", path.display());
257        std::fs::remove_file(path)?;
258    }
259    // A legacy single-slot file for this phase is the same state under its
260    // old name — clearing must not leave it behind to be re-migrated.
261    let legacy = legacy_state_path(project_root);
262    if let Ok(contents) = std::fs::read_to_string(&legacy)
263        && let Ok(state) = serde_json::from_str::<State>(&contents)
264        && state.phase == phase
265    {
266        std::fs::remove_file(&legacy)?;
267    }
268    Ok(())
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn migrate_legacy_state_warning_names_recovery_command() {
277        assert!(CORRUPT_LEGACY_STATE_HINT.contains("recover --clean"));
278    }
279
280    #[test]
281    fn ensure_devflow_dir_writes_star_gitignore() {
282        let dir = tempfile::tempdir().unwrap();
283        let target = dir.path().join(".devflow");
284        ensure_devflow_dir(&target).expect("ensure_devflow_dir");
285        assert!(target.is_dir());
286        let contents = std::fs::read_to_string(target.join(".gitignore")).unwrap();
287        assert_eq!(contents.trim(), "*");
288    }
289
290    #[test]
291    fn ensure_devflow_dir_is_idempotent_and_preserves_existing_gitignore() {
292        let dir = tempfile::tempdir().unwrap();
293        let target = dir.path().join(".devflow");
294        ensure_devflow_dir(&target).expect("first call");
295        let first = std::fs::read(target.join(".gitignore")).unwrap();
296
297        ensure_devflow_dir(&target).expect("second call");
298        let second = std::fs::read(target.join(".gitignore")).unwrap();
299        assert_eq!(
300            first, second,
301            "second call must leave the file byte-identical"
302        );
303    }
304
305    #[test]
306    fn ensure_devflow_dir_preserves_foreign_gitignore_content() {
307        let dir = tempfile::tempdir().unwrap();
308        let target = dir.path().join(".devflow");
309        std::fs::create_dir_all(&target).unwrap();
310        std::fs::write(target.join(".gitignore"), "# owned by something else\n").unwrap();
311
312        ensure_devflow_dir(&target).expect("must not fail on a foreign .gitignore");
313
314        let contents = std::fs::read_to_string(target.join(".gitignore")).unwrap();
315        assert_eq!(contents, "# owned by something else\n");
316    }
317
318    #[test]
319    fn ensure_devflow_dir_on_nested_subpath_marks_the_devflow_ancestor() {
320        let dir = tempfile::tempdir().unwrap();
321        let target = dir.path().join(".devflow").join("history").join("phase-01");
322        ensure_devflow_dir(&target).expect("nested ensure_devflow_dir");
323
324        assert!(target.is_dir());
325        let marker = dir.path().join(".devflow").join(".gitignore");
326        assert!(
327            marker.is_file(),
328            "gitignore must land at the .devflow ancestor"
329        );
330        assert!(
331            !target.join(".gitignore").exists(),
332            "gitignore must not land at the leaf directory"
333        );
334    }
335
336    /// Antigravity review edge case: a relative path whose `.devflow` is the
337    /// leaf or near-leaf component. Exercises the component-detection logic
338    /// directly rather than a real filesystem call, so the assertion does not
339    /// depend on the test process's (global, shared-across-threads) cwd.
340    #[test]
341    fn ensure_devflow_dir_on_relative_devflow_leaf_path_marks_it() {
342        assert_eq!(
343            find_devflow_marker(Path::new(".devflow/captures")),
344            Some(PathBuf::from(".devflow")),
345            "leaf-adjacent relative path must mark .devflow, not captures/"
346        );
347        assert_eq!(
348            find_devflow_marker(Path::new(".devflow")),
349            Some(PathBuf::from(".devflow")),
350            "bare relative .devflow path must mark itself"
351        );
352    }
353
354    #[test]
355    fn ensure_devflow_dir_without_a_devflow_ancestor_only_creates_dirs() {
356        let dir = tempfile::tempdir().unwrap();
357        let target = dir.path().join("plain").join("sub");
358        ensure_devflow_dir(&target).expect("ensure_devflow_dir");
359
360        assert!(target.is_dir());
361        assert!(!dir.path().join(".gitignore").exists());
362        assert!(!dir.path().join("plain").join(".gitignore").exists());
363        assert!(!target.join(".gitignore").exists());
364    }
365
366    #[test]
367    fn ensure_devflow_dir_concurrent_calls_both_succeed() {
368        let dir = tempfile::tempdir().unwrap();
369        let target = dir.path().join(".devflow");
370
371        let t1 = {
372            let target = target.clone();
373            std::thread::spawn(move || ensure_devflow_dir(&target))
374        };
375        let t2 = {
376            let target = target.clone();
377            std::thread::spawn(move || ensure_devflow_dir(&target))
378        };
379        assert!(
380            t1.join().unwrap().is_ok(),
381            "first concurrent call must succeed"
382        );
383        assert!(
384            t2.join().unwrap().is_ok(),
385            "second concurrent call must succeed"
386        );
387
388        let contents = std::fs::read_to_string(target.join(".gitignore")).unwrap();
389        assert_eq!(contents.trim(), "*");
390    }
391
392    use crate::mode::Mode;
393    use crate::stage::Stage;
394    use crate::state::AgentKind;
395
396    fn state_in(root: &Path, phase: u32, stage: Stage) -> State {
397        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
398        state.stage = stage;
399        state
400    }
401
402    #[test]
403    fn paths_are_per_phase_under_devflow_dir() {
404        let root = Path::new("/repo");
405        assert_eq!(devflow_dir(root), Path::new("/repo/.devflow"));
406        assert_eq!(
407            state_path(root, 7),
408            Path::new("/repo/.devflow/state-07.json")
409        );
410        assert_eq!(
411            state_path(root, 14),
412            Path::new("/repo/.devflow/state-14.json")
413        );
414    }
415
416    #[test]
417    fn save_then_load_round_trips() {
418        let dir = tempfile::tempdir().unwrap();
419        let state = state_in(dir.path(), 1, Stage::Code);
420        save_state(&state).expect("save");
421
422        let loaded = load_state(dir.path(), 1).expect("load");
423        assert_eq!(loaded.stage, Stage::Code);
424        assert_eq!(loaded.phase, 1);
425        assert_eq!(loaded.agent, AgentKind::Claude);
426        assert_eq!(loaded.mode, Mode::Auto);
427    }
428
429    /// 13-DEFERRED-CR-03 scenario 1: two phases' states must coexist — the
430    /// second `start` no longer clobbers the first phase's state.
431    #[test]
432    fn two_phases_states_coexist_without_clobbering() {
433        let dir = tempfile::tempdir().unwrap();
434        save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
435        save_state(&state_in(dir.path(), 14, Stage::Validate)).unwrap();
436
437        let a = load_state(dir.path(), 13).expect("phase 13 state");
438        let b = load_state(dir.path(), 14).expect("phase 14 state");
439        assert_eq!(a.phase, 13);
440        assert_eq!(a.stage, Stage::Code);
441        assert_eq!(b.phase, 14);
442        assert_eq!(b.stage, Stage::Validate);
443    }
444
445    #[test]
446    fn save_state_writes_atomically_and_leaves_no_temp() {
447        let dir = tempfile::tempdir().unwrap();
448        let state = state_in(dir.path(), 1, Stage::Validate);
449
450        save_state(&state).expect("save");
451
452        let path = state_path(dir.path(), 1);
453        assert!(path.exists());
454        let loaded = load_state(dir.path(), 1).expect("load");
455        assert_eq!(loaded.stage, Stage::Validate);
456        assert_eq!(loaded.phase, state.phase);
457        assert!(!path.with_extension("tmp").exists());
458    }
459
460    #[test]
461    fn load_missing_state_errors() {
462        let dir = tempfile::tempdir().unwrap();
463        let err = load_state(dir.path(), 1).unwrap_err();
464        assert!(matches!(err, WorkflowError::MissingState(_)));
465    }
466
467    #[test]
468    fn clear_removes_state_and_is_idempotent() {
469        let dir = tempfile::tempdir().unwrap();
470        let state = state_in(dir.path(), 1, Stage::Validate);
471        save_state(&state).unwrap();
472        assert!(state_path(dir.path(), 1).exists());
473
474        clear_state(dir.path(), 1).expect("clear");
475        assert!(!state_path(dir.path(), 1).exists());
476        // Clearing when nothing is present is a no-op success.
477        clear_state(dir.path(), 1).expect("clear again");
478    }
479
480    #[test]
481    fn clear_only_touches_its_own_phase() {
482        let dir = tempfile::tempdir().unwrap();
483        save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
484        save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
485
486        clear_state(dir.path(), 13).unwrap();
487
488        assert!(!state_path(dir.path(), 13).exists());
489        assert!(load_state(dir.path(), 14).is_ok(), "phase 14 must survive");
490    }
491
492    #[test]
493    fn list_states_enumerates_sorted_by_phase() {
494        let dir = tempfile::tempdir().unwrap();
495        save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
496        save_state(&state_in(dir.path(), 3, Stage::Code)).unwrap();
497
498        let states = list_states(dir.path());
499        assert_eq!(
500            states.iter().map(|s| s.phase).collect::<Vec<_>>(),
501            vec![3, 14]
502        );
503    }
504
505    #[test]
506    fn list_states_empty_when_no_devflow_dir() {
507        let dir = tempfile::tempdir().unwrap();
508        assert!(list_states(dir.path()).is_empty());
509    }
510
511    #[test]
512    fn list_states_skips_corrupt_files() {
513        let dir = tempfile::tempdir().unwrap();
514        save_state(&state_in(dir.path(), 5, Stage::Code)).unwrap();
515        std::fs::write(state_path(dir.path(), 6), "not json").unwrap();
516
517        let states = list_states(dir.path());
518        assert_eq!(states.len(), 1);
519        assert_eq!(states[0].phase, 5);
520    }
521
522    /// Upgrade path: a legacy single-slot `state.json` written by an older
523    /// binary must be readable after upgrading — migrated to its per-phase
524    /// name on first load/list, with the legacy file removed.
525    #[test]
526    fn legacy_state_json_migrates_on_load() {
527        let dir = tempfile::tempdir().unwrap();
528        let state = state_in(dir.path(), 9, Stage::Validate);
529        let legacy = legacy_state_path(dir.path());
530        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
531        std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
532
533        let loaded = load_state(dir.path(), 9).expect("legacy state must migrate");
534        assert_eq!(loaded.phase, 9);
535        assert_eq!(loaded.stage, Stage::Validate);
536        assert!(!legacy.exists(), "legacy file must be gone after migration");
537        assert!(state_path(dir.path(), 9).exists());
538    }
539
540    #[test]
541    fn legacy_state_json_migrates_on_list() {
542        let dir = tempfile::tempdir().unwrap();
543        let state = state_in(dir.path(), 4, Stage::Code);
544        let legacy = legacy_state_path(dir.path());
545        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
546        std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
547
548        let states = list_states(dir.path());
549        assert_eq!(states.len(), 1);
550        assert_eq!(states[0].phase, 4);
551        assert!(!legacy.exists());
552    }
553
554    #[test]
555    fn legacy_migration_never_overwrites_existing_per_phase_state() {
556        let dir = tempfile::tempdir().unwrap();
557        // Newer per-phase state at Ship...
558        save_state(&state_in(dir.path(), 9, Stage::Ship)).unwrap();
559        // ...and a stale legacy file for the same phase still at Code.
560        let legacy = legacy_state_path(dir.path());
561        std::fs::write(
562            &legacy,
563            serde_json::to_string_pretty(&state_in(dir.path(), 9, Stage::Code)).unwrap(),
564        )
565        .unwrap();
566
567        let loaded = load_state(dir.path(), 9).unwrap();
568        assert_eq!(loaded.stage, Stage::Ship, "per-phase state must win");
569        assert!(!legacy.exists(), "stale legacy file must be dropped");
570    }
571}