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 save_to_dir(&self, dir: &Path) -> Result<PathBuf, GateError> {
274 std::fs::create_dir_all(dir).map_err(|e| GateError::Io(e.to_string()))?;
275 let path = dir.join("gates.json");
276 let json =
277 serde_json::to_string_pretty(self).map_err(|e| GateError::Serde(e.to_string()))?;
278 std::fs::write(&path, json).map_err(|e| GateError::Io(e.to_string()))?;
279 Ok(path)
280 }
281
282 pub fn load_from_dir(dir: &Path) -> Result<Self, GateError> {
283 let path = dir.join("gates.json");
284 let text = std::fs::read_to_string(&path).map_err(|e| GateError::Io(e.to_string()))?;
285 serde_json::from_str(&text).map_err(|e| GateError::Serde(e.to_string()))
286 }
287
288 pub fn status_summary(&self) -> Vec<GateStatusLine> {
290 self.gates
291 .iter()
292 .map(|(id, state)| GateStatusLine {
293 gate_id: id.clone(),
294 state: state.as_str().to_string(),
295 blocked_reason: state.blocked_reason().map(str::to_string),
296 })
297 .collect()
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct GateStatusLine {
303 pub gate_id: String,
304 pub state: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub blocked_reason: Option<String>,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Error)]
310pub enum GateError {
311 #[error("gate id must not be empty")]
312 EmptyGateId,
313 #[error("handoff lane id `{got}` does not match board lane `{expected}`")]
314 LaneMismatch { expected: String, got: String },
315 #[error("io error: {0}")]
316 Io(String),
317 #[error("serde error: {0}")]
318 Serde(String),
319}
320
321pub fn stopship_gate_pipeline() -> Vec<GateSpec> {
323 vec![
324 GateSpec {
325 id: "scout-findings".into(),
326 role: "scout".into(),
327 on: GateOn::RoleComplete,
328 gate: GateKind::Approve,
329 on_fail: GateOnFail::Block,
330 blocks_role: Some("implementer".into()),
331 max_retries: 0,
332 artifact_kind: Some("findings".into()),
333 require_explicit_verdict: false,
334 },
335 GateSpec {
336 id: "reviewer-diff".into(),
337 role: "reviewer".into(),
338 on: GateOn::RoleComplete,
339 gate: GateKind::Review,
340 on_fail: GateOnFail::Block,
341 blocks_role: Some("verifier".into()),
342 max_retries: 1,
343 artifact_kind: Some("diff_review".into()),
344 require_explicit_verdict: false,
345 },
346 GateSpec {
347 id: "verifier-suite".into(),
348 role: "verifier".into(),
349 on: GateOn::RoleComplete,
350 gate: GateKind::Verify,
351 on_fail: GateOnFail::Retry,
352 blocks_role: Some("release_lead".into()),
353 max_retries: 2,
354 artifact_kind: Some("verify_report".into()),
355 require_explicit_verdict: false,
356 },
357 ]
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use tempfile::tempdir;
364
365 #[test]
366 fn scout_handoff_passes_findings_to_implementer() {
367 let mut board = LaneGateBoard::new("lane-test");
368 let gates = stopship_gate_pipeline();
369 board.install_gates(&gates);
370
371 board
372 .record_handoff(HandoffArtifact {
373 id: "art-1".into(),
374 lane_id: "lane-test".into(),
375 from_role: "scout".into(),
376 to_role: "implementer".into(),
377 kind: "findings".into(),
378 payload: r#"{"issue":4090,"files":["app.rs"]}"#.into(),
379 created_at: "2026-07-09T00:00:00Z".into(),
380 })
381 .unwrap();
382
383 let art = board
384 .latest_handoff("scout", "implementer", "findings")
385 .expect("findings artifact");
386 assert_eq!(art.id, "art-1");
387 assert!(art.payload.contains("4090"));
388
389 let state = board.evaluate(&gates[0], GateOutcome::Pass).unwrap();
391 assert_eq!(state, GateState::Passed);
392 assert!(board.role_is_blocked(&gates, "implementer").is_none());
393 }
394
395 #[test]
396 fn reviewer_block_prevents_verifier() {
397 let mut board = LaneGateBoard::new("lane-rev");
398 let gates = stopship_gate_pipeline();
399 board.install_gates(&gates);
400
401 let state = board
402 .evaluate(
403 &gates[1],
404 GateOutcome::Fail {
405 reason: "regression in Ctrl+C path".into(),
406 },
407 )
408 .unwrap();
409 assert!(matches!(state, GateState::Blocked { .. }));
410 let blocked = board
411 .role_is_blocked(&gates, "verifier")
412 .expect("verifier blocked");
413 assert_eq!(blocked.blocked_reason(), Some("regression in Ctrl+C path"));
414 }
415
416 #[test]
417 fn verifier_retry_then_escalate() {
418 let mut board = LaneGateBoard::new("lane-ver");
419 let gates = stopship_gate_pipeline();
420 board.install_gates(&gates);
421 let verify = &gates[2];
422 assert_eq!(verify.max_retries, 2);
423
424 let s1 = board
425 .evaluate(
426 verify,
427 GateOutcome::Fail {
428 reason: "cargo test failed".into(),
429 },
430 )
431 .unwrap();
432 assert!(matches!(s1, GateState::Retrying { attempt: 1, .. }));
433
434 let s2 = board
435 .evaluate(
436 verify,
437 GateOutcome::Fail {
438 reason: "cargo test failed again".into(),
439 },
440 )
441 .unwrap();
442 assert!(matches!(s2, GateState::Retrying { attempt: 2, .. }));
443
444 let s3 = board
445 .evaluate(
446 verify,
447 GateOutcome::Fail {
448 reason: "still red".into(),
449 },
450 )
451 .unwrap();
452 assert!(matches!(s3, GateState::Escalated { .. }));
453 assert!(board.role_is_blocked(&gates, "release_lead").is_some());
454 }
455
456 #[test]
457 fn human_approve_clears_block() {
458 let mut board = LaneGateBoard::new("lane-hum");
459 let gates = stopship_gate_pipeline();
460 board.install_gates(&gates);
461 board
462 .evaluate(
463 &gates[1],
464 GateOutcome::Fail {
465 reason: "needs human".into(),
466 },
467 )
468 .unwrap();
469 assert!(board.role_is_blocked(&gates, "verifier").is_some());
470
471 let state = board
472 .evaluate(
473 &gates[1],
474 GateOutcome::HumanApprove {
475 note: "override: known flaky".into(),
476 },
477 )
478 .unwrap();
479 assert_eq!(state, GateState::Passed);
480 assert!(board.role_is_blocked(&gates, "verifier").is_none());
481 }
482
483 #[test]
484 fn board_persists_for_lane_status() {
485 let dir = tempdir().unwrap();
486 let mut board = LaneGateBoard::new("lane-persist");
487 board.install_gates(&stopship_gate_pipeline());
488 board
489 .evaluate(
490 &stopship_gate_pipeline()[0],
491 GateOutcome::Fail {
492 reason: "scout incomplete".into(),
493 },
494 )
495 .unwrap();
496 board.save_to_dir(dir.path()).unwrap();
497 let loaded = LaneGateBoard::load_from_dir(dir.path()).unwrap();
498 let summary = loaded.status_summary();
499 assert!(
500 summary
501 .iter()
502 .any(|l| l.gate_id == "scout-findings" && l.state == "blocked")
503 );
504 }
505}