1use crate::phase_id::PhaseId;
16use crate::stage::Stage;
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21use tracing::{debug, info, warn};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct GateFile {
26 pub phase: PhaseId,
28 pub stage: Stage,
30 pub context: String,
32 pub timestamp: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct GateResponse {
39 pub approved: bool,
41 #[serde(default)]
43 pub note: Option<String>,
44 #[serde(default)]
46 pub responded_by: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct GateAck {
52 pub received: bool,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum GateAction {
59 Advance,
61 LoopBack(Stage),
63 Abort(String),
65}
66
67impl GateAction {
68 pub fn from_response(response: &GateResponse) -> GateAction {
71 if response.approved {
72 return GateAction::Advance;
73 }
74 match response.note.as_deref() {
75 Some(note) if note.to_ascii_lowercase().contains("abort") => {
76 GateAction::Abort(note.to_string())
77 }
78 _ => GateAction::LoopBack(Stage::Code),
79 }
80 }
81}
82
83#[derive(Debug, thiserror::Error)]
85pub enum GateError {
86 #[error("gate I/O failed: {0}")]
88 Io(#[from] std::io::Error),
89 #[error("gate JSON failed: {0}")]
91 Json(#[from] serde_json::Error),
92 #[error("no open gate for phase {phase} stage {stage} — see `devflow gate list`")]
94 NoOpenGate { phase: PhaseId, stage: Stage },
95 #[error("gate for phase {phase} stage {stage} already has a response awaiting pickup")]
97 AlreadyResponded { phase: PhaseId, stage: Stage },
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct OpenGate {
103 pub phase: PhaseId,
105 pub stage: Stage,
107 pub context: String,
109 pub timestamp: String,
111}
112
113pub struct Gates;
115
116impl Gates {
117 pub fn dir(project_root: &Path) -> PathBuf {
119 project_root.join(".devflow").join("gates")
120 }
121
122 pub fn gate_path(project_root: &Path, phase: PhaseId, stage: Stage) -> PathBuf {
124 Self::dir(project_root).join(format!("{padded}-{stage}.json", padded = phase.padded()))
125 }
126
127 pub fn response_path(project_root: &Path, phase: PhaseId, stage: Stage) -> PathBuf {
129 Self::dir(project_root).join(format!(
130 "{padded}-{stage}.response.json",
131 padded = phase.padded()
132 ))
133 }
134
135 pub fn ack_path(project_root: &Path, phase: PhaseId, stage: Stage) -> PathBuf {
137 Self::dir(project_root).join(format!(
138 "{padded}-{stage}.ack.json",
139 padded = phase.padded()
140 ))
141 }
142
143 pub fn list_open(project_root: &Path) -> Vec<OpenGate> {
148 let mut open = Vec::new();
149 let Ok(entries) = std::fs::read_dir(Self::dir(project_root)) else {
150 return open;
151 };
152 for entry in entries.flatten() {
153 let name = entry.file_name();
154 let Some(name) = name.to_str() else { continue };
155 if !name.ends_with(".json")
156 || name.ends_with(".response.json")
157 || name.ends_with(".ack.json")
158 {
159 continue;
160 }
161 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
162 continue;
163 };
164 let Ok(gate) = serde_json::from_str::<GateFile>(&contents) else {
165 continue;
166 };
167 if Self::response_path(project_root, gate.phase, gate.stage).exists() {
168 continue;
169 }
170 open.push(OpenGate {
171 phase: gate.phase,
172 stage: gate.stage,
173 context: gate.context,
174 timestamp: gate.timestamp,
175 });
176 }
177 open.sort_by_key(|g| (g.phase, g.stage.to_string()));
178 open
179 }
180
181 pub fn respond(
187 project_root: &Path,
188 phase: PhaseId,
189 stage: Stage,
190 response: &GateResponse,
191 ) -> Result<PathBuf, GateError> {
192 if !Self::gate_path(project_root, phase, stage).exists() {
193 return Err(GateError::NoOpenGate { phase, stage });
194 }
195 let path = Self::response_path(project_root, phase, stage);
196 if path.exists() {
197 return Err(GateError::AlreadyResponded { phase, stage });
198 }
199 write_atomic(&path, &serde_json::to_string_pretty(response)?)?;
200 info!(
201 "gate response written for phase {phase} {stage}: approved={}",
202 response.approved
203 );
204 Ok(path)
205 }
206
207 pub fn reap(
219 project_root: &Path,
220 phase: PhaseId,
221 stage: Stage,
222 note: &str,
223 responded_by: &str,
224 ) -> Result<PathBuf, GateError> {
225 let response = GateResponse {
226 approved: false,
227 note: Some(note.to_string()),
228 responded_by: Some(responded_by.to_string()),
229 };
230 Self::respond(project_root, phase, stage, &response)
231 }
232
233 pub fn write_gate(
235 project_root: &Path,
236 phase: PhaseId,
237 stage: Stage,
238 context: &str,
239 ) -> Result<PathBuf, GateError> {
240 let gate = GateFile {
241 phase,
242 stage,
243 context: context.to_string(),
244 timestamp: unix_now(),
245 };
246 let path = Self::gate_path(project_root, phase, stage);
247 info!("writing gate {} for phase {phase}", stage);
248 write_atomic(&path, &serde_json::to_string_pretty(&gate)?)?;
249 Ok(path)
250 }
251
252 pub fn poll_response(
256 project_root: &Path,
257 phase: PhaseId,
258 stage: Stage,
259 timeout_secs: u64,
260 ) -> Option<GateResponse> {
261 let path = Self::response_path(project_root, phase, stage);
262 let deadline = Duration::from_secs(timeout_secs);
263 let mut waited = Duration::ZERO;
264 let mut backoff = Duration::from_secs(1);
265 let cap = Duration::from_secs(60);
266 debug!("polling for gate response at {}", path.display());
267 loop {
268 if let Ok(contents) = std::fs::read_to_string(&path)
269 && let Ok(response) = serde_json::from_str::<GateResponse>(&contents)
270 {
271 return Some(response);
272 }
273 if waited >= deadline {
274 return None;
275 }
276 let sleep = backoff.min(deadline - waited);
277 std::thread::sleep(sleep);
278 waited += sleep;
279 backoff = (backoff * 2).min(cap);
280 }
281 }
282
283 pub fn ack(project_root: &Path, phase: PhaseId, stage: Stage) -> Result<PathBuf, GateError> {
285 let path = Self::ack_path(project_root, phase, stage);
286 write_atomic(
287 &path,
288 &serde_json::to_string_pretty(&GateAck { received: true })?,
289 )?;
290 Ok(path)
291 }
292
293 pub fn cleanup(project_root: &Path, phase: PhaseId, stage: Stage) -> Result<(), GateError> {
295 for path in [
296 Self::gate_path(project_root, phase, stage),
297 Self::response_path(project_root, phase, stage),
298 Self::ack_path(project_root, phase, stage),
299 ] {
300 if path.exists() {
301 std::fs::remove_file(path)?;
302 }
303 }
304 Ok(())
305 }
306}
307
308pub fn fire_gate_notify(phase: PhaseId, stage: Stage, context: &str, unexpected: bool) {
316 let cmd = match std::env::var("DEVFLOW_GATE_NOTIFY_CMD") {
317 Ok(cmd) if !cmd.is_empty() => cmd,
318 _ => return,
319 };
320 run_notify_command(&cmd, phase, stage, context, unexpected);
321}
322
323fn run_notify_command(cmd: &str, phase: PhaseId, stage: Stage, context: &str, unexpected: bool) {
330 let output = Command::new("sh")
331 .arg("-c")
332 .arg(cmd)
333 .env("DEVFLOW_GATE_PHASE", phase.to_string())
334 .env("DEVFLOW_GATE_STAGE", stage.to_string())
335 .env("DEVFLOW_GATE_CONTEXT", context)
336 .env(
337 "DEVFLOW_NON_SILENT_GATE",
338 if unexpected { "1" } else { "0" },
339 )
340 .output();
341 match output {
342 Ok(out) if out.status.success() => {
343 debug!("gate notify hook ran successfully");
344 }
345 Ok(out) => warn!(
346 "gate notify hook exited with status {:?}: {}",
347 out.status.code(),
348 String::from_utf8_lossy(&out.stderr)
349 ),
350 Err(err) => warn!("gate notify hook could not be spawned: {err}"),
351 }
352}
353
354fn write_atomic(path: &Path, contents: &str) -> Result<(), GateError> {
357 if let Some(parent) = path.parent() {
358 crate::workflow::ensure_devflow_dir(parent)?;
359 }
360 let tmp = path.with_extension("tmp");
361 std::fs::write(&tmp, contents)?;
362 std::fs::rename(&tmp, path)?;
363 Ok(())
364}
365
366fn unix_now() -> String {
367 SystemTime::now()
368 .duration_since(UNIX_EPOCH)
369 .map(|d| d.as_secs().to_string())
370 .unwrap_or_else(|_| "0".to_string())
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::sync::Mutex;
377
378 static ENV_MUTEX: Mutex<()> = Mutex::new(());
382
383 #[test]
384 fn gate_file_round_trips_through_serde() {
385 let gate = GateFile {
386 phase: PhaseId::new(11),
387 stage: Stage::Validate,
388 context: "review the validation".into(),
389 timestamp: "1750000000".into(),
390 };
391 let json = serde_json::to_string(&gate).unwrap();
392 let back: GateFile = serde_json::from_str(&json).unwrap();
393 assert_eq!(gate, back);
394 }
395
396 #[test]
397 fn write_gate_creates_file_with_correct_path() {
398 let dir = tempfile::tempdir().unwrap();
399 let path = Gates::write_gate(dir.path(), PhaseId::new(11), Stage::Validate, "ctx").unwrap();
400 assert!(path.ends_with(".devflow/gates/11-validate.json"));
401 assert!(path.exists());
402 let gate: GateFile =
403 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
404 assert_eq!(gate.phase, PhaseId::new(11));
405 assert_eq!(gate.stage, Stage::Validate);
406 assert_eq!(gate.context, "ctx");
407 }
408
409 #[test]
410 fn poll_response_returns_when_file_appears() {
411 let dir = tempfile::tempdir().unwrap();
412 let response = GateResponse {
413 approved: true,
414 note: None,
415 responded_by: Some("human".into()),
416 };
417 let path = Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate);
418 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
419 std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
420
421 let got = Gates::poll_response(dir.path(), PhaseId::new(11), Stage::Validate, 1).unwrap();
422 assert_eq!(got, response);
423 }
424
425 #[test]
426 fn poll_response_returns_immediately_at_full_timeout() {
427 const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
428
429 let dir = tempfile::tempdir().unwrap();
430 let response = GateResponse {
431 approved: true,
432 note: None,
433 responded_by: Some("human".into()),
434 };
435 let path = Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate);
436 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
437 std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
438
439 let started = std::time::Instant::now();
440 let got = Gates::poll_response(dir.path(), PhaseId::new(11), Stage::Validate, SEVEN_DAYS)
441 .unwrap();
442
443 assert_eq!(got, response);
444 assert!(started.elapsed() < std::time::Duration::from_secs(5));
445 }
446
447 #[test]
448 fn poll_response_times_out_when_absent() {
449 let dir = tempfile::tempdir().unwrap();
450 assert!(Gates::poll_response(dir.path(), PhaseId::new(11), Stage::Ship, 0).is_none());
451 }
452
453 #[test]
454 fn ack_writes_received_true() {
455 let dir = tempfile::tempdir().unwrap();
456 let path = Gates::ack(dir.path(), PhaseId::new(11), Stage::Ship).unwrap();
457 let ack: GateAck = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
458 assert!(ack.received);
459 }
460
461 #[test]
462 fn cleanup_removes_all_three_files_idempotently() {
463 let dir = tempfile::tempdir().unwrap();
464 Gates::write_gate(dir.path(), PhaseId::new(11), Stage::Validate, "ctx").unwrap();
465 Gates::ack(dir.path(), PhaseId::new(11), Stage::Validate).unwrap();
466 std::fs::write(
467 Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate),
468 "{\"approved\":true}",
469 )
470 .unwrap();
471
472 Gates::cleanup(dir.path(), PhaseId::new(11), Stage::Validate).unwrap();
473 assert!(!Gates::gate_path(dir.path(), PhaseId::new(11), Stage::Validate).exists());
474 assert!(!Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate).exists());
475 assert!(!Gates::ack_path(dir.path(), PhaseId::new(11), Stage::Validate).exists());
476 Gates::cleanup(dir.path(), PhaseId::new(11), Stage::Validate).unwrap();
478 }
479
480 #[test]
483 fn list_open_shows_unanswered_gates_only() {
484 let dir = tempfile::tempdir().unwrap();
485 Gates::write_gate(dir.path(), PhaseId::new(7), Stage::Ship, "approve merge?").unwrap();
486 Gates::write_gate(dir.path(), PhaseId::new(8), Stage::Validate, "review gaps").unwrap();
487 Gates::respond(
489 dir.path(),
490 PhaseId::new(8),
491 Stage::Validate,
492 &GateResponse {
493 approved: true,
494 note: None,
495 responded_by: Some("test".into()),
496 },
497 )
498 .unwrap();
499 Gates::ack(dir.path(), PhaseId::new(8), Stage::Validate).unwrap();
500 std::fs::write(Gates::dir(dir.path()).join("junk.json"), "{nope").unwrap();
502
503 let open = Gates::list_open(dir.path());
504
505 assert_eq!(open.len(), 1);
506 assert_eq!(open[0].phase, PhaseId::new(7));
507 assert_eq!(open[0].stage, Stage::Ship);
508 assert_eq!(open[0].context, "approve merge?");
509 }
510
511 #[test]
512 fn list_open_is_empty_without_gates_dir() {
513 let dir = tempfile::tempdir().unwrap();
514 assert!(Gates::list_open(dir.path()).is_empty());
515 }
516
517 #[test]
520 fn respond_writes_a_response_poll_response_consumes() {
521 let dir = tempfile::tempdir().unwrap();
522 Gates::write_gate(dir.path(), PhaseId::new(9), Stage::Ship, "ctx").unwrap();
523 let response = GateResponse {
524 approved: false,
525 note: Some("abort: nope".into()),
526 responded_by: Some("cli".into()),
527 };
528
529 Gates::respond(dir.path(), PhaseId::new(9), Stage::Ship, &response).unwrap();
530
531 let polled = Gates::poll_response(dir.path(), PhaseId::new(9), Stage::Ship, 1).unwrap();
532 assert_eq!(polled, response);
533 assert!(matches!(
534 GateAction::from_response(&polled),
535 GateAction::Abort(_)
536 ));
537 }
538
539 #[test]
540 fn respond_refuses_when_no_gate_is_open() {
541 let dir = tempfile::tempdir().unwrap();
542 let response = GateResponse {
543 approved: true,
544 note: None,
545 responded_by: None,
546 };
547 let err = Gates::respond(dir.path(), PhaseId::new(3), Stage::Ship, &response).unwrap_err();
548 assert!(matches!(err, GateError::NoOpenGate { phase, .. } if phase == PhaseId::new(3)));
549 }
550
551 #[test]
552 fn respond_refuses_to_clobber_unconsumed_response() {
553 let dir = tempfile::tempdir().unwrap();
554 Gates::write_gate(dir.path(), PhaseId::new(4), Stage::Validate, "ctx").unwrap();
555 let response = GateResponse {
556 approved: true,
557 note: None,
558 responded_by: None,
559 };
560 Gates::respond(dir.path(), PhaseId::new(4), Stage::Validate, &response).unwrap();
561
562 let err =
563 Gates::respond(dir.path(), PhaseId::new(4), Stage::Validate, &response).unwrap_err();
564 assert!(
565 matches!(err, GateError::AlreadyResponded { phase, .. } if phase == PhaseId::new(4))
566 );
567 }
568
569 #[test]
570 fn gate_action_advances_on_approval() {
571 let response = GateResponse {
572 approved: true,
573 note: None,
574 responded_by: None,
575 };
576 assert_eq!(GateAction::from_response(&response), GateAction::Advance);
577 }
578
579 #[test]
580 fn gate_action_loops_back_on_fixable_rejection() {
581 let response = GateResponse {
582 approved: false,
583 note: Some("fix the failing test".into()),
584 responded_by: None,
585 };
586 assert_eq!(
587 GateAction::from_response(&response),
588 GateAction::LoopBack(Stage::Code)
589 );
590 }
591
592 #[test]
593 fn gate_action_aborts_when_note_says_abort() {
594 let response = GateResponse {
595 approved: false,
596 note: Some("abort: requirements changed".into()),
597 responded_by: None,
598 };
599 assert!(matches!(
600 GateAction::from_response(&response),
601 GateAction::Abort(_)
602 ));
603 }
604
605 #[test]
609 fn notify_hook_runs_configured_command() {
610 let dir = tempfile::tempdir().unwrap();
611 let sentinel = dir.path().join("sentinel");
612 let cmd = format!("touch {}", sentinel.display());
613 run_notify_command(&cmd, PhaseId::new(11), Stage::Ship, "ctx", false);
614 assert!(sentinel.exists());
615 }
616
617 #[test]
618 fn notify_hook_failure_is_fail_soft() {
619 run_notify_command("exit 1", PhaseId::new(11), Stage::Ship, "ctx", false);
622 }
623
624 #[test]
625 fn notify_hook_sets_non_silent_flag() {
626 let dir = tempfile::tempdir().unwrap();
627
628 let sentinel_unexpected = dir.path().join("unexpected");
629 let cmd_unexpected = format!(
630 "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
631 sentinel_unexpected.display()
632 );
633 run_notify_command(&cmd_unexpected, PhaseId::new(11), Stage::Code, "ctx", true);
634 assert_eq!(std::fs::read_to_string(&sentinel_unexpected).unwrap(), "1");
635
636 let sentinel_expected = dir.path().join("expected");
637 let cmd_expected = format!(
638 "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
639 sentinel_expected.display()
640 );
641 run_notify_command(&cmd_expected, PhaseId::new(11), Stage::Ship, "ctx", false);
642 assert_eq!(std::fs::read_to_string(&sentinel_expected).unwrap(), "0");
643 }
644
645 #[test]
648 fn notify_hook_unset_is_noop() {
649 let _guard = ENV_MUTEX.lock().unwrap();
650 unsafe {
653 std::env::remove_var("DEVFLOW_GATE_NOTIFY_CMD");
654 }
655 fire_gate_notify(PhaseId::new(11), Stage::Ship, "ctx", false);
657 }
658}