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    /// Persist board under a lane directory (JSON).
273    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    /// Compact status for `lane status` / panel surfaces.
289    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
321/// Canonical stopship-style gate pipeline (scout → implementer → reviewer → verifier → release_lead).
322pub 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        // Approve scout gate so implementer unblocks.
390        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}