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 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 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 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 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
275pub 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
290fn 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
321fn 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 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 Gates::cleanup(dir.path(), 11, Stage::Validate).unwrap();
444 }
445
446 #[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 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 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 #[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 #[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 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 #[test]
611 fn notify_hook_unset_is_noop() {
612 let _guard = ENV_MUTEX.lock().unwrap();
613 unsafe {
616 std::env::remove_var("DEVFLOW_GATE_NOTIFY_CMD");
617 }
618 fire_gate_notify(11, Stage::Ship, "ctx", false);
620 }
621}