use crate::stage::Stage;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateFile {
pub phase: u32,
pub stage: Stage,
pub context: String,
pub timestamp: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateResponse {
pub approved: bool,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub responded_by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateAck {
pub received: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateAction {
Advance,
LoopBack(Stage),
Abort(String),
}
impl GateAction {
pub fn from_response(response: &GateResponse) -> GateAction {
if response.approved {
return GateAction::Advance;
}
match response.note.as_deref() {
Some(note) if note.to_ascii_lowercase().contains("abort") => {
GateAction::Abort(note.to_string())
}
_ => GateAction::LoopBack(Stage::Code),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum GateError {
#[error("gate I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("gate JSON failed: {0}")]
Json(#[from] serde_json::Error),
#[error("no open gate for phase {phase} stage {stage} — see `devflow gate list`")]
NoOpenGate { phase: u32, stage: Stage },
#[error("gate for phase {phase} stage {stage} already has a response awaiting pickup")]
AlreadyResponded { phase: u32, stage: Stage },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenGate {
pub phase: u32,
pub stage: Stage,
pub context: String,
pub timestamp: String,
}
pub struct Gates;
impl Gates {
pub fn dir(project_root: &Path) -> PathBuf {
project_root.join(".devflow").join("gates")
}
pub fn gate_path(project_root: &Path, phase: u32, stage: Stage) -> PathBuf {
Self::dir(project_root).join(format!("{phase:02}-{stage}.json"))
}
pub fn response_path(project_root: &Path, phase: u32, stage: Stage) -> PathBuf {
Self::dir(project_root).join(format!("{phase:02}-{stage}.response.json"))
}
pub fn ack_path(project_root: &Path, phase: u32, stage: Stage) -> PathBuf {
Self::dir(project_root).join(format!("{phase:02}-{stage}.ack.json"))
}
pub fn list_open(project_root: &Path) -> Vec<OpenGate> {
let mut open = Vec::new();
let Ok(entries) = std::fs::read_dir(Self::dir(project_root)) else {
return open;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.ends_with(".json")
|| name.ends_with(".response.json")
|| name.ends_with(".ack.json")
{
continue;
}
let Ok(contents) = std::fs::read_to_string(entry.path()) else {
continue;
};
let Ok(gate) = serde_json::from_str::<GateFile>(&contents) else {
continue;
};
if Self::response_path(project_root, gate.phase, gate.stage).exists() {
continue;
}
open.push(OpenGate {
phase: gate.phase,
stage: gate.stage,
context: gate.context,
timestamp: gate.timestamp,
});
}
open.sort_by_key(|g| (g.phase, g.stage.to_string()));
open
}
pub fn respond(
project_root: &Path,
phase: u32,
stage: Stage,
response: &GateResponse,
) -> Result<PathBuf, GateError> {
if !Self::gate_path(project_root, phase, stage).exists() {
return Err(GateError::NoOpenGate { phase, stage });
}
let path = Self::response_path(project_root, phase, stage);
if path.exists() {
return Err(GateError::AlreadyResponded { phase, stage });
}
write_atomic(&path, &serde_json::to_string_pretty(response)?)?;
info!(
"gate response written for phase {phase} {stage}: approved={}",
response.approved
);
Ok(path)
}
pub fn write_gate(
project_root: &Path,
phase: u32,
stage: Stage,
context: &str,
) -> Result<PathBuf, GateError> {
let gate = GateFile {
phase,
stage,
context: context.to_string(),
timestamp: unix_now(),
};
let path = Self::gate_path(project_root, phase, stage);
info!("writing gate {} for phase {phase}", stage);
write_atomic(&path, &serde_json::to_string_pretty(&gate)?)?;
Ok(path)
}
pub fn poll_response(
project_root: &Path,
phase: u32,
stage: Stage,
timeout_secs: u64,
) -> Option<GateResponse> {
let path = Self::response_path(project_root, phase, stage);
let deadline = Duration::from_secs(timeout_secs);
let mut waited = Duration::ZERO;
let mut backoff = Duration::from_secs(1);
let cap = Duration::from_secs(60);
debug!("polling for gate response at {}", path.display());
loop {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(response) = serde_json::from_str::<GateResponse>(&contents)
{
return Some(response);
}
if waited >= deadline {
return None;
}
let sleep = backoff.min(deadline - waited);
std::thread::sleep(sleep);
waited += sleep;
backoff = (backoff * 2).min(cap);
}
}
pub fn ack(project_root: &Path, phase: u32, stage: Stage) -> Result<PathBuf, GateError> {
let path = Self::ack_path(project_root, phase, stage);
write_atomic(
&path,
&serde_json::to_string_pretty(&GateAck { received: true })?,
)?;
Ok(path)
}
pub fn cleanup(project_root: &Path, phase: u32, stage: Stage) -> Result<(), GateError> {
for path in [
Self::gate_path(project_root, phase, stage),
Self::response_path(project_root, phase, stage),
Self::ack_path(project_root, phase, stage),
] {
if path.exists() {
std::fs::remove_file(path)?;
}
}
Ok(())
}
}
pub fn fire_gate_notify(phase: u32, stage: Stage, context: &str, unexpected: bool) {
let cmd = match std::env::var("DEVFLOW_GATE_NOTIFY_CMD") {
Ok(cmd) if !cmd.is_empty() => cmd,
_ => return,
};
run_notify_command(&cmd, phase, stage, context, unexpected);
}
fn run_notify_command(cmd: &str, phase: u32, stage: Stage, context: &str, unexpected: bool) {
let output = Command::new("sh")
.arg("-c")
.arg(cmd)
.env("DEVFLOW_GATE_PHASE", phase.to_string())
.env("DEVFLOW_GATE_STAGE", stage.to_string())
.env("DEVFLOW_GATE_CONTEXT", context)
.env(
"DEVFLOW_NON_SILENT_GATE",
if unexpected { "1" } else { "0" },
)
.output();
match output {
Ok(out) if out.status.success() => {
debug!("gate notify hook ran successfully");
}
Ok(out) => warn!(
"gate notify hook exited with status {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
),
Err(err) => warn!("gate notify hook could not be spawned: {err}"),
}
}
fn write_atomic(path: &Path, contents: &str) -> Result<(), GateError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, contents)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn unix_now() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn gate_file_round_trips_through_serde() {
let gate = GateFile {
phase: 11,
stage: Stage::Validate,
context: "review the validation".into(),
timestamp: "1750000000".into(),
};
let json = serde_json::to_string(&gate).unwrap();
let back: GateFile = serde_json::from_str(&json).unwrap();
assert_eq!(gate, back);
}
#[test]
fn write_gate_creates_file_with_correct_path() {
let dir = tempfile::tempdir().unwrap();
let path = Gates::write_gate(dir.path(), 11, Stage::Validate, "ctx").unwrap();
assert!(path.ends_with(".devflow/gates/11-validate.json"));
assert!(path.exists());
let gate: GateFile =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(gate.phase, 11);
assert_eq!(gate.stage, Stage::Validate);
assert_eq!(gate.context, "ctx");
}
#[test]
fn poll_response_returns_when_file_appears() {
let dir = tempfile::tempdir().unwrap();
let response = GateResponse {
approved: true,
note: None,
responded_by: Some("human".into()),
};
let path = Gates::response_path(dir.path(), 11, Stage::Validate);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
let got = Gates::poll_response(dir.path(), 11, Stage::Validate, 1).unwrap();
assert_eq!(got, response);
}
#[test]
fn poll_response_returns_immediately_at_full_timeout() {
const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
let dir = tempfile::tempdir().unwrap();
let response = GateResponse {
approved: true,
note: None,
responded_by: Some("human".into()),
};
let path = Gates::response_path(dir.path(), 11, Stage::Validate);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
let started = std::time::Instant::now();
let got = Gates::poll_response(dir.path(), 11, Stage::Validate, SEVEN_DAYS).unwrap();
assert_eq!(got, response);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[test]
fn poll_response_times_out_when_absent() {
let dir = tempfile::tempdir().unwrap();
assert!(Gates::poll_response(dir.path(), 11, Stage::Ship, 0).is_none());
}
#[test]
fn ack_writes_received_true() {
let dir = tempfile::tempdir().unwrap();
let path = Gates::ack(dir.path(), 11, Stage::Ship).unwrap();
let ack: GateAck = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(ack.received);
}
#[test]
fn cleanup_removes_all_three_files_idempotently() {
let dir = tempfile::tempdir().unwrap();
Gates::write_gate(dir.path(), 11, Stage::Validate, "ctx").unwrap();
Gates::ack(dir.path(), 11, Stage::Validate).unwrap();
std::fs::write(
Gates::response_path(dir.path(), 11, Stage::Validate),
"{\"approved\":true}",
)
.unwrap();
Gates::cleanup(dir.path(), 11, Stage::Validate).unwrap();
assert!(!Gates::gate_path(dir.path(), 11, Stage::Validate).exists());
assert!(!Gates::response_path(dir.path(), 11, Stage::Validate).exists());
assert!(!Gates::ack_path(dir.path(), 11, Stage::Validate).exists());
Gates::cleanup(dir.path(), 11, Stage::Validate).unwrap();
}
#[test]
fn list_open_shows_unanswered_gates_only() {
let dir = tempfile::tempdir().unwrap();
Gates::write_gate(dir.path(), 7, Stage::Ship, "approve merge?").unwrap();
Gates::write_gate(dir.path(), 8, Stage::Validate, "review gaps").unwrap();
Gates::respond(
dir.path(),
8,
Stage::Validate,
&GateResponse {
approved: true,
note: None,
responded_by: Some("test".into()),
},
)
.unwrap();
Gates::ack(dir.path(), 8, Stage::Validate).unwrap();
std::fs::write(Gates::dir(dir.path()).join("junk.json"), "{nope").unwrap();
let open = Gates::list_open(dir.path());
assert_eq!(open.len(), 1);
assert_eq!(open[0].phase, 7);
assert_eq!(open[0].stage, Stage::Ship);
assert_eq!(open[0].context, "approve merge?");
}
#[test]
fn list_open_is_empty_without_gates_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(Gates::list_open(dir.path()).is_empty());
}
#[test]
fn respond_writes_a_response_poll_response_consumes() {
let dir = tempfile::tempdir().unwrap();
Gates::write_gate(dir.path(), 9, Stage::Ship, "ctx").unwrap();
let response = GateResponse {
approved: false,
note: Some("abort: nope".into()),
responded_by: Some("cli".into()),
};
Gates::respond(dir.path(), 9, Stage::Ship, &response).unwrap();
let polled = Gates::poll_response(dir.path(), 9, Stage::Ship, 1).unwrap();
assert_eq!(polled, response);
assert!(matches!(
GateAction::from_response(&polled),
GateAction::Abort(_)
));
}
#[test]
fn respond_refuses_when_no_gate_is_open() {
let dir = tempfile::tempdir().unwrap();
let response = GateResponse {
approved: true,
note: None,
responded_by: None,
};
let err = Gates::respond(dir.path(), 3, Stage::Ship, &response).unwrap_err();
assert!(matches!(err, GateError::NoOpenGate { phase: 3, .. }));
}
#[test]
fn respond_refuses_to_clobber_unconsumed_response() {
let dir = tempfile::tempdir().unwrap();
Gates::write_gate(dir.path(), 4, Stage::Validate, "ctx").unwrap();
let response = GateResponse {
approved: true,
note: None,
responded_by: None,
};
Gates::respond(dir.path(), 4, Stage::Validate, &response).unwrap();
let err = Gates::respond(dir.path(), 4, Stage::Validate, &response).unwrap_err();
assert!(matches!(err, GateError::AlreadyResponded { phase: 4, .. }));
}
#[test]
fn gate_action_advances_on_approval() {
let response = GateResponse {
approved: true,
note: None,
responded_by: None,
};
assert_eq!(GateAction::from_response(&response), GateAction::Advance);
}
#[test]
fn gate_action_loops_back_on_fixable_rejection() {
let response = GateResponse {
approved: false,
note: Some("fix the failing test".into()),
responded_by: None,
};
assert_eq!(
GateAction::from_response(&response),
GateAction::LoopBack(Stage::Code)
);
}
#[test]
fn gate_action_aborts_when_note_says_abort() {
let response = GateResponse {
approved: false,
note: Some("abort: requirements changed".into()),
responded_by: None,
};
assert!(matches!(
GateAction::from_response(&response),
GateAction::Abort(_)
));
}
#[test]
fn notify_hook_runs_configured_command() {
let dir = tempfile::tempdir().unwrap();
let sentinel = dir.path().join("sentinel");
let cmd = format!("touch {}", sentinel.display());
run_notify_command(&cmd, 11, Stage::Ship, "ctx", false);
assert!(sentinel.exists());
}
#[test]
fn notify_hook_failure_is_fail_soft() {
run_notify_command("exit 1", 11, Stage::Ship, "ctx", false);
}
#[test]
fn notify_hook_sets_non_silent_flag() {
let dir = tempfile::tempdir().unwrap();
let sentinel_unexpected = dir.path().join("unexpected");
let cmd_unexpected = format!(
"echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
sentinel_unexpected.display()
);
run_notify_command(&cmd_unexpected, 11, Stage::Code, "ctx", true);
assert_eq!(std::fs::read_to_string(&sentinel_unexpected).unwrap(), "1");
let sentinel_expected = dir.path().join("expected");
let cmd_expected = format!(
"echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
sentinel_expected.display()
);
run_notify_command(&cmd_expected, 11, Stage::Ship, "ctx", false);
assert_eq!(std::fs::read_to_string(&sentinel_expected).unwrap(), "0");
}
#[test]
fn notify_hook_unset_is_noop() {
let _guard = ENV_MUTEX.lock().unwrap();
unsafe {
std::env::remove_var("DEVFLOW_GATE_NOTIFY_CMD");
}
fire_gate_notify(11, Stage::Ship, "ctx", false);
}
}