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