返回 CodeWhale
gates.rs
根目录 / crates / workflow / src / gates.rs
1 //! Workflow gate nodes and role-to-role handoffs (#4179).
2 //!
3 //! Gates live in the Workflow definition; Fleet only supplies roles.
4 //! Handoff artifacts are **lane-scoped** (keyed by lane id), never fleet-scoped.
5 //!
6 //! Gate semantics:
7 //! - **block** — downstream role cannot start until the gate passes or a human
8 //! override approves
9 //! - **approve** — promote an artifact into the next role's context substrate
10 //! - **escalate** — after N retries, surface to parent / lane status
11 //!
12 //! This module is pure IR + evaluation. Runtime execution and Lane status UI
13 //! wire in later; unit tests cover block/approve/retry/escalate paths.
14
15 use std::collections::BTreeMap;
16 use std::path::{Path, PathBuf};
17
18 use serde::{Deserialize, Serialize};
19 use thiserror::Error;
20
21 /// When a gate fires relative to role lifecycle.
22 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23 #[serde(rename_all = "snake_case")]
24 pub enum GateOn {
25 /// After a fleet role task completes successfully.
26 RoleComplete,
27 /// Before a fleet role is allowed to start.
28 RoleStart,
29 }
30
31 /// Kind of verification / review gate.
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33 #[serde(rename_all = "snake_case")]
34 pub enum GateKind {
35 /// Compile/test/lint suite (verifier role; #4013).
36 Verify,
37 /// Diff review (reviewer role).
38 Review,
39 /// Explicit human/operator approve.
40 Approve,
41 }
42
43 /// Policy when a gate fails.
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45 #[serde(rename_all = "snake_case")]
46 pub enum GateOnFail {
47 /// Re-run the upstream role (up to `max_retries`).
48 Retry,
49 /// Block downstream until resolved.
50 Block,
51 /// Surface to parent / lane status after retries exhausted.
52 Escalate,
53 }
54
55 /// One gate node in a Workflow definition.
56 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57 pub struct GateSpec {
58 pub id: String,
59 /// Role whose completion (or start) triggers this gate.
60 pub role: String,
61 #[serde(rename = "on")]
62 pub on: GateOn,
63 pub gate: GateKind,
64 pub on_fail: GateOnFail,
65 /// Downstream role blocked until this gate passes.
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub blocks_role: Option<String>,
68 /// Max retries before escalate (default 1).
69 #[serde(default = "default_max_retries")]
70 pub max_retries: u32,
71 /// Optional artifact type this gate produces/consumes (e.g. `findings`).
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub artifact_kind: Option<String>,
74 /// Require a standalone first-line PASS/APPROVE/BLOCK/FAIL verdict from a
75 /// successfully completed role. When enabled, missing or malformed
76 /// verdicts fail closed instead of inheriting legacy pass-on-success
77 /// behavior.
78 #[serde(default, skip_serializing_if = "is_false")]
79 pub require_explicit_verdict: bool,
80 }
81
82 fn default_max_retries() -> u32 {
83 1
84 }
85
86 fn is_false(value: &bool) -> bool {
87 !*value
88 }
89
90 /// Live state of one gate within a lane.
91 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92 #[serde(rename_all = "snake_case")]
93 pub enum GateState {
94 Pending,
95 Passed,
96 Blocked { reason: String },
97 Retrying { attempt: u32, reason: String },
98 Escalated { reason: String },
99 }
100
101 impl 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 /// Outcome reported by a verifier/reviewer/human for a gate evaluation.
130 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131 #[serde(rename_all = "snake_case")]
132 pub enum GateOutcome {
133 Pass,
134 Fail {
135 reason: String,
136 },
137 /// Explicit human override that clears a block.
138 HumanApprove {
139 note: String,
140 },
141 }
142
143 /// Lane-scoped handoff artifact produced by a role for the next role.
144 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145 pub struct HandoffArtifact {
146 pub id: String,
147 pub lane_id: String,
148 /// Producing fleet role (e.g. `scout`).
149 pub from_role: String,
150 /// Consuming fleet role (e.g. `implementer`).
151 pub to_role: String,
152 /// Artifact kind (`findings`, `diff`, `verify_report`, …).
153 pub kind: String,
154 /// Opaque payload (JSON text or path reference).
155 pub payload: String,
156 pub created_at: String,
157 }
158
159 /// In-memory (and serializable) gate + handoff store for one lane.
160 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161 pub struct LaneGateBoard {
162 pub lane_id: String,
163 /// Gate id → current state.
164 #[serde(default)]
165 pub gates: BTreeMap<String, GateState>,
166 /// Retry counters per gate id.
167 #[serde(default)]
168 pub retries: BTreeMap<String, u32>,
169 /// Handoff artifacts for this lane.
170 #[serde(default)]
171 pub artifacts: Vec<HandoffArtifact>,
172 }
173
174 impl 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 /// Register gate specs in pending state.
185 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 /// Evaluate a gate against an outcome; updates board state.
194 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 /// Whether `role` is currently blocked by any gate that targets it.
227 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 /// Record a scout→implementer (etc.) handoff artifact.
247 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 /// Latest handoff of `kind` from `from_role` to `to_role`, if any.
259 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 /// Remove and return the latest handoffs addressed to `to_role` (newest
273 /// first), up to `limit`.
274 ///
275 /// Handoffs are single-use: the consumer that receives one is also issued
276 /// a `HandoffConsumed` receipt, so leaving the artifact on the board would
277 /// re-deliver it to every later same-role task — stale evidence, repeated
278 /// token cost, and a receipt that lies about being spent.
279 pub fn consume_handoffs_for(&mut self, to_role: &str, limit: usize) -> Vec<HandoffArtifact> {
280 // Collect matching indices newest-first; removing in descending index
281 // order keeps every not-yet-removed index valid.
282 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 /// Persist board under a lane directory (JSON).
299 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 /// Compact status for `lane status` / panel surfaces.
315 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)]
328 pub 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)]
336 pub 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
347 /// Canonical stopship-style gate pipeline (scout → implementer → reviewer → verifier → release_lead).
348 pub 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)]
387 mod 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 // Approve scout gate so implementer unblocks.
416 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 }
569
569 lines RUST