Skip to main content

codewhale_workflow/
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
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use 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")]
24pub 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")]
34pub 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")]
46pub 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)]
57pub 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
82fn default_max_retries() -> u32 {
83    1
84}
85
86fn 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")]
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/// 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")]
132pub 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)]
145pub 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)]
161pub 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
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    /// 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)]
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
347/// Canonical stopship-style gate pipeline (scout → implementer → reviewer → verifier → release_lead).
348pub 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        // 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}