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            return corrupt(row.stage.clone());
140        };
141        let consumed = &evidence[..position];
142        if row.stage_index != cursor || row.attempt != attempt_at(consumed, cursor) {
143            return corrupt(stage.name.clone());
144        }
145        match row.verdict {
146            Verdict::Pass => cursor += 1,
147            Verdict::Fail => match &stage.fail_action {
148                FailAction::Halt => {
149                    return Step::Halted {
150                        failed_stage: stage.name.clone(),
151                        reason: HaltReason::FailActionHalt,
152                    };
153                }
154                FailAction::Continue => cursor += 1,
155                FailAction::ReturnTo {
156                    stage: target,
157                    attempts,
158                } => {
159                    let Some(target_index) = flow
160                        .stages
161                        .iter()
162                        .position(|candidate| candidate.name == *target)
163                    else {
164                        return corrupt(stage.name.clone());
165                    };
166                    if returns_taken(consumed, cursor) >= *attempts {
167                        return Step::Halted {
168                            failed_stage: stage.name.clone(),
169                            reason: HaltReason::ReturnBudgetExhausted,
170                        };
171                    }
172                    cursor = target_index;
173                }
174            },
175        }
176    }
177    match flow.stages.get(cursor) {
178        Some(stage) => Step::Run {
179            stage,
180            attempt: attempt_at(evidence, cursor),
181        },
182        None => Step::Complete,
183    }
184}
185
186/// The failure that most recently sent the walk backwards, as the `(stage
187/// index, attempt)` key of its evidence row — the row a re-entered stage is
188/// being re-run *because of*.
189///
190/// Every backward edge leaves a `Fail` row on the stage that owns it, so the
191/// last such row in the log is the jump the walk is still inside. Reading it
192/// from the persisted log rather than from a live counter is what makes a
193/// re-run prompt reproducible: a resumed run derives the same trigger, and so
194/// composes the same prompt, as the daemon that first took the jump.
195pub fn return_trigger(flow: &Flow, evidence: &[StageEvidence]) -> Option<(usize, u32)> {
196    evidence
197        .iter()
198        .rev()
199        .find(|row| {
200            row.verdict == Verdict::Fail
201                && matches!(
202                    flow.stages
203                        .get(row.stage_index)
204                        .map(|stage| &stage.fail_action),
205                    Some(FailAction::ReturnTo { .. })
206                )
207        })
208        .map(|row| (row.stage_index, row.attempt))
209}
210
211fn corrupt(failed_stage: String) -> Step<'static> {
212    Step::Halted {
213        failed_stage,
214        reason: HaltReason::CorruptLog,
215    }
216}
217
218/// Which attempt the next execution of `index` is, given the rows the fold
219/// has already consumed. Every row records one completed execution of its
220/// stage, so counting them is the attempt counter; recomputing it by scan
221/// keeps the fold allocation-free and leaves nothing to fall out of step
222/// with the log.
223fn attempt_at(consumed: &[StageEvidence], index: usize) -> u32 {
224    count(consumed.iter().filter(|row| row.stage_index == index)).saturating_add(1)
225}
226
227/// How much of the backward edge leaving `index` the replay has already
228/// spent. A stage owns exactly one `fail_action`, so its outgoing edge is
229/// identified by the stage alone, and every consumed `Fail` row there is one
230/// jump the fold has taken.
231fn returns_taken(consumed: &[StageEvidence], index: usize) -> u32 {
232    count(
233        consumed
234            .iter()
235            .filter(|row| row.stage_index == index && row.verdict == Verdict::Fail),
236    )
237}
238
239fn count<'a>(rows: impl Iterator<Item = &'a StageEvidence>) -> u32 {
240    u32::try_from(rows.count()).unwrap_or(u32::MAX)
241}
242
243#[cfg(test)]
244mod tests {
245    use crate::flow::{
246        Actor, Builtin, Check, FailAction, Flow, HaltReason, Reported, Stage, StageEvidence, Step,
247        Verdict, VerdictSource, next_step, resolve_verdict, return_trigger,
248    };
249
250    fn commits() -> Check {
251        Check::Actor(Actor::Builtin(Builtin::Commits))
252    }
253
254    fn build_review_merge() -> Flow {
255        Flow {
256            name: "example".into(),
257            stages: vec![
258                Stage {
259                    name: "build".into(),
260                    action: Actor::Agent,
261                    result_check: commits(),
262                    fail_action: FailAction::Halt,
263                    ff_only: false,
264                },
265                Stage {
266                    name: "review".into(),
267                    action: Actor::Exec {
268                        cmd: vec!["true".into()],
269                    },
270                    result_check: Check::None,
271                    fail_action: FailAction::Halt,
272                    ff_only: false,
273                },
274                Stage {
275                    name: "merge".into(),
276                    action: Actor::Builtin(Builtin::Merge),
277                    result_check: Check::None,
278                    fail_action: FailAction::Halt,
279                    ff_only: false,
280                },
281            ],
282        }
283    }
284
285    /// A flow of exec stages with the given names and fail actions, built by
286    /// hand so a fold test can name an edge without spelling out YAML.
287    fn flow_with(stages: &[(&str, FailAction)]) -> Flow {
288        Flow {
289            name: "example".into(),
290            stages: stages
291                .iter()
292                .map(|(name, fail_action)| Stage {
293                    name: (*name).into(),
294                    action: Actor::Exec {
295                        cmd: vec!["true".into()],
296                    },
297                    result_check: Check::None,
298                    fail_action: fail_action.clone(),
299                    ff_only: false,
300                })
301                .collect(),
302        }
303    }
304
305    fn return_to(stage: &str, attempts: u32) -> FailAction {
306        FailAction::ReturnTo {
307            stage: stage.into(),
308            attempts,
309        }
310    }
311
312    /// One log row, keyed the way the fold reads it.
313    fn row(flow: &Flow, index: usize, attempt: u32, verdict: Verdict) -> StageEvidence {
314        StageEvidence {
315            stage: flow.stages[index].name.clone(),
316            stage_index: index,
317            attempt,
318            verdict,
319            source: VerdictSource::ExitCode,
320            reason: None,
321        }
322    }
323
324    fn pass(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
325        row(flow, index, attempt, Verdict::Pass)
326    }
327
328    fn fail(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
329        row(flow, index, attempt, Verdict::Fail)
330    }
331
332    fn passed(flow: &Flow, index: usize) -> StageEvidence {
333        pass(flow, index, 1)
334    }
335
336    fn failed(flow: &Flow, index: usize) -> StageEvidence {
337        fail(flow, index, 1)
338    }
339
340    fn run(flow: &Flow, index: usize, attempt: u32) -> Step<'_> {
341        Step::Run {
342            stage: &flow.stages[index],
343            attempt,
344        }
345    }
346
347    fn halted(stage: &str, reason: HaltReason) -> Step<'static> {
348        Step::Halted {
349            failed_stage: stage.into(),
350            reason,
351        }
352    }
353
354    #[test]
355    fn a_linear_log_of_passes_advances_one_stage_per_row() {
356        let flow = build_review_merge();
357
358        assert_eq!(next_step(&flow, &[]), run(&flow, 0, 1));
359        assert_eq!(next_step(&flow, &[passed(&flow, 0)]), run(&flow, 1, 1));
360        assert_eq!(
361            next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
362            run(&flow, 2, 1)
363        );
364    }
365
366    #[test]
367    fn next_step_is_complete_only_when_the_cursor_runs_off_the_end() {
368        let flow = build_review_merge();
369
370        assert_eq!(
371            next_step(
372                &flow,
373                &[passed(&flow, 0), passed(&flow, 1), passed(&flow, 2)]
374            ),
375            Step::Complete
376        );
377        assert_ne!(
378            next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
379            Step::Complete
380        );
381    }
382
383    #[test]
384    fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
385        let flow = build_review_merge();
386
387        let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
388
389        assert_eq!(
390            next_step(&flow, &evidence),
391            halted("review", HaltReason::FailActionHalt)
392        );
393    }
394
395    #[test]
396    fn a_continue_fail_action_advances_past_the_failure() {
397        let flow = flow_with(&[
398            ("build", FailAction::Halt),
399            ("lint", FailAction::Continue),
400            ("merge", FailAction::Halt),
401        ]);
402
403        let evidence = [passed(&flow, 0), failed(&flow, 1)];
404        assert_eq!(next_step(&flow, &evidence), run(&flow, 2, 1));
405
406        let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
407        assert_eq!(next_step(&flow, &evidence), Step::Complete);
408    }
409
410    #[test]
411    fn a_return_to_loop_converges_on_the_second_attempt() {
412        let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
413
414        let mut log = vec![pass(&flow, 0, 1), fail(&flow, 1, 1)];
415        assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
416
417        log.push(pass(&flow, 0, 2));
418        assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
419
420        log.push(pass(&flow, 1, 2));
421        assert_eq!(next_step(&flow, &log), Step::Complete);
422    }
423
424    #[test]
425    fn a_return_to_loop_halts_once_its_edge_budget_is_spent() {
426        let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 1))]);
427
428        let log = vec![
429            pass(&flow, 0, 1),
430            fail(&flow, 1, 1),
431            pass(&flow, 0, 2),
432            fail(&flow, 1, 2),
433        ];
434
435        assert_eq!(
436            next_step(&flow, &log),
437            halted("test", HaltReason::ReturnBudgetExhausted)
438        );
439    }
440
441    #[test]
442    fn distinct_backward_edges_keep_independent_budgets() {
443        let flow = flow_with(&[
444            ("build", FailAction::Halt),
445            ("test", return_to("build", 1)),
446            ("review", return_to("test", 1)),
447        ]);
448
449        let mut log = vec![
450            pass(&flow, 0, 1),
451            fail(&flow, 1, 1),
452            pass(&flow, 0, 2),
453            pass(&flow, 1, 2),
454            fail(&flow, 2, 1),
455        ];
456        assert_eq!(next_step(&flow, &log), run(&flow, 1, 3));
457
458        log.push(fail(&flow, 1, 3));
459        assert_eq!(
460            next_step(&flow, &log),
461            halted("test", HaltReason::ReturnBudgetExhausted)
462        );
463    }
464
465    #[test]
466    fn a_jump_supersedes_the_passes_recorded_inside_its_span() {
467        let flow = flow_with(&[
468            ("build", FailAction::Halt),
469            ("lint", FailAction::Halt),
470            ("test", return_to("build", 1)),
471        ]);
472
473        let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
474        assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
475
476        log.push(pass(&flow, 0, 2));
477        assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
478    }
479
480    /// A log that could not have been produced by any replay of this flow is
481    /// never reinterpreted into one that could.
482    #[test]
483    fn a_row_the_cursor_did_not_expect_is_a_corrupt_log() {
484        let flow = build_review_merge();
485
486        assert_eq!(
487            next_step(&flow, &[passed(&flow, 1)]),
488            halted("build", HaltReason::CorruptLog)
489        );
490
491        assert_eq!(
492            next_step(&flow, &[passed(&flow, 0), passed(&flow, 0)]),
493            halted("review", HaltReason::CorruptLog)
494        );
495
496        assert_eq!(
497            next_step(&flow, &[pass(&flow, 0, 2)]),
498            halted("build", HaltReason::CorruptLog)
499        );
500
501        assert_eq!(
502            next_step(
503                &flow,
504                &[
505                    passed(&flow, 0),
506                    passed(&flow, 1),
507                    passed(&flow, 2),
508                    pass(&flow, 2, 2),
509                ]
510            ),
511            halted("merge", HaltReason::CorruptLog)
512        );
513    }
514
515    /// `parse` guarantees a `return_to` names an earlier stage, but a flow
516    /// recovered from a snapshot has not been through it. An edge with
517    /// nowhere to land is a history the fold cannot re-derive, not a licence
518    /// to pick a landing site.
519    #[test]
520    fn a_return_to_with_no_such_stage_is_a_corrupt_log() {
521        let flow = flow_with(&[
522            ("build", FailAction::Halt),
523            ("test", return_to("nowhere", 1)),
524        ]);
525
526        assert_eq!(
527            next_step(&flow, &[passed(&flow, 0), failed(&flow, 1)]),
528            halted("test", HaltReason::CorruptLog)
529        );
530    }
531
532    /// A re-entered stage must be able to say *why*, and the answer is a row
533    /// in the log rather than anything the driver remembers.
534    #[test]
535    fn the_return_trigger_is_the_failure_the_walk_jumped_on() {
536        let flow = flow_with(&[
537            ("build", FailAction::Halt),
538            ("lint", FailAction::Halt),
539            ("test", return_to("build", 2)),
540        ]);
541
542        assert_eq!(return_trigger(&flow, &[]), None);
543        assert_eq!(return_trigger(&flow, &[pass(&flow, 0, 1)]), None);
544
545        assert_eq!(
546            return_trigger(&flow, &[pass(&flow, 0, 1), fail(&flow, 1, 1)]),
547            None
548        );
549
550        let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
551        assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
552
553        log.push(pass(&flow, 0, 2));
554        assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
555
556        log.extend([pass(&flow, 1, 2), fail(&flow, 2, 2)]);
557        assert_eq!(return_trigger(&flow, &log), Some((2, 2)));
558    }
559
560    #[test]
561    fn replaying_an_identical_log_yields_an_identical_step() {
562        let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
563        let log = [
564            pass(&flow, 0, 1),
565            fail(&flow, 1, 1),
566            pass(&flow, 0, 2),
567            fail(&flow, 1, 2),
568        ];
569
570        assert_eq!(next_step(&flow, &log), next_step(&flow, &log));
571        assert_eq!(next_step(&flow, &log), run(&flow, 0, 3));
572    }
573
574    #[test]
575    fn only_reported_policy_consults_reported_verdicts() {
576        assert_eq!(
577            resolve_verdict(&Check::None, Verdict::Pass, None),
578            (Verdict::Pass, VerdictSource::ExitCode, None)
579        );
580
581        let reported = Reported {
582            verdict: Verdict::Fail,
583            reason: Some("changes requested".into()),
584        };
585        assert_eq!(
586            resolve_verdict(&Check::Reported, Verdict::Pass, Some(reported)),
587            (
588                Verdict::Fail,
589                VerdictSource::Reported,
590                Some("changes requested".into())
591            )
592        );
593    }
594
595    #[test]
596    fn non_reported_policies_ignore_reports() {
597        let reported = Reported {
598            verdict: Verdict::Pass,
599            reason: Some("looks fine to me".into()),
600        };
601
602        assert_eq!(
603            resolve_verdict(&commits(), Verdict::Fail, Some(reported)),
604            (Verdict::Fail, VerdictSource::ExitCode, None)
605        );
606    }
607
608    #[test]
609    fn missing_report_is_a_failed_reported_verdict() {
610        assert_eq!(
611            resolve_verdict(&Check::Reported, Verdict::Pass, None),
612            (
613                Verdict::Fail,
614                VerdictSource::Reported,
615                Some("no verdict reported".into())
616            )
617        );
618    }
619}