| 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 |