Skip to main content

sloop/flow/
walk.rs

1//! The replay: a left fold over a run's ordered evidence log that derives
2//! where the walk stands. It reads exactly three things from the flow —
3//! `Flow::stages`, `Stage::name`, and `Stage::fail_action` — and touches no
4//! clock, process, or store, so the same log always yields the same step.
5
6use super::{Check, FailAction, Flow, Stage};
7
8/// A stage's pass/fail reading. Richer verdicts (e.g. `changes-requested`)
9/// are a later phase; v1 is strictly binary.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Verdict {
12    Pass,
13    Fail,
14}
15
16/// Where a stage's verdict came from.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum VerdictSource {
19    /// The stage process's own exit status: 0 is `Pass`, anything else is
20    /// `Fail`.
21    ExitCode,
22    /// A worker called `sloop verdict` over its stage's socket.
23    Reported,
24    /// A panel's reviewers reported and [`aggregate`](super::aggregate)
25    /// counted them.
26    Panel,
27}
28
29impl VerdictSource {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            Self::ExitCode => "exit_code",
33            Self::Reported => "reported",
34            Self::Panel => "panel",
35        }
36    }
37}
38
39/// A worker's self-reported verdict for the stage it is running, gated to
40/// at most one report per stage.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Reported {
43    pub verdict: Verdict,
44    pub reason: Option<String>,
45}
46
47/// One stage execution's recorded result, and one entry in a run's evidence
48/// log. Rows persist as they are produced and callers must supply them in
49/// log order, because the walk is a replay of that order rather than a
50/// lookup over a set: the same stage may hold several rows once a
51/// `return_to` edge sends the walk back through it.
52///
53/// `stage_index` and `attempt` are the log key and are authoritative;
54/// `stage` is carried for rendering only. A daemon crash mid-flow resumes
55/// idempotently because `next_step` re-derives the same position from the
56/// same rows.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct StageEvidence {
59    pub stage: String,
60    pub stage_index: usize,
61    /// 1 for a stage's first execution, incrementing each time a `return_to`
62    /// edge re-enters it.
63    pub attempt: u32,
64    pub verdict: Verdict,
65    pub source: VerdictSource,
66    pub reason: Option<String>,
67}
68
69/// Resolves a stage's verdict, source, and reason from the evidence selected
70/// by its result check. Reports are authoritative only for `Reported`; every
71/// other check ignores them.
72pub fn resolve_verdict(
73    check: &Check,
74    exit: Verdict,
75    reported: Option<Reported>,
76) -> (Verdict, VerdictSource, Option<String>) {
77    if *check != Check::Reported {
78        return (exit, VerdictSource::ExitCode, None);
79    }
80    match reported {
81        Some(reported) => (reported.verdict, VerdictSource::Reported, reported.reason),
82        None => (
83            Verdict::Fail,
84            VerdictSource::Reported,
85            Some("no verdict reported".into()),
86        ),
87    }
88}
89
90/// What the walk does next, given a flow and the evidence log so far.
91#[derive(Debug, PartialEq, Eq)]
92pub enum Step<'a> {
93    /// Where the replay stands with the log exhausted: this stage's
94    /// `attempt`-th execution is the one that has not been recorded yet.
95    Run { stage: &'a Stage, attempt: u32 },
96    /// The replay stopped short of the end of the flow. Stages after it are
97    /// never requested.
98    Halted {
99        failed_stage: String,
100        reason: HaltReason,
101    },
102    /// The cursor walked off the end of the flow.
103    Complete,
104}
105
106/// Why a replay stopped short of the end of the flow.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum HaltReason {
109    /// The stage failed and its `fail_action` is `Halt`.
110    FailActionHalt,
111    /// The stage failed with a `return_to`, but that edge's attempt budget
112    /// was already spent earlier in this same replay.
113    ReturnBudgetExhausted,
114    /// The log does not describe a walk over this flow: a row arrived for a
115    /// stage or attempt the replay was not standing on, or a `return_to`
116    /// named a stage the bound flow no longer holds. Recovery treats this as
117    /// needs-review rather than guessing at a history it cannot re-derive.
118    CorruptLog,
119}
120
121/// The pure decision at the heart of a flow: a left fold over the run's
122/// ordered evidence log. A cursor starts at stage 0 and consumes rows in
123/// sequence: a `Pass` advances it, a `Fail` applies the failed stage's
124/// `fail_action` — `Halt` stops, `Continue` advances, `ReturnTo` moves the
125/// cursor back if that edge's budget is not yet spent. Wherever the cursor
126/// stands once the log is exhausted is what runs next.
127///
128/// Position is therefore always recomputed from history; no stored cursor
129/// exists to disagree with it. Because the fold reads only the rows — never
130/// a clock, a process, or the store — resuming after a crash with the same
131/// log yields the same `Step`: the walk is idempotent by construction.
132///
133/// Rows must arrive in log order. A row for any other stage or attempt than
134/// the one the cursor expects is a corrupt log, not a hint to re-order.
135pub fn next_step<'a>(flow: &'a Flow, evidence: &[StageEvidence]) -> Step<'a> {
136    let mut cursor = 0usize;
137    for (position, row) in evidence.iter().enumerate() {
138        let Some(stage) = flow.stages.get(cursor) else {
139            // The walk already ran off the end of the flow, so no execution
140            // could have produced this row.
141            return corrupt(row.stage.clone());
142        };
143        let consumed = &evidence[..position];
144        if row.stage_index != cursor || row.attempt != attempt_at(consumed, cursor) {
145            return corrupt(stage.name.clone());
146        }
147        match row.verdict {
148            Verdict::Pass => cursor += 1,
149            Verdict::Fail => match &stage.fail_action {
150                FailAction::Halt => {
151                    return Step::Halted {
152                        failed_stage: stage.name.clone(),
153                        reason: HaltReason::FailActionHalt,
154                    };
155                }
156                FailAction::Continue => cursor += 1,
157                FailAction::ReturnTo {
158                    stage: target,
159                    attempts,
160                } => {
161                    let Some(target_index) = flow
162                        .stages
163                        .iter()
164                        .position(|candidate| candidate.name == *target)
165                    else {
166                        return corrupt(stage.name.clone());
167                    };
168                    if returns_taken(consumed, cursor) >= *attempts {
169                        return Step::Halted {
170                            failed_stage: stage.name.clone(),
171                            reason: HaltReason::ReturnBudgetExhausted,
172                        };
173                    }
174                    // Replay resumes from the target; the rows appended
175                    // before the jump are behind the fold and superseded by
176                    // whatever the re-run records.
177                    cursor = target_index;
178                }
179            },
180        }
181    }
182    match flow.stages.get(cursor) {
183        Some(stage) => Step::Run {
184            stage,
185            attempt: attempt_at(evidence, cursor),
186        },
187        None => Step::Complete,
188    }
189}
190
191/// The failure that most recently sent the walk backwards, as the `(stage
192/// index, attempt)` key of its evidence row — the row a re-entered stage is
193/// being re-run *because of*.
194///
195/// Every backward edge leaves a `Fail` row on the stage that owns it, so the
196/// last such row in the log is the jump the walk is still inside. Reading it
197/// from the persisted log rather than from a live counter is what makes a
198/// re-run prompt reproducible: a resumed run derives the same trigger, and so
199/// composes the same prompt, as the daemon that first took the jump.
200pub fn return_trigger(flow: &Flow, evidence: &[StageEvidence]) -> Option<(usize, u32)> {
201    evidence
202        .iter()
203        .rev()
204        .find(|row| {
205            row.verdict == Verdict::Fail
206                && matches!(
207                    flow.stages
208                        .get(row.stage_index)
209                        .map(|stage| &stage.fail_action),
210                    Some(FailAction::ReturnTo { .. })
211                )
212        })
213        .map(|row| (row.stage_index, row.attempt))
214}
215
216fn corrupt(failed_stage: String) -> Step<'static> {
217    Step::Halted {
218        failed_stage,
219        reason: HaltReason::CorruptLog,
220    }
221}
222
223/// Which attempt the next execution of `index` is, given the rows the fold
224/// has already consumed. Every row records one completed execution of its
225/// stage, so counting them is the attempt counter; recomputing it by scan
226/// keeps the fold allocation-free and leaves nothing to fall out of step
227/// with the log.
228fn attempt_at(consumed: &[StageEvidence], index: usize) -> u32 {
229    count(consumed.iter().filter(|row| row.stage_index == index)).saturating_add(1)
230}
231
232/// How much of the backward edge leaving `index` the replay has already
233/// spent. A stage owns exactly one `fail_action`, so its outgoing edge is
234/// identified by the stage alone, and every consumed `Fail` row there is one
235/// jump the fold has taken.
236fn returns_taken(consumed: &[StageEvidence], index: usize) -> u32 {
237    count(
238        consumed
239            .iter()
240            .filter(|row| row.stage_index == index && row.verdict == Verdict::Fail),
241    )
242}
243
244fn count<'a>(rows: impl Iterator<Item = &'a StageEvidence>) -> u32 {
245    u32::try_from(rows.count()).unwrap_or(u32::MAX)
246}
247
248#[cfg(test)]
249mod tests {
250    use crate::flow::{
251        Actor, Builtin, Check, FailAction, Flow, HaltReason, Reported, Stage, StageEvidence, Step,
252        Verdict, VerdictSource, next_step, resolve_verdict, return_trigger,
253    };
254
255    fn commits() -> Check {
256        Check::Actor(Actor::Builtin(Builtin::Commits))
257    }
258
259    fn build_review_merge() -> Flow {
260        Flow {
261            name: "example".into(),
262            stages: vec![
263                Stage {
264                    name: "build".into(),
265                    action: Actor::Agent,
266                    result_check: commits(),
267                    fail_action: FailAction::Halt,
268                    ff_only: false,
269                },
270                Stage {
271                    name: "review".into(),
272                    action: Actor::Exec {
273                        cmd: vec!["true".into()],
274                    },
275                    result_check: Check::None,
276                    fail_action: FailAction::Halt,
277                    ff_only: false,
278                },
279                Stage {
280                    name: "merge".into(),
281                    action: Actor::Builtin(Builtin::Merge),
282                    result_check: Check::None,
283                    fail_action: FailAction::Halt,
284                    ff_only: false,
285                },
286            ],
287        }
288    }
289
290    /// A flow of exec stages with the given names and fail actions, built by
291    /// hand so a fold test can name an edge without spelling out YAML.
292    fn flow_with(stages: &[(&str, FailAction)]) -> Flow {
293        Flow {
294            name: "example".into(),
295            stages: stages
296                .iter()
297                .map(|(name, fail_action)| Stage {
298                    name: (*name).into(),
299                    action: Actor::Exec {
300                        cmd: vec!["true".into()],
301                    },
302                    result_check: Check::None,
303                    fail_action: fail_action.clone(),
304                    ff_only: false,
305                })
306                .collect(),
307        }
308    }
309
310    fn return_to(stage: &str, attempts: u32) -> FailAction {
311        FailAction::ReturnTo {
312            stage: stage.into(),
313            attempts,
314        }
315    }
316
317    /// One log row, keyed the way the fold reads it.
318    fn row(flow: &Flow, index: usize, attempt: u32, verdict: Verdict) -> StageEvidence {
319        StageEvidence {
320            stage: flow.stages[index].name.clone(),
321            stage_index: index,
322            attempt,
323            verdict,
324            source: VerdictSource::ExitCode,
325            reason: None,
326        }
327    }
328
329    fn pass(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
330        row(flow, index, attempt, Verdict::Pass)
331    }
332
333    fn fail(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
334        row(flow, index, attempt, Verdict::Fail)
335    }
336
337    fn passed(flow: &Flow, index: usize) -> StageEvidence {
338        pass(flow, index, 1)
339    }
340
341    fn failed(flow: &Flow, index: usize) -> StageEvidence {
342        fail(flow, index, 1)
343    }
344
345    fn run(flow: &Flow, index: usize, attempt: u32) -> Step<'_> {
346        Step::Run {
347            stage: &flow.stages[index],
348            attempt,
349        }
350    }
351
352    fn halted(stage: &str, reason: HaltReason) -> Step<'static> {
353        Step::Halted {
354            failed_stage: stage.into(),
355            reason,
356        }
357    }
358
359    #[test]
360    fn a_linear_log_of_passes_advances_one_stage_per_row() {
361        let flow = build_review_merge();
362
363        assert_eq!(next_step(&flow, &[]), run(&flow, 0, 1));
364        assert_eq!(next_step(&flow, &[passed(&flow, 0)]), run(&flow, 1, 1));
365        assert_eq!(
366            next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
367            run(&flow, 2, 1)
368        );
369    }
370
371    #[test]
372    fn next_step_is_complete_only_when_the_cursor_runs_off_the_end() {
373        let flow = build_review_merge();
374
375        assert_eq!(
376            next_step(
377                &flow,
378                &[passed(&flow, 0), passed(&flow, 1), passed(&flow, 2)]
379            ),
380            Step::Complete
381        );
382        assert_ne!(
383            next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
384            Step::Complete
385        );
386    }
387
388    #[test]
389    fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
390        let flow = build_review_merge();
391
392        // The fold stops at `review`, so a `merge` row could only be a
393        // corrupt appendix — stages after a halting failure are never
394        // requested, and evidence claiming otherwise is never believed.
395        let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
396
397        assert_eq!(
398            next_step(&flow, &evidence),
399            halted("review", HaltReason::FailActionHalt)
400        );
401    }
402
403    #[test]
404    fn a_continue_fail_action_advances_past_the_failure() {
405        let flow = flow_with(&[
406            ("build", FailAction::Halt),
407            ("lint", FailAction::Continue),
408            ("merge", FailAction::Halt),
409        ]);
410
411        let evidence = [passed(&flow, 0), failed(&flow, 1)];
412        assert_eq!(next_step(&flow, &evidence), run(&flow, 2, 1));
413
414        let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
415        assert_eq!(next_step(&flow, &evidence), Step::Complete);
416    }
417
418    #[test]
419    fn a_return_to_loop_converges_on_the_second_attempt() {
420        let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
421
422        // The failure sends the cursor back to `build`, which is now on its
423        // second execution.
424        let mut log = vec![pass(&flow, 0, 1), fail(&flow, 1, 1)];
425        assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
426
427        log.push(pass(&flow, 0, 2));
428        assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
429
430        log.push(pass(&flow, 1, 2));
431        assert_eq!(next_step(&flow, &log), Step::Complete);
432    }
433
434    #[test]
435    fn a_return_to_loop_halts_once_its_edge_budget_is_spent() {
436        let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 1))]);
437
438        let log = vec![
439            pass(&flow, 0, 1),
440            fail(&flow, 1, 1),
441            pass(&flow, 0, 2),
442            fail(&flow, 1, 2),
443        ];
444
445        assert_eq!(
446            next_step(&flow, &log),
447            halted("test", HaltReason::ReturnBudgetExhausted)
448        );
449    }
450
451    #[test]
452    fn distinct_backward_edges_keep_independent_budgets() {
453        // `test` and `review` each own one backward edge with one attempt.
454        let flow = flow_with(&[
455            ("build", FailAction::Halt),
456            ("test", return_to("build", 1)),
457            ("review", return_to("test", 1)),
458        ]);
459
460        // `test`'s edge is spent, but that says nothing about `review`'s:
461        // its own failure still jumps, re-entering `test` for a third time.
462        let mut log = vec![
463            pass(&flow, 0, 1),
464            fail(&flow, 1, 1),
465            pass(&flow, 0, 2),
466            pass(&flow, 1, 2),
467            fail(&flow, 2, 1),
468        ];
469        assert_eq!(next_step(&flow, &log), run(&flow, 1, 3));
470
471        // And the reverse: `review`'s untouched budget never refills
472        // `test`'s, which is still exhausted.
473        log.push(fail(&flow, 1, 3));
474        assert_eq!(
475            next_step(&flow, &log),
476            halted("test", HaltReason::ReturnBudgetExhausted)
477        );
478    }
479
480    #[test]
481    fn a_jump_supersedes_the_passes_recorded_inside_its_span() {
482        let flow = flow_with(&[
483            ("build", FailAction::Halt),
484            ("lint", FailAction::Halt),
485            ("test", return_to("build", 1)),
486        ]);
487
488        // `lint` passed before the jump, but the jump puts that row behind
489        // the fold: the span re-runs whole, so `lint` is requested again.
490        let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
491        assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
492
493        log.push(pass(&flow, 0, 2));
494        assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
495    }
496
497    /// A log that could not have been produced by any replay of this flow is
498    /// never reinterpreted into one that could.
499    #[test]
500    fn a_row_the_cursor_did_not_expect_is_a_corrupt_log() {
501        let flow = build_review_merge();
502
503        // A row for a stage the cursor is not standing on.
504        assert_eq!(
505            next_step(&flow, &[passed(&flow, 1)]),
506            halted("build", HaltReason::CorruptLog)
507        );
508
509        // The same row twice: the second arrives with the cursor already
510        // past it.
511        assert_eq!(
512            next_step(&flow, &[passed(&flow, 0), passed(&flow, 0)]),
513            halted("review", HaltReason::CorruptLog)
514        );
515
516        // The right stage on the wrong attempt.
517        assert_eq!(
518            next_step(&flow, &[pass(&flow, 0, 2)]),
519            halted("build", HaltReason::CorruptLog)
520        );
521
522        // A row appended after the walk already ran off the end.
523        assert_eq!(
524            next_step(
525                &flow,
526                &[
527                    passed(&flow, 0),
528                    passed(&flow, 1),
529                    passed(&flow, 2),
530                    pass(&flow, 2, 2),
531                ]
532            ),
533            halted("merge", HaltReason::CorruptLog)
534        );
535    }
536
537    /// `parse` guarantees a `return_to` names an earlier stage, but a flow
538    /// recovered from a snapshot has not been through it. An edge with
539    /// nowhere to land is a history the fold cannot re-derive, not a licence
540    /// to pick a landing site.
541    #[test]
542    fn a_return_to_with_no_such_stage_is_a_corrupt_log() {
543        let flow = flow_with(&[
544            ("build", FailAction::Halt),
545            ("test", return_to("nowhere", 1)),
546        ]);
547
548        assert_eq!(
549            next_step(&flow, &[passed(&flow, 0), failed(&flow, 1)]),
550            halted("test", HaltReason::CorruptLog)
551        );
552    }
553
554    /// A re-entered stage must be able to say *why*, and the answer is a row
555    /// in the log rather than anything the driver remembers.
556    #[test]
557    fn the_return_trigger_is_the_failure_the_walk_jumped_on() {
558        let flow = flow_with(&[
559            ("build", FailAction::Halt),
560            ("lint", FailAction::Halt),
561            ("test", return_to("build", 2)),
562        ]);
563
564        // Nothing has jumped yet, so nothing triggered a re-entry.
565        assert_eq!(return_trigger(&flow, &[]), None);
566        assert_eq!(return_trigger(&flow, &[pass(&flow, 0, 1)]), None);
567
568        // `lint` fails with a halting edge: a failure, but not a jump.
569        assert_eq!(
570            return_trigger(&flow, &[pass(&flow, 0, 1), fail(&flow, 1, 1)]),
571            None
572        );
573
574        let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
575        assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
576
577        // The whole span re-runs, and every stage inside it is re-entered
578        // because of the same failure.
579        log.push(pass(&flow, 0, 2));
580        assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
581
582        // A second jump supersedes the first.
583        log.extend([pass(&flow, 1, 2), fail(&flow, 2, 2)]);
584        assert_eq!(return_trigger(&flow, &log), Some((2, 2)));
585    }
586
587    #[test]
588    fn replaying_an_identical_log_yields_an_identical_step() {
589        let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
590        let log = [
591            pass(&flow, 0, 1),
592            fail(&flow, 1, 1),
593            pass(&flow, 0, 2),
594            fail(&flow, 1, 2),
595        ];
596
597        assert_eq!(next_step(&flow, &log), next_step(&flow, &log));
598        assert_eq!(next_step(&flow, &log), run(&flow, 0, 3));
599    }
600
601    #[test]
602    fn only_reported_policy_consults_reported_verdicts() {
603        assert_eq!(
604            resolve_verdict(&Check::None, Verdict::Pass, None),
605            (Verdict::Pass, VerdictSource::ExitCode, None)
606        );
607
608        let reported = Reported {
609            verdict: Verdict::Fail,
610            reason: Some("changes requested".into()),
611        };
612        assert_eq!(
613            resolve_verdict(&Check::Reported, Verdict::Pass, Some(reported)),
614            (
615                Verdict::Fail,
616                VerdictSource::Reported,
617                Some("changes requested".into())
618            )
619        );
620    }
621
622    #[test]
623    fn non_reported_policies_ignore_reports() {
624        let reported = Reported {
625            verdict: Verdict::Pass,
626            reason: Some("looks fine to me".into()),
627        };
628
629        assert_eq!(
630            resolve_verdict(&commits(), Verdict::Fail, Some(reported)),
631            (Verdict::Fail, VerdictSource::ExitCode, None)
632        );
633    }
634
635    #[test]
636    fn missing_report_is_a_failed_reported_verdict() {
637        assert_eq!(
638            resolve_verdict(&Check::Reported, Verdict::Pass, None),
639            (
640                Verdict::Fail,
641                VerdictSource::Reported,
642                Some("no verdict reported".into())
643            )
644        );
645    }
646}