1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct GateFile {
25 pub phase: u32,
27 pub stage: Stage,
29 pub context: String,
31 pub timestamp: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct GateResponse {
38 pub approved: bool,
40 #[serde(default)]
42 pub note: Option<String>,
43 #[serde(default)]
45 pub responded_by: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct GateAck {
51 pub received: bool,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum GateAction {
58 Advance,
60 LoopBack(Stage),
62 Abort(String),
64}
65
66impl GateAction {
67 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#[derive(Debug, thiserror::Error)]
84pub enum GateError {
85 #[error("gate I/O failed: {0}")]
87 Io(#[from] std::io::Error),
88 #[error("gate JSON failed: {0}")]
90 Json(#[from] serde_json::Error),
91 #[error("no open gate for phase {phase} stage {stage} — see `devflow gate list`")]
93 NoOpenGate { phase: u32, stage: Stage },
94 #[error("gate for phase {phase} stage {stage} already has a response awaiting pickup")]
96 AlreadyResponded { phase: u32, stage: Stage },
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct OpenGate {
102 pub phase: u32,
104 pub stage: Stage,
106 pub context: String,
108 pub timestamp: String,
110}
111
112pub struct Gates;
114
115impl Gates {
116 pub fn dir(project_root: &Path) -> PathBuf {
118 project_root.join(".devflow").join("gates")
119 }
120
121 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 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 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 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 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 pub fn reap(
212 project_root: &Path,
213 phase: u32,
214 stage: Stage,
215 note: &str,
216 responded_by: &str,
217 ) -> Result<PathBuf, GateError> {
218 let response = GateResponse {
219 approved: false,
220 note: Some(note.to_string()),
221 responded_by: Some(responded_by.to_string()),
222 };
223 Self::respond(project_root, phase, stage, &response)
224 }
225
226 pub fn write_gate(
228 project_root: &Path,
229 phase: u32,
230 stage: Stage,
231 context: &str,
232 ) -> Result<PathBuf, GateError> {
233 let gate = GateFile {
234 phase,
235 stage,
236 context: context.to_string(),
237 timestamp: unix_now(),
238 };
239 let path = Self::gate_path(project_root, phase, stage);
240 info!("writing gate {} for phase {phase}", stage);
241 write_atomic(&path, &serde_json::to_string_pretty(&gate)?)?;
242 Ok(path)
243 }
244
245 pub fn poll_response(
249 project_root: &Path,
250 phase: u32,
251 stage: Stage,
252 timeout_secs: u64,
253 ) -> Option<GateResponse> {
254 let path = Self::response_path(project_root, phase, stage);
255 let deadline = Duration::from_secs(timeout_secs);
256 let mut waited = Duration::ZERO;
257 let mut backoff = Duration::from_secs(1);
258 let cap = Duration::from_secs(60);
259 debug!("polling for gate response at {}", path.display());
260 loop {
261 if let Ok(contents) = std::fs::read_to_string(&path)
262 && let Ok(response) = serde_json::from_str::<GateResponse>(&contents)
263 {
264 return Some(response);
265 }
266 if waited >= deadline {
267 return None;
268 }
269 let sleep = backoff.min(deadline - waited);
270 std::thread::sleep(sleep);
271 waited += sleep;
272 backoff = (backoff * 2).min(cap);
273 }
274 }
275
276 pub fn ack(project_root: &Path, phase: u32, stage: Stage) -> Result<PathBuf, GateError> {
278 let path = Self::ack_path(project_root, phase, stage);
279 write_atomic(
280 &path,
281 &serde_json::to_string_pretty(&GateAck { received: true })?,
282 )?;
283 Ok(path)
284 }
285
286 pub fn cleanup(project_root: &Path, phase: u32, stage: Stage) -> Result<(), GateError> {
288 for path in [
289 Self::gate_path(project_root, phase, stage),
290 Self::response_path(project_root, phase, stage),
291 Self::ack_path(project_root, phase, stage),
292 ] {
293 if path.exists() {
294 std::fs::remove_file(path)?;
295 }
296 }
297 Ok(())
298 }
299}
300
301pub fn fire_gate_notify(phase: u32, stage: Stage, context: &str, unexpected: bool) {
309 let cmd = match std::env::var("DEVFLOW_GATE_NOTIFY_CMD") {
310 Ok(cmd) if !cmd.is_empty() => cmd,
311 _ => return,
312 };
313 run_notify_command(&cmd, phase, stage, context, unexpected);
314}
315
316fn run_notify_command(cmd: &str, phase: u32, stage: Stage, context: &str, unexpected: bool) {
323 let output = Command::new("sh")
324 .arg("-c")
325 .arg(cmd)
326 .env("DEVFLOW_GATE_PHASE", phase.to_string())
327 .env("DEVFLOW_GATE_STAGE", stage.to_string())
328 .env("DEVFLOW_GATE_CONTEXT", context)
329 .env(
330 "DEVFLOW_NON_SILENT_GATE",
331 if unexpected { "1" } else { "0" },
332 )
333 .output();
334 match output {
335 Ok(out) if out.status.success() => {
336 debug!("gate notify hook ran successfully");
337 }
338 Ok(out) => warn!(
339 "gate notify hook exited with status {:?}: {}",
340 out.status.code(),
341 String::from_utf8_lossy(&out.stderr)
342 ),
343 Err(err) => warn!("gate notify hook could not be spawned: {err}"),
344 }
345}
346
347fn write_atomic(path: &Path, contents: &str) -> Result<(), GateError> {
350 if let Some(parent) = path.parent() {
351 crate::workflow::ensure_devflow_dir(parent)?;
352 }
353 let tmp = path.with_extension("tmp");
354 std::fs::write(&tmp, contents)?;
355 std::fs::rename(&tmp, path)?;
356 Ok(())
357}
358
359fn unix_now() -> String {
360 SystemTime::now()
361 .duration_since(UNIX_EPOCH)
362 .map(|d| d.as_secs().to_string())
363 .unwrap_or_else(|_| "0".to_string())
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use std::sync::Mutex;
370
371 static ENV_MUTEX: Mutex<()> = Mutex::new(());
375
376 #[test]
377 fn gate_file_round_trips_through_serde() {
378 let gate = GateFile {
379 phase: 11,
380 stage: Stage::Validate,
381 context: "review the validation".into(),
382 timestamp: "1750000000".into(),
383 };
384 let json = serde_json::to_string(&gate).unwrap();
385 let back: GateFile = serde_json::from_str(&json).unwrap();
386 assert_eq!(gate, back);
387 }
388
389 #[test]
390 fn write_gate_creates_file_with_correct_path() {
391 let dir = tempfile::tempdir().unwrap();
392 let path = Gates::write_gate(dir.path(), 11, Stage::Validate, "ctx").unwrap();
393 assert!(path.ends_with(".devflow/gates/11-validate.json"));
394 assert!(path.exists());
395 let gate: GateFile =
396 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
397 assert_eq!(gate.phase, 11);
398 assert_eq!(gate.stage, Stage::Validate);
399 assert_eq!(gate.context, "ctx");
400 }
401
402 #[test]
403 fn poll_response_returns_when_file_appears() {
404 let dir = tempfile::tempdir().unwrap();
405 let response = GateResponse {
406 approved: true,
407 note: None,
408 responded_by: Some("human".into()),
409 };
410 let path = Gates::response_path(dir.path(), 11, Stage::Validate);
411 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
412 std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
413
414 let got = Gates::poll_response(dir.path(), 11, Stage::Validate, 1).unwrap();
415 assert_eq!(got, response);
416 }
417
418 #[test]
419 fn poll_response_returns_immediately_at_full_timeout() {
420 const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
421
422 let dir = tempfile::tempdir().unwrap();
423 let response = GateResponse {
424 approved: true,
425 note: None,
426 responded_by: Some("human".into()),
427 };
428 let path = Gates::response_path(dir.path(), 11, Stage::Validate);
429 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
430 std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
431
432 let started = std::time::Instant::now();
433 let got = Gates::poll_response(dir.path(), 11, Stage::Validate, SEVEN_DAYS).unwrap();
434
435 assert_eq!(got, response);
436 assert!(started.elapsed() < std::time::Duration::from_secs(5));
437 }
438
439 #[test]
440 fn poll_response_times_out_when_absent() {
441 let dir = tempfile::tempdir().unwrap();
442 assert!(Gates::poll_response(dir.path(), 11, Stage::Ship, 0).is_none());
443 }
444
445 #[test]
446 fn ack_writes_received_true() {
447 let dir = tempfile::tempdir().unwrap();
448 let path = Gates::ack(dir.path(), 11, Stage::Ship).unwrap();
449 let ack: GateAck = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
450 assert!(ack.received);
451 }
452
453 #[test]
454 fn cleanup_removes_all_three_files_idempotently() {
455 let dir = tempfile::tempdir().unwrap();
456 Gates::write_gate(dir.path(), 11, Stage::Validate, "ctx").unwrap();
457 Gates::ack(dir.path(), 11, Stage::Validate).unwrap();
458 std::fs::write(
459 Gates::response_path(dir.path(), 11, Stage::Validate),
460 "{\"approved\":true}",
461 )
462 .unwrap();
463
464 Gates::cleanup(dir.path(), 11, Stage::Validate).unwrap();
465 assert!(!Gates::gate_path(dir.path(), 11, Stage::Validate).exists());
466 assert!(!Gates::response_path(dir.path(), 11, Stage::Validate).exists());
467 assert!(!Gates::ack_path(dir.path(), 11, Stage::Validate).exists());
468 Gates::cleanup(dir.path(), 11, Stage::Validate).unwrap();
470 }
471
472 #[test]
475 fn list_open_shows_unanswered_gates_only() {
476 let dir = tempfile::tempdir().unwrap();
477 Gates::write_gate(dir.path(), 7, Stage::Ship, "approve merge?").unwrap();
478 Gates::write_gate(dir.path(), 8, Stage::Validate, "review gaps").unwrap();
479 Gates::respond(
481 dir.path(),
482 8,
483 Stage::Validate,
484 &GateResponse {
485 approved: true,
486 note: None,
487 responded_by: Some("test".into()),
488 },
489 )
490 .unwrap();
491 Gates::ack(dir.path(), 8, Stage::Validate).unwrap();
492 std::fs::write(Gates::dir(dir.path()).join("junk.json"), "{nope").unwrap();
494
495 let open = Gates::list_open(dir.path());
496
497 assert_eq!(open.len(), 1);
498 assert_eq!(open[0].phase, 7);
499 assert_eq!(open[0].stage, Stage::Ship);
500 assert_eq!(open[0].context, "approve merge?");
501 }
502
503 #[test]
504 fn list_open_is_empty_without_gates_dir() {
505 let dir = tempfile::tempdir().unwrap();
506 assert!(Gates::list_open(dir.path()).is_empty());
507 }
508
509 #[test]
512 fn respond_writes_a_response_poll_response_consumes() {
513 let dir = tempfile::tempdir().unwrap();
514 Gates::write_gate(dir.path(), 9, Stage::Ship, "ctx").unwrap();
515 let response = GateResponse {
516 approved: false,
517 note: Some("abort: nope".into()),
518 responded_by: Some("cli".into()),
519 };
520
521 Gates::respond(dir.path(), 9, Stage::Ship, &response).unwrap();
522
523 let polled = Gates::poll_response(dir.path(), 9, Stage::Ship, 1).unwrap();
524 assert_eq!(polled, response);
525 assert!(matches!(
526 GateAction::from_response(&polled),
527 GateAction::Abort(_)
528 ));
529 }
530
531 #[test]
532 fn respond_refuses_when_no_gate_is_open() {
533 let dir = tempfile::tempdir().unwrap();
534 let response = GateResponse {
535 approved: true,
536 note: None,
537 responded_by: None,
538 };
539 let err = Gates::respond(dir.path(), 3, Stage::Ship, &response).unwrap_err();
540 assert!(matches!(err, GateError::NoOpenGate { phase: 3, .. }));
541 }
542
543 #[test]
544 fn respond_refuses_to_clobber_unconsumed_response() {
545 let dir = tempfile::tempdir().unwrap();
546 Gates::write_gate(dir.path(), 4, Stage::Validate, "ctx").unwrap();
547 let response = GateResponse {
548 approved: true,
549 note: None,
550 responded_by: None,
551 };
552 Gates::respond(dir.path(), 4, Stage::Validate, &response).unwrap();
553
554 let err = Gates::respond(dir.path(), 4, Stage::Validate, &response).unwrap_err();
555 assert!(matches!(err, GateError::AlreadyResponded { phase: 4, .. }));
556 }
557
558 #[test]
559 fn gate_action_advances_on_approval() {
560 let response = GateResponse {
561 approved: true,
562 note: None,
563 responded_by: None,
564 };
565 assert_eq!(GateAction::from_response(&response), GateAction::Advance);
566 }
567
568 #[test]
569 fn gate_action_loops_back_on_fixable_rejection() {
570 let response = GateResponse {
571 approved: false,
572 note: Some("fix the failing test".into()),
573 responded_by: None,
574 };
575 assert_eq!(
576 GateAction::from_response(&response),
577 GateAction::LoopBack(Stage::Code)
578 );
579 }
580
581 #[test]
582 fn gate_action_aborts_when_note_says_abort() {
583 let response = GateResponse {
584 approved: false,
585 note: Some("abort: requirements changed".into()),
586 responded_by: None,
587 };
588 assert!(matches!(
589 GateAction::from_response(&response),
590 GateAction::Abort(_)
591 ));
592 }
593
594 #[test]
598 fn notify_hook_runs_configured_command() {
599 let dir = tempfile::tempdir().unwrap();
600 let sentinel = dir.path().join("sentinel");
601 let cmd = format!("touch {}", sentinel.display());
602 run_notify_command(&cmd, 11, Stage::Ship, "ctx", false);
603 assert!(sentinel.exists());
604 }
605
606 #[test]
607 fn notify_hook_failure_is_fail_soft() {
608 run_notify_command("exit 1", 11, Stage::Ship, "ctx", false);
611 }
612
613 #[test]
614 fn notify_hook_sets_non_silent_flag() {
615 let dir = tempfile::tempdir().unwrap();
616
617 let sentinel_unexpected = dir.path().join("unexpected");
618 let cmd_unexpected = format!(
619 "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
620 sentinel_unexpected.display()
621 );
622 run_notify_command(&cmd_unexpected, 11, Stage::Code, "ctx", true);
623 assert_eq!(std::fs::read_to_string(&sentinel_unexpected).unwrap(), "1");
624
625 let sentinel_expected = dir.path().join("expected");
626 let cmd_expected = format!(
627 "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
628 sentinel_expected.display()
629 );
630 run_notify_command(&cmd_expected, 11, Stage::Ship, "ctx", false);
631 assert_eq!(std::fs::read_to_string(&sentinel_expected).unwrap(), "0");
632 }
633
634 #[test]
637 fn notify_hook_unset_is_noop() {
638 let _guard = ENV_MUTEX.lock().unwrap();
639 unsafe {
642 std::env::remove_var("DEVFLOW_GATE_NOTIFY_CMD");
643 }
644 fire_gate_notify(11, Stage::Ship, "ctx", false);
646 }
647}