Skip to main content

devflow_core/
gates.rs

1//! Gate file protocol — the handoff between DevFlow and a human (via Hermes).
2//!
3//! A *gate* is a pause point where DevFlow writes a request to
4//! `.devflow/gates/` and waits for a human (or the Hermes cron poller) to drop
5//! a response file. The protocol is three files per gated stage:
6//!
7//! - `NN-{stage}.json` — the gate request DevFlow writes (a [`GateFile`]).
8//! - `NN-{stage}.response.json` — the human's answer (a [`GateResponse`]).
9//! - `NN-{stage}.ack.json` — DevFlow's receipt (a [`GateAck`]) so the poller can
10//!   clean up.
11//!
12//! Writes are atomic (write-to-temp + rename) so a reader never sees a partial
13//! file. Polling uses exponential backoff so a long human wait costs little.
14
15use crate::stage::Stage;
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20use tracing::{debug, info, warn};
21
22/// The gate request DevFlow writes when it pauses for a human decision.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct GateFile {
25    /// Phase the gate belongs to.
26    pub phase: u32,
27    /// Stage that fired the gate.
28    pub stage: Stage,
29    /// Human-readable context explaining what is being asked.
30    pub context: String,
31    /// Unix timestamp (seconds) when the gate was written.
32    pub timestamp: String,
33}
34
35/// The human's response to a gate.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct GateResponse {
38    /// Whether the gated work is approved to advance.
39    pub approved: bool,
40    /// Optional free-text note (e.g. what to fix on a rejection).
41    #[serde(default)]
42    pub note: Option<String>,
43    /// Who responded (human name, or "hermes").
44    #[serde(default)]
45    pub responded_by: Option<String>,
46}
47
48/// DevFlow's receipt that it has read a [`GateResponse`].
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct GateAck {
51    /// Always `true` — presence of the file is the signal.
52    pub received: bool,
53}
54
55/// What DevFlow should do after reading a [`GateResponse`].
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum GateAction {
58    /// Approved — advance to the next stage.
59    Advance,
60    /// Rejected with fixable feedback — loop back to the given stage.
61    LoopBack(Stage),
62    /// Rejected and aborted — stop the workflow with a reason.
63    Abort(String),
64}
65
66impl GateAction {
67    /// Decide the action from a response: approval advances, a rejection loops
68    /// back to Code unless the note asks to abort.
69    pub fn from_response(response: &GateResponse) -> GateAction {
70        if response.approved {
71            return GateAction::Advance;
72        }
73        match response.note.as_deref() {
74            Some(note) if note.to_ascii_lowercase().contains("abort") => {
75                GateAction::Abort(note.to_string())
76            }
77            _ => GateAction::LoopBack(Stage::Code),
78        }
79    }
80}
81
82/// Errors produced by the gate protocol.
83#[derive(Debug, thiserror::Error)]
84pub enum GateError {
85    /// Filesystem operation failed.
86    #[error("gate I/O failed: {0}")]
87    Io(#[from] std::io::Error),
88    /// JSON parse or serialization failed.
89    #[error("gate JSON failed: {0}")]
90    Json(#[from] serde_json::Error),
91    /// Responding to a gate that was never fired (or already resolved).
92    #[error("no open gate for phase {phase} stage {stage} — see `devflow gate list`")]
93    NoOpenGate { phase: u32, stage: Stage },
94    /// Responding to a gate that already has a response on disk.
95    #[error("gate for phase {phase} stage {stage} already has a response awaiting pickup")]
96    AlreadyResponded { phase: u32, stage: Stage },
97}
98
99/// An open gate: a request the workflow wrote that has no response yet.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct OpenGate {
102    /// Phase the gate belongs to.
103    pub phase: u32,
104    /// Stage that fired the gate.
105    pub stage: Stage,
106    /// Human-readable context from the request.
107    pub context: String,
108    /// Unix timestamp (seconds) when the gate was written.
109    pub timestamp: String,
110}
111
112/// The gate-file protocol, scoped to a project's `.devflow/gates/` directory.
113pub struct Gates;
114
115impl Gates {
116    /// The `.devflow/gates/` directory for a project.
117    pub fn dir(project_root: &Path) -> PathBuf {
118        project_root.join(".devflow").join("gates")
119    }
120
121    /// Path to the gate request file for a phase + stage.
122    pub fn gate_path(project_root: &Path, phase: u32, stage: Stage) -> PathBuf {
123        Self::dir(project_root).join(format!("{phase:02}-{stage}.json"))
124    }
125
126    /// Path to the response file for a phase + stage.
127    pub fn response_path(project_root: &Path, phase: u32, stage: Stage) -> PathBuf {
128        Self::dir(project_root).join(format!("{phase:02}-{stage}.response.json"))
129    }
130
131    /// Path to the ack file for a phase + stage.
132    pub fn ack_path(project_root: &Path, phase: u32, stage: Stage) -> PathBuf {
133        Self::dir(project_root).join(format!("{phase:02}-{stage}.ack.json"))
134    }
135
136    /// Every open gate (request written, no response yet), sorted by phase
137    /// then stage. Request files are `NN-{stage}.json`; `.response.json` and
138    /// `.ack.json` siblings are protocol artifacts, not requests, and any
139    /// unparsable file is skipped — listing must degrade, not die.
140    pub fn list_open(project_root: &Path) -> Vec<OpenGate> {
141        let mut open = Vec::new();
142        let Ok(entries) = std::fs::read_dir(Self::dir(project_root)) else {
143            return open;
144        };
145        for entry in entries.flatten() {
146            let name = entry.file_name();
147            let Some(name) = name.to_str() else { continue };
148            if !name.ends_with(".json")
149                || name.ends_with(".response.json")
150                || name.ends_with(".ack.json")
151            {
152                continue;
153            }
154            let Ok(contents) = std::fs::read_to_string(entry.path()) else {
155                continue;
156            };
157            let Ok(gate) = serde_json::from_str::<GateFile>(&contents) else {
158                continue;
159            };
160            if Self::response_path(project_root, gate.phase, gate.stage).exists() {
161                continue;
162            }
163            open.push(OpenGate {
164                phase: gate.phase,
165                stage: gate.stage,
166                context: gate.context,
167                timestamp: gate.timestamp,
168            });
169        }
170        open.sort_by_key(|g| (g.phase, g.stage.to_string()));
171        open
172    }
173
174    /// Answer an open gate by writing its response file atomically — the
175    /// programmatic form of what a human previously hand-edited. Refuses
176    /// when no gate request is open for the phase+stage, and when a
177    /// response is already on disk awaiting the workflow's poller (silently
178    /// replacing an unconsumed answer would race the poll).
179    pub fn respond(
180        project_root: &Path,
181        phase: u32,
182        stage: Stage,
183        response: &GateResponse,
184    ) -> Result<PathBuf, GateError> {
185        if !Self::gate_path(project_root, phase, stage).exists() {
186            return Err(GateError::NoOpenGate { phase, stage });
187        }
188        let path = Self::response_path(project_root, phase, stage);
189        if path.exists() {
190            return Err(GateError::AlreadyResponded { phase, stage });
191        }
192        write_atomic(&path, &serde_json::to_string_pretty(response)?)?;
193        info!(
194            "gate response written for phase {phase} {stage}: approved={}",
195            response.approved
196        );
197        Ok(path)
198    }
199
200    /// Write a gate request, creating the gates directory if needed.
201    pub fn write_gate(
202        project_root: &Path,
203        phase: u32,
204        stage: Stage,
205        context: &str,
206    ) -> Result<PathBuf, GateError> {
207        let gate = GateFile {
208            phase,
209            stage,
210            context: context.to_string(),
211            timestamp: unix_now(),
212        };
213        let path = Self::gate_path(project_root, phase, stage);
214        info!("writing gate {} for phase {phase}", stage);
215        write_atomic(&path, &serde_json::to_string_pretty(&gate)?)?;
216        Ok(path)
217    }
218
219    /// Poll for a response with exponential backoff (1s → 2s → 4s … capped at
220    /// 60s), giving up after `timeout_secs`. Returns the parsed response when it
221    /// appears, or `None` on timeout.
222    pub fn poll_response(
223        project_root: &Path,
224        phase: u32,
225        stage: Stage,
226        timeout_secs: u64,
227    ) -> Option<GateResponse> {
228        let path = Self::response_path(project_root, phase, stage);
229        let deadline = Duration::from_secs(timeout_secs);
230        let mut waited = Duration::ZERO;
231        let mut backoff = Duration::from_secs(1);
232        let cap = Duration::from_secs(60);
233        debug!("polling for gate response at {}", path.display());
234        loop {
235            if let Ok(contents) = std::fs::read_to_string(&path)
236                && let Ok(response) = serde_json::from_str::<GateResponse>(&contents)
237            {
238                return Some(response);
239            }
240            if waited >= deadline {
241                return None;
242            }
243            let sleep = backoff.min(deadline - waited);
244            std::thread::sleep(sleep);
245            waited += sleep;
246            backoff = (backoff * 2).min(cap);
247        }
248    }
249
250    /// Write an ack file signalling the response was read.
251    pub fn ack(project_root: &Path, phase: u32, stage: Stage) -> Result<PathBuf, GateError> {
252        let path = Self::ack_path(project_root, phase, stage);
253        write_atomic(
254            &path,
255            &serde_json::to_string_pretty(&GateAck { received: true })?,
256        )?;
257        Ok(path)
258    }
259
260    /// Remove the gate, response, and ack files for a stage. Idempotent.
261    pub fn cleanup(project_root: &Path, phase: u32, stage: Stage) -> Result<(), GateError> {
262        for path in [
263            Self::gate_path(project_root, phase, stage),
264            Self::response_path(project_root, phase, stage),
265            Self::ack_path(project_root, phase, stage),
266        ] {
267            if path.exists() {
268                std::fs::remove_file(path)?;
269            }
270        }
271        Ok(())
272    }
273}
274
275/// Fire the operator-configured gate notify hook, if any.
276///
277/// Reads `DEVFLOW_GATE_NOTIFY_CMD`; if unset or empty, this is a silent no-op
278/// (no notify command configured). Otherwise delegates to
279/// [`run_notify_command`]. `unexpected` marks a gate fired on a stage the
280/// active [`crate::mode::Mode`] would not normally gate (e.g. a Define/Plan/Code
281/// failure in Auto mode) — a never-silent gate per WR-11.
282pub fn fire_gate_notify(phase: u32, stage: Stage, context: &str, unexpected: bool) {
283    let cmd = match std::env::var("DEVFLOW_GATE_NOTIFY_CMD") {
284        Ok(cmd) if !cmd.is_empty() => cmd,
285        _ => return,
286    };
287    run_notify_command(&cmd, phase, stage, context, unexpected);
288}
289
290/// Run the notify `cmd` via `sh -c`, passing gate metadata to the child as
291/// environment variables — never interpolated into the command string
292/// (WR-01 argv-not-shell precedent; `context` may contain agent-generated,
293/// untrusted text). Fail-soft: a non-zero exit or spawn error is logged via
294/// `warn!` and otherwise ignored — this must never propagate an error that
295/// could abort `run_gate`.
296fn run_notify_command(cmd: &str, phase: u32, stage: Stage, context: &str, unexpected: bool) {
297    let output = Command::new("sh")
298        .arg("-c")
299        .arg(cmd)
300        .env("DEVFLOW_GATE_PHASE", phase.to_string())
301        .env("DEVFLOW_GATE_STAGE", stage.to_string())
302        .env("DEVFLOW_GATE_CONTEXT", context)
303        .env(
304            "DEVFLOW_NON_SILENT_GATE",
305            if unexpected { "1" } else { "0" },
306        )
307        .output();
308    match output {
309        Ok(out) if out.status.success() => {
310            debug!("gate notify hook ran successfully");
311        }
312        Ok(out) => warn!(
313            "gate notify hook exited with status {:?}: {}",
314            out.status.code(),
315            String::from_utf8_lossy(&out.stderr)
316        ),
317        Err(err) => warn!("gate notify hook could not be spawned: {err}"),
318    }
319}
320
321/// Write `contents` to `path` atomically: write a temp file in the same
322/// directory, then rename over the target so readers never see a partial write.
323fn write_atomic(path: &Path, contents: &str) -> Result<(), GateError> {
324    if let Some(parent) = path.parent() {
325        std::fs::create_dir_all(parent)?;
326    }
327    let tmp = path.with_extension("tmp");
328    std::fs::write(&tmp, contents)?;
329    std::fs::rename(&tmp, path)?;
330    Ok(())
331}
332
333fn unix_now() -> String {
334    SystemTime::now()
335        .duration_since(UNIX_EPOCH)
336        .map(|d| d.as_secs().to_string())
337        .unwrap_or_else(|_| "0".to_string())
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use std::sync::Mutex;
344
345    /// Serializes tests that mutate process-global env vars (`set_var`/
346    /// `remove_var` are process-wide and `cargo test` runs in parallel by
347    /// default) so they don't race each other.
348    static ENV_MUTEX: Mutex<()> = Mutex::new(());
349
350    #[test]
351    fn gate_file_round_trips_through_serde() {
352        let gate = GateFile {
353            phase: 11,
354            stage: Stage::Validate,
355            context: "review the validation".into(),
356            timestamp: "1750000000".into(),
357        };
358        let json = serde_json::to_string(&gate).unwrap();
359        let back: GateFile = serde_json::from_str(&json).unwrap();
360        assert_eq!(gate, back);
361    }
362
363    #[test]
364    fn write_gate_creates_file_with_correct_path() {
365        let dir = tempfile::tempdir().unwrap();
366        let path = Gates::write_gate(dir.path(), 11, Stage::Validate, "ctx").unwrap();
367        assert!(path.ends_with(".devflow/gates/11-validate.json"));
368        assert!(path.exists());
369        let gate: GateFile =
370            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
371        assert_eq!(gate.phase, 11);
372        assert_eq!(gate.stage, Stage::Validate);
373        assert_eq!(gate.context, "ctx");
374    }
375
376    #[test]
377    fn poll_response_returns_when_file_appears() {
378        let dir = tempfile::tempdir().unwrap();
379        let response = GateResponse {
380            approved: true,
381            note: None,
382            responded_by: Some("human".into()),
383        };
384        let path = Gates::response_path(dir.path(), 11, Stage::Validate);
385        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
386        std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
387
388        let got = Gates::poll_response(dir.path(), 11, Stage::Validate, 1).unwrap();
389        assert_eq!(got, response);
390    }
391
392    #[test]
393    fn poll_response_returns_immediately_at_full_timeout() {
394        const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
395
396        let dir = tempfile::tempdir().unwrap();
397        let response = GateResponse {
398            approved: true,
399            note: None,
400            responded_by: Some("human".into()),
401        };
402        let path = Gates::response_path(dir.path(), 11, Stage::Validate);
403        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
404        std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
405
406        let started = std::time::Instant::now();
407        let got = Gates::poll_response(dir.path(), 11, Stage::Validate, SEVEN_DAYS).unwrap();
408
409        assert_eq!(got, response);
410        assert!(started.elapsed() < std::time::Duration::from_secs(5));
411    }
412
413    #[test]
414    fn poll_response_times_out_when_absent() {
415        let dir = tempfile::tempdir().unwrap();
416        assert!(Gates::poll_response(dir.path(), 11, Stage::Ship, 0).is_none());
417    }
418
419    #[test]
420    fn ack_writes_received_true() {
421        let dir = tempfile::tempdir().unwrap();
422        let path = Gates::ack(dir.path(), 11, Stage::Ship).unwrap();
423        let ack: GateAck = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
424        assert!(ack.received);
425    }
426
427    #[test]
428    fn cleanup_removes_all_three_files_idempotently() {
429        let dir = tempfile::tempdir().unwrap();
430        Gates::write_gate(dir.path(), 11, Stage::Validate, "ctx").unwrap();
431        Gates::ack(dir.path(), 11, Stage::Validate).unwrap();
432        std::fs::write(
433            Gates::response_path(dir.path(), 11, Stage::Validate),
434            "{\"approved\":true}",
435        )
436        .unwrap();
437
438        Gates::cleanup(dir.path(), 11, Stage::Validate).unwrap();
439        assert!(!Gates::gate_path(dir.path(), 11, Stage::Validate).exists());
440        assert!(!Gates::response_path(dir.path(), 11, Stage::Validate).exists());
441        assert!(!Gates::ack_path(dir.path(), 11, Stage::Validate).exists());
442        // Idempotent: cleaning again with nothing present succeeds.
443        Gates::cleanup(dir.path(), 11, Stage::Validate).unwrap();
444    }
445
446    /// 15a: `devflow gate list` — a gate is open until its response lands;
447    /// response/ack protocol files must never be mistaken for requests.
448    #[test]
449    fn list_open_shows_unanswered_gates_only() {
450        let dir = tempfile::tempdir().unwrap();
451        Gates::write_gate(dir.path(), 7, Stage::Ship, "approve merge?").unwrap();
452        Gates::write_gate(dir.path(), 8, Stage::Validate, "review gaps").unwrap();
453        // Phase 8's gate gets answered; its response/ack must hide it.
454        Gates::respond(
455            dir.path(),
456            8,
457            Stage::Validate,
458            &GateResponse {
459                approved: true,
460                note: None,
461                responded_by: Some("test".into()),
462            },
463        )
464        .unwrap();
465        Gates::ack(dir.path(), 8, Stage::Validate).unwrap();
466        // Corrupt junk in the gates dir is skipped, not fatal.
467        std::fs::write(Gates::dir(dir.path()).join("junk.json"), "{nope").unwrap();
468
469        let open = Gates::list_open(dir.path());
470
471        assert_eq!(open.len(), 1);
472        assert_eq!(open[0].phase, 7);
473        assert_eq!(open[0].stage, Stage::Ship);
474        assert_eq!(open[0].context, "approve merge?");
475    }
476
477    #[test]
478    fn list_open_is_empty_without_gates_dir() {
479        let dir = tempfile::tempdir().unwrap();
480        assert!(Gates::list_open(dir.path()).is_empty());
481    }
482
483    /// 15a: `respond` is the programmatic answer path — it must round-trip
484    /// through the same file `poll_response` reads.
485    #[test]
486    fn respond_writes_a_response_poll_response_consumes() {
487        let dir = tempfile::tempdir().unwrap();
488        Gates::write_gate(dir.path(), 9, Stage::Ship, "ctx").unwrap();
489        let response = GateResponse {
490            approved: false,
491            note: Some("abort: nope".into()),
492            responded_by: Some("cli".into()),
493        };
494
495        Gates::respond(dir.path(), 9, Stage::Ship, &response).unwrap();
496
497        let polled = Gates::poll_response(dir.path(), 9, Stage::Ship, 1).unwrap();
498        assert_eq!(polled, response);
499        assert!(matches!(
500            GateAction::from_response(&polled),
501            GateAction::Abort(_)
502        ));
503    }
504
505    #[test]
506    fn respond_refuses_when_no_gate_is_open() {
507        let dir = tempfile::tempdir().unwrap();
508        let response = GateResponse {
509            approved: true,
510            note: None,
511            responded_by: None,
512        };
513        let err = Gates::respond(dir.path(), 3, Stage::Ship, &response).unwrap_err();
514        assert!(matches!(err, GateError::NoOpenGate { phase: 3, .. }));
515    }
516
517    #[test]
518    fn respond_refuses_to_clobber_unconsumed_response() {
519        let dir = tempfile::tempdir().unwrap();
520        Gates::write_gate(dir.path(), 4, Stage::Validate, "ctx").unwrap();
521        let response = GateResponse {
522            approved: true,
523            note: None,
524            responded_by: None,
525        };
526        Gates::respond(dir.path(), 4, Stage::Validate, &response).unwrap();
527
528        let err = Gates::respond(dir.path(), 4, Stage::Validate, &response).unwrap_err();
529        assert!(matches!(err, GateError::AlreadyResponded { phase: 4, .. }));
530    }
531
532    #[test]
533    fn gate_action_advances_on_approval() {
534        let response = GateResponse {
535            approved: true,
536            note: None,
537            responded_by: None,
538        };
539        assert_eq!(GateAction::from_response(&response), GateAction::Advance);
540    }
541
542    #[test]
543    fn gate_action_loops_back_on_fixable_rejection() {
544        let response = GateResponse {
545            approved: false,
546            note: Some("fix the failing test".into()),
547            responded_by: None,
548        };
549        assert_eq!(
550            GateAction::from_response(&response),
551            GateAction::LoopBack(Stage::Code)
552        );
553    }
554
555    #[test]
556    fn gate_action_aborts_when_note_says_abort() {
557        let response = GateResponse {
558            approved: false,
559            note: Some("abort: requirements changed".into()),
560            responded_by: None,
561        };
562        assert!(matches!(
563            GateAction::from_response(&response),
564            GateAction::Abort(_)
565        ));
566    }
567
568    /// `run_notify_command` takes the command string as an argument (not an
569    /// env var), so this test needs no env mutation and cannot race other
570    /// tests.
571    #[test]
572    fn notify_hook_runs_configured_command() {
573        let dir = tempfile::tempdir().unwrap();
574        let sentinel = dir.path().join("sentinel");
575        let cmd = format!("touch {}", sentinel.display());
576        run_notify_command(&cmd, 11, Stage::Ship, "ctx", false);
577        assert!(sentinel.exists());
578    }
579
580    #[test]
581    fn notify_hook_failure_is_fail_soft() {
582        // A command that always fails must not panic or otherwise abort the
583        // caller — fail-soft per T-13-02.
584        run_notify_command("exit 1", 11, Stage::Ship, "ctx", false);
585    }
586
587    #[test]
588    fn notify_hook_sets_non_silent_flag() {
589        let dir = tempfile::tempdir().unwrap();
590
591        let sentinel_unexpected = dir.path().join("unexpected");
592        let cmd_unexpected = format!(
593            "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
594            sentinel_unexpected.display()
595        );
596        run_notify_command(&cmd_unexpected, 11, Stage::Code, "ctx", true);
597        assert_eq!(std::fs::read_to_string(&sentinel_unexpected).unwrap(), "1");
598
599        let sentinel_expected = dir.path().join("expected");
600        let cmd_expected = format!(
601            "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
602            sentinel_expected.display()
603        );
604        run_notify_command(&cmd_expected, 11, Stage::Ship, "ctx", false);
605        assert_eq!(std::fs::read_to_string(&sentinel_expected).unwrap(), "0");
606    }
607
608    /// This test mutates process-global env, so it acquires `ENV_MUTEX` to
609    /// avoid racing any other env-touching test in this module.
610    #[test]
611    fn notify_hook_unset_is_noop() {
612        let _guard = ENV_MUTEX.lock().unwrap();
613        // SAFETY: serialized under ENV_MUTEX — no other thread in this
614        // process reads/writes DEVFLOW_GATE_NOTIFY_CMD concurrently.
615        unsafe {
616            std::env::remove_var("DEVFLOW_GATE_NOTIFY_CMD");
617        }
618        // Must return normally without touching the filesystem or panicking.
619        fire_gate_notify(11, Stage::Ship, "ctx", false);
620    }
621}