1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum GateOn {
25 RoleComplete,
27 RoleStart,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum GateKind {
35 Verify,
37 Review,
39 Approve,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum GateOnFail {
47 Retry,
49 Block,
51 Escalate,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct GateSpec {
58 pub id: String,
59 pub role: String,
61 #[serde(rename = "on")]
62 pub on: GateOn,
63 pub gate: GateKind,
64 pub on_fail: GateOnFail,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub blocks_role: Option<String>,
68 #[serde(default = "default_max_retries")]
70 pub max_retries: u32,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub artifact_kind: Option<String>,
74 #[serde(default, skip_serializing_if = "is_false")]
79 pub require_explicit_verdict: bool,
80}
81
82fn default_max_retries() -> u32 {
83 1
84}
85
86fn is_false(value: &bool) -> bool {
87 !*value
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum GateState {
94 Pending,
95 Passed,
96 Blocked { reason: String },
97 Retrying { attempt: u32, reason: String },
98 Escalated { reason: String },
99}
100
101impl GateState {
102 pub fn as_str(&self) -> &'static str {
103 match self {
104 Self::Pending => "pending",
105 Self::Passed => "passed",
106 Self::Blocked { .. } => "blocked",
107 Self::Retrying { .. } => "retrying",
108 Self::Escalated { .. } => "escalated",
109 }
110 }
111
112 pub fn is_blocking(&self) -> bool {
113 matches!(
114 self,
115 Self::Blocked { .. } | Self::Escalated { .. } | Self::Retrying { .. }
116 )
117 }
118
119 pub fn blocked_reason(&self) -> Option<&str> {
120 match self {
121 Self::Blocked { reason }
122 | Self::Retrying { reason, .. }
123 | Self::Escalated { reason } => Some(reason.as_str()),
124 _ => None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum GateOutcome {
133 Pass,
134 Fail {
135 reason: String,
136 },
137 HumanApprove {
139 note: String,
140 },
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct HandoffArtifact {
146 pub id: String,
147 pub lane_id: String,
148 pub from_role: String,
150 pub to_role: String,
152 pub kind: String,
154 pub payload: String,
156 pub created_at: String,
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161pub struct LaneGateBoard {
162 pub lane_id: String,
163 #[serde(default)]
165 pub gates: BTreeMap<String, GateState>,
166 #[serde(default)]
168 pub retries: BTreeMap<String, u32>,
169 #[serde(default)]
171 pub artifacts: Vec<HandoffArtifact>,
172}
173
174impl LaneGateBoard {
175 pub fn new(lane_id: impl Into<String>) -> Self {
176 Self {
177 lane_id: lane_id.into(),
178 gates: BTreeMap::new(),
179 retries: BTreeMap::new(),
180 artifacts: Vec::new(),
181 }
182 }
183
184 pub fn install_gates(&mut self, specs: &[GateSpec]) {
186 for spec in specs {
187 self.gates
188 .entry(spec.id.clone())
189 .or_insert(GateState::Pending);
190 }
191 }
192
193 pub fn evaluate(
195 &mut self,
196 spec: &GateSpec,
197 outcome: GateOutcome,
198 ) -> Result<GateState, GateError> {
199 if spec.id.trim().is_empty() {
200 return Err(GateError::EmptyGateId);
201 }
202 match outcome {
203 GateOutcome::Pass | GateOutcome::HumanApprove { .. } => {
204 let state = GateState::Passed;
205 self.gates.insert(spec.id.clone(), state.clone());
206 self.retries.remove(&spec.id);
207 Ok(state)
208 }
209 GateOutcome::Fail { reason } => {
210 let attempt = self.retries.entry(spec.id.clone()).or_insert(0);
211 *attempt = attempt.saturating_add(1);
212 let attempt = *attempt;
213 let state = match spec.on_fail {
214 GateOnFail::Block => GateState::Blocked { reason },
215 GateOnFail::Retry if attempt <= spec.max_retries => {
216 GateState::Retrying { attempt, reason }
217 }
218 GateOnFail::Retry | GateOnFail::Escalate => GateState::Escalated { reason },
219 };
220 self.gates.insert(spec.id.clone(), state.clone());
221 Ok(state)
222 }
223 }
224 }
225
226 pub fn role_is_blocked(&self, specs: &[GateSpec], role: &str) -> Option<&GateState> {
228 for spec in specs {
229 let blocks = spec
230 .blocks_role
231 .as_deref()
232 .unwrap_or("")
233 .eq_ignore_ascii_case(role);
234 if !blocks {
235 continue;
236 }
237 if let Some(state) = self.gates.get(&spec.id)
238 && state.is_blocking()
239 {
240 return Some(state);
241 }
242 }
243 None
244 }
245
246 pub fn record_handoff(&mut self, artifact: HandoffArtifact) -> Result<(), GateError> {
248 if artifact.lane_id != self.lane_id {
249 return Err(GateError::LaneMismatch {
250 expected: self.lane_id.clone(),
251 got: artifact.lane_id,
252 });
253 }
254 self.artifacts.push(artifact);
255 Ok(())
256 }
257
258 pub fn latest_handoff(
260 &self,
261 from_role: &str,
262 to_role: &str,
263 kind: &str,
264 ) -> Option<&HandoffArtifact> {
265 self.artifacts.iter().rev().find(|a| {
266 a.from_role.eq_ignore_ascii_case(from_role)
267 && a.to_role.eq_ignore_ascii_case(to_role)
268 && a.kind.eq_ignore_ascii_case(kind)
269 })
270 }
271
272 pub fn consume_handoffs_for(&mut self, to_role: &str, limit: usize) -> Vec<HandoffArtifact> {
280 let mut picked: Vec<usize> = Vec::new();
283 for (index, artifact) in self.artifacts.iter().enumerate().rev() {
284 if picked.len() >= limit {
285 break;
286 }
287 if artifact.to_role.eq_ignore_ascii_case(to_role) {
288 picked.push(index);
289 }
290 }
291 let mut consumed = Vec::with_capacity(picked.len());
292 for index in picked {
293 consumed.push(self.artifacts.remove(index));
294 }
295 consumed
296 }
297
298 pub fn save_to_dir(&self, dir: &Path) -> Result<PathBuf, GateError> {
300 std::fs::create_dir_all(dir).map_err(|e| GateError::Io(e.to_string()))?;
301 let path = dir.join("gates.json");
302 let json =
303 serde_json::to_string_pretty(self).map_err(|e| GateError::Serde(e.to_string()))?;
304 std::fs::write(&path, json).map_err(|e| GateError::Io(e.to_string()))?;
305 Ok(path)
306 }
307
308 pub fn load_from_dir(dir: &Path) -> Result<Self, GateError> {
309 let path = dir.join("gates.json");
310 let text = std::fs::read_to_string(&path).map_err(|e| GateError::Io(e.to_string()))?;
311 serde_json::from_str(&text).map_err(|e| GateError::Serde(e.to_string()))
312 }
313
314 pub fn status_summary(&self) -> Vec<GateStatusLine> {
316 self.gates
317 .iter()
318 .map(|(id, state)| GateStatusLine {
319 gate_id: id.clone(),
320 state: state.as_str().to_string(),
321 blocked_reason: state.blocked_reason().map(str::to_string),
322 })
323 .collect()
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct GateStatusLine {
329 pub gate_id: String,
330 pub state: String,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub blocked_reason: Option<String>,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Error)]
336pub enum GateError {
337 #[error("gate id must not be empty")]
338 EmptyGateId,
339 #[error("handoff lane id `{got}` does not match board lane `{expected}`")]
340 LaneMismatch { expected: String, got: String },
341 #[error("io error: {0}")]
342 Io(String),
343 #[error("serde error: {0}")]
344 Serde(String),
345}
346
347pub fn stopship_gate_pipeline() -> Vec<GateSpec> {
349 vec![
350 GateSpec {
351 id: "scout-findings".into(),
352 role: "scout".into(),
353 on: GateOn::RoleComplete,
354 gate: GateKind::Approve,
355 on_fail: GateOnFail::Block,
356 blocks_role: Some("implementer".into()),
357 max_retries: 0,
358 artifact_kind: Some("findings".into()),
359 require_explicit_verdict: false,
360 },
361 GateSpec {
362 id: "reviewer-diff".into(),
363 role: "reviewer".into(),
364 on: GateOn::RoleComplete,
365 gate: GateKind::Review,
366 on_fail: GateOnFail::Block,
367 blocks_role: Some("verifier".into()),
368 max_retries: 1,
369 artifact_kind: Some("diff_review".into()),
370 require_explicit_verdict: false,
371 },
372 GateSpec {
373 id: "verifier-suite".into(),
374 role: "verifier".into(),
375 on: GateOn::RoleComplete,
376 gate: GateKind::Verify,
377 on_fail: GateOnFail::Retry,
378 blocks_role: Some("release_lead".into()),
379 max_retries: 2,
380 artifact_kind: Some("verify_report".into()),
381 require_explicit_verdict: false,
382 },
383 ]
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use tempfile::tempdir;
390
391 #[test]
392 fn scout_handoff_passes_findings_to_implementer() {
393 let mut board = LaneGateBoard::new("lane-test");
394 let gates = stopship_gate_pipeline();
395 board.install_gates(&gates);
396
397 board
398 .record_handoff(HandoffArtifact {
399 id: "art-1".into(),
400 lane_id: "lane-test".into(),
401 from_role: "scout".into(),
402 to_role: "implementer".into(),
403 kind: "findings".into(),
404 payload: r#"{"issue":4090,"files":["app.rs"]}"#.into(),
405 created_at: "2026-07-09T00:00:00Z".into(),
406 })
407 .unwrap();
408
409 let art = board
410 .latest_handoff("scout", "implementer", "findings")
411 .expect("findings artifact");
412 assert_eq!(art.id, "art-1");
413 assert!(art.payload.contains("4090"));
414
415 let state = board.evaluate(&gates[0], GateOutcome::Pass).unwrap();
417 assert_eq!(state, GateState::Passed);
418 assert!(board.role_is_blocked(&gates, "implementer").is_none());
419 }
420
421 #[test]
422 fn handoff_is_consumed_exactly_once() {
423 let mut board = LaneGateBoard::new("lane-consume");
424 let record = |board: &mut LaneGateBoard, id: &str, to_role: &str, payload: &str| {
425 board
426 .record_handoff(HandoffArtifact {
427 id: id.into(),
428 lane_id: "lane-consume".into(),
429 from_role: "scout".into(),
430 to_role: to_role.into(),
431 kind: "findings".into(),
432 payload: payload.into(),
433 created_at: "2026-08-03T00:00:00Z".into(),
434 })
435 .unwrap();
436 };
437 record(&mut board, "art-old", "implementer", "first");
438 record(&mut board, "art-new", "implementer", "second");
439 record(&mut board, "art-other", "reviewer", "untouched");
440
441 let consumed = board.consume_handoffs_for("implementer", 4);
442 assert_eq!(consumed.len(), 2);
443 assert_eq!(consumed[0].id, "art-new", "newest handoff delivers first");
444 assert_eq!(consumed[1].id, "art-old");
445
446 assert!(
447 board.consume_handoffs_for("implementer", 4).is_empty(),
448 "a consumed handoff must not be delivered again"
449 );
450 assert_eq!(
451 board.artifacts.len(),
452 1,
453 "handoffs for other roles stay on the board"
454 );
455 assert_eq!(board.artifacts[0].id, "art-other");
456 }
457
458 #[test]
459 fn reviewer_block_prevents_verifier() {
460 let mut board = LaneGateBoard::new("lane-rev");
461 let gates = stopship_gate_pipeline();
462 board.install_gates(&gates);
463
464 let state = board
465 .evaluate(
466 &gates[1],
467 GateOutcome::Fail {
468 reason: "regression in Ctrl+C path".into(),
469 },
470 )
471 .unwrap();
472 assert!(matches!(state, GateState::Blocked { .. }));
473 let blocked = board
474 .role_is_blocked(&gates, "verifier")
475 .expect("verifier blocked");
476 assert_eq!(blocked.blocked_reason(), Some("regression in Ctrl+C path"));
477 }
478
479 #[test]
480 fn verifier_retry_then_escalate() {
481 let mut board = LaneGateBoard::new("lane-ver");
482 let gates = stopship_gate_pipeline();
483 board.install_gates(&gates);
484 let verify = &gates[2];
485 assert_eq!(verify.max_retries, 2);
486
487 let s1 = board
488 .evaluate(
489 verify,
490 GateOutcome::Fail {
491 reason: "cargo test failed".into(),
492 },
493 )
494 .unwrap();
495 assert!(matches!(s1, GateState::Retrying { attempt: 1, .. }));
496
497 let s2 = board
498 .evaluate(
499 verify,
500 GateOutcome::Fail {
501 reason: "cargo test failed again".into(),
502 },
503 )
504 .unwrap();
505 assert!(matches!(s2, GateState::Retrying { attempt: 2, .. }));
506
507 let s3 = board
508 .evaluate(
509 verify,
510 GateOutcome::Fail {
511 reason: "still red".into(),
512 },
513 )
514 .unwrap();
515 assert!(matches!(s3, GateState::Escalated { .. }));
516 assert!(board.role_is_blocked(&gates, "release_lead").is_some());
517 }
518
519 #[test]
520 fn human_approve_clears_block() {
521 let mut board = LaneGateBoard::new("lane-hum");
522 let gates = stopship_gate_pipeline();
523 board.install_gates(&gates);
524 board
525 .evaluate(
526 &gates[1],
527 GateOutcome::Fail {
528 reason: "needs human".into(),
529 },
530 )
531 .unwrap();
532 assert!(board.role_is_blocked(&gates, "verifier").is_some());
533
534 let state = board
535 .evaluate(
536 &gates[1],
537 GateOutcome::HumanApprove {
538 note: "override: known flaky".into(),
539 },
540 )
541 .unwrap();
542 assert_eq!(state, GateState::Passed);
543 assert!(board.role_is_blocked(&gates, "verifier").is_none());
544 }
545
546 #[test]
547 fn board_persists_for_lane_status() {
548 let dir = tempdir().unwrap();
549 let mut board = LaneGateBoard::new("lane-persist");
550 board.install_gates(&stopship_gate_pipeline());
551 board
552 .evaluate(
553 &stopship_gate_pipeline()[0],
554 GateOutcome::Fail {
555 reason: "scout incomplete".into(),
556 },
557 )
558 .unwrap();
559 board.save_to_dir(dir.path()).unwrap();
560 let loaded = LaneGateBoard::load_from_dir(dir.path()).unwrap();
561 let summary = loaded.status_summary();
562 assert!(
563 summary
564 .iter()
565 .any(|l| l.gate_id == "scout-findings" && l.state == "blocked")
566 );
567 }
568}