Skip to main content

sloop/
flow.rs

1//! Flow definitions and the pure walk over them. Parsing turns a committed
2//! YAML file into a validated `Flow`; `next_step` then turns a flow and the
3//! evidence gathered so far into the next stage to run or a terminal
4//! reading. Neither half touches a clock, a process, or the store, so
5//! policy can be tested without a daemon.
6
7use std::collections::HashSet;
8
9use serde::Deserialize;
10
11pub const DEFAULT_FLOW_NAME: &str = "default";
12pub const REVIEW_PROMPT_PATH: &str = ".agents/sloop/prompts/review.md";
13pub const REVIEW_PROMPT_INSTRUCTION: &str = "Review the completed work for correctness and regressions. Run relevant tests, then report a pass or fail verdict with a concise reason.";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Flow {
17    pub name: String,
18    pub stages: Vec<Stage>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Stage {
23    pub name: String,
24    pub kind: StageKind,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum StageKind {
29    Build,
30    Merge,
31    Exec { cmd: Vec<String> },
32}
33
34pub(crate) fn parse(name: &str, contents: &str) -> Result<Flow, String> {
35    let file: RawFlowFile = serde_yaml::from_str(contents).map_err(|error| error.to_string())?;
36    let raw_stages = match file {
37        RawFlowFile::List(stages) => stages,
38        RawFlowFile::Map { stages } => stages,
39    };
40
41    let mut stages = Vec::with_capacity(raw_stages.len());
42    let mut names = HashSet::new();
43    for raw in raw_stages {
44        if !names.insert(raw.name.clone()) {
45            return Err(format!("duplicate stage name `{}`", raw.name));
46        }
47        let kind = match raw.kind.as_str() {
48            "build" => {
49                if raw.cmd.is_some() {
50                    return Err(format!("build stage `{}` must not define `cmd`", raw.name));
51                }
52                StageKind::Build
53            }
54            "merge" => {
55                if raw.cmd.is_some() {
56                    return Err(format!("merge stage `{}` must not define `cmd`", raw.name));
57                }
58                StageKind::Merge
59            }
60            "exec" => {
61                let cmd = raw.cmd.unwrap_or_default();
62                if cmd.is_empty() {
63                    return Err(format!(
64                        "exec stage `{}` must define a non-empty `cmd`",
65                        raw.name
66                    ));
67                }
68                StageKind::Exec { cmd }
69            }
70            kind => return Err(format!("stage `{}` has unknown kind `{kind}`", raw.name)),
71        };
72        stages.push(Stage {
73            name: raw.name,
74            kind,
75        });
76    }
77
78    validate_order(&stages)?;
79    Ok(Flow {
80        name: name.to_owned(),
81        stages,
82    })
83}
84
85pub(crate) fn built_in_default(
86    default_agent_cmd: Option<&[String]>,
87    test_cmd: Option<&[String]>,
88) -> Flow {
89    let review_cmd = default_agent_cmd
90        .map(|cmd| {
91            cmd.iter()
92                .map(|argument| argument.replace("{prompt}", REVIEW_PROMPT_INSTRUCTION))
93                .collect()
94        })
95        // Dispatch is already disabled without an agent target. Keep the
96        // resolved flow structurally valid for status and post-gate consumers.
97        .unwrap_or_else(|| vec!["sloop-agent".into(), REVIEW_PROMPT_INSTRUCTION.into()]);
98
99    let mut stages = vec![
100        Stage {
101            name: "build".into(),
102            kind: StageKind::Build,
103        },
104        Stage {
105            name: "review".into(),
106            kind: StageKind::Exec { cmd: review_cmd },
107        },
108    ];
109    if let Some(cmd) = test_cmd {
110        stages.push(Stage {
111            name: "test".into(),
112            kind: StageKind::Exec { cmd: cmd.to_vec() },
113        });
114    }
115    stages.push(Stage {
116        name: "merge".into(),
117        kind: StageKind::Merge,
118    });
119
120    Flow {
121        name: DEFAULT_FLOW_NAME.into(),
122        stages,
123    }
124}
125
126fn validate_order(stages: &[Stage]) -> Result<(), String> {
127    let build_count = stages
128        .iter()
129        .filter(|stage| stage.kind == StageKind::Build)
130        .count();
131    if build_count != 1 {
132        return Err(format!(
133            "flow must contain exactly one build stage; found {build_count}"
134        ));
135    }
136    if stages.first().map(|stage| &stage.kind) != Some(&StageKind::Build) {
137        return Err("build stage must be first".into());
138    }
139
140    let merge_count = stages
141        .iter()
142        .filter(|stage| stage.kind == StageKind::Merge)
143        .count();
144    if merge_count > 1 {
145        return Err(format!(
146            "flow may contain at most one merge stage; found {merge_count}"
147        ));
148    }
149    if merge_count == 1 && stages.last().map(|stage| &stage.kind) != Some(&StageKind::Merge) {
150        return Err("merge stage must be last".into());
151    }
152    Ok(())
153}
154
155/// A stage's pass/fail reading. Richer verdicts (e.g. `changes-requested`)
156/// are a later phase; v1 is strictly binary.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum Verdict {
159    Pass,
160    Fail,
161}
162
163/// Where a stage's verdict came from.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum VerdictSource {
166    /// The stage process's own exit status: 0 is `Pass`, anything else is
167    /// `Fail`.
168    ExitCode,
169    /// A worker called `sloop verdict` over its stage's socket.
170    Reported,
171}
172
173/// A worker's self-reported verdict for the stage it is running, gated to
174/// at most one report per stage.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct Reported {
177    pub verdict: Verdict,
178    pub reason: Option<String>,
179}
180
181/// One stage's recorded result. Rows persist as they are produced, so a
182/// daemon crash mid-flow resumes idempotently at the first stage without a
183/// row: `next_step` re-derives the same answer from the same rows.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct StageEvidence {
186    pub stage: String,
187    pub verdict: Verdict,
188    pub source: VerdictSource,
189    pub reason: Option<String>,
190}
191
192/// Resolves a stage's verdict, source, and reason from its exit-code
193/// reading and an optional reported verdict.
194///
195/// Policy: a reported verdict is authoritative and overrides the exit code,
196/// because agentic stages (e.g. review) exit 0 regardless of their
197/// judgment — the CLI ran; that says nothing about the verdict. `build` is
198/// the one exception: its evidence rule (exit 0 and commits > 0) is fixed
199/// and not negotiable by the worker, so a reported verdict is ignored
200/// entirely when the stage kind is `Build`.
201pub fn resolve_verdict(
202    kind: &StageKind,
203    exit: Verdict,
204    reported: Option<Reported>,
205) -> (Verdict, VerdictSource, Option<String>) {
206    if *kind != StageKind::Build {
207        if let Some(reported) = reported {
208            return (reported.verdict, VerdictSource::Reported, reported.reason);
209        }
210    }
211    (exit, VerdictSource::ExitCode, None)
212}
213
214/// What the walk does next, given a flow and its evidence so far.
215#[derive(Debug, PartialEq, Eq)]
216pub enum Step<'a> {
217    /// The first stage without an evidence row; every row before it is
218    /// `Pass`.
219    Run(&'a Stage),
220    /// Some row is `Fail`; the walk stops there. Stages after it are never
221    /// requested.
222    Halted { failed_stage: String },
223    /// Every stage has a `Pass` row.
224    Complete,
225}
226
227/// The pure decision at the heart of a flow: given the flow and the
228/// evidence recorded so far, what runs next. Linear and halt-on-fail, with
229/// no notion of loops, branches, or retries (see `sloop-flows.md` §4).
230///
231/// Because this only reads persisted evidence rows and never a clock or a
232/// process, resuming after a crash with the same rows yields the same
233/// `Step`: the walk is idempotent by construction.
234pub fn next_step<'a>(flow: &'a Flow, evidence: &[StageEvidence]) -> Step<'a> {
235    for stage in &flow.stages {
236        match evidence.iter().find(|row| row.stage == stage.name) {
237            None => return Step::Run(stage),
238            Some(row) if row.verdict == Verdict::Pass => continue,
239            Some(row) => {
240                return Step::Halted {
241                    failed_stage: row.stage.clone(),
242                };
243            }
244        }
245    }
246    Step::Complete
247}
248
249#[derive(Debug, Deserialize)]
250#[serde(untagged)]
251enum RawFlowFile {
252    List(Vec<RawStage>),
253    Map { stages: Vec<RawStage> },
254}
255
256#[derive(Debug, Deserialize)]
257struct RawStage {
258    name: String,
259    kind: String,
260    cmd: Option<Vec<String>>,
261}
262
263#[cfg(test)]
264mod tests {
265    use super::{
266        Flow, Reported, Stage, StageEvidence, StageKind, Step, Verdict, VerdictSource, next_step,
267        parse, resolve_verdict,
268    };
269
270    fn error(yaml: &str) -> String {
271        parse("example", yaml).unwrap_err()
272    }
273
274    #[test]
275    fn valid_multi_stage_flow_parses_in_order() {
276        let flow = parse(
277            "release",
278            "stages:\n  - name: build\n    kind: build\n  - name: test\n    kind: exec\n    cmd: [cargo, test]\n  - name: merge\n    kind: merge\n",
279        )
280        .unwrap();
281
282        assert_eq!(
283            flow,
284            Flow {
285                name: "release".into(),
286                stages: vec![
287                    Stage {
288                        name: "build".into(),
289                        kind: StageKind::Build,
290                    },
291                    Stage {
292                        name: "test".into(),
293                        kind: StageKind::Exec {
294                            cmd: vec!["cargo".into(), "test".into()],
295                        },
296                    },
297                    Stage {
298                        name: "merge".into(),
299                        kind: StageKind::Merge,
300                    },
301                ],
302            }
303        );
304    }
305
306    #[test]
307    fn unknown_kinds_are_rejected() {
308        let error = error("- { name: build, kind: build }\n- { name: deploy, kind: magic }\n");
309        assert!(error.contains("unknown kind `magic`"), "{error}");
310    }
311
312    #[test]
313    fn duplicate_stage_names_are_rejected() {
314        let error = error("- { name: build, kind: build }\n- { name: build, kind: merge }\n");
315        assert!(error.contains("duplicate stage name `build`"), "{error}");
316    }
317
318    #[test]
319    fn exactly_one_build_stage_is_required() {
320        let missing = error("- { name: check, kind: exec, cmd: ['true'] }\n");
321        assert!(missing.contains("exactly one build stage"), "{missing}");
322
323        let duplicate = error("- { name: build, kind: build }\n- { name: rebuild, kind: build }\n");
324        assert!(duplicate.contains("exactly one build stage"), "{duplicate}");
325    }
326
327    #[test]
328    fn build_stage_must_be_first() {
329        let error =
330            error("- { name: check, kind: exec, cmd: ['true'] }\n- { name: build, kind: build }\n");
331        assert!(error.contains("build stage must be first"), "{error}");
332    }
333
334    #[test]
335    fn at_most_one_merge_stage_is_allowed() {
336        let error = error(
337            "- { name: build, kind: build }\n- { name: merge-one, kind: merge }\n- { name: merge-two, kind: merge }\n",
338        );
339        assert!(error.contains("at most one merge stage"), "{error}");
340    }
341
342    #[test]
343    fn merge_stage_must_be_last() {
344        let error = error(
345            "- { name: build, kind: build }\n- { name: merge, kind: merge }\n- { name: check, kind: exec, cmd: ['true'] }\n",
346        );
347        assert!(error.contains("merge stage must be last"), "{error}");
348    }
349
350    #[test]
351    fn exec_stage_command_must_be_nonempty() {
352        for yaml in [
353            "- { name: build, kind: build }\n- { name: check, kind: exec }\n",
354            "- { name: build, kind: build }\n- { name: check, kind: exec, cmd: [] }\n",
355        ] {
356            let error = error(yaml);
357            assert!(error.contains("non-empty `cmd`"), "{error}");
358        }
359    }
360
361    fn build_review_merge() -> Flow {
362        Flow {
363            name: "example".into(),
364            stages: vec![
365                Stage {
366                    name: "build".into(),
367                    kind: StageKind::Build,
368                },
369                Stage {
370                    name: "review".into(),
371                    kind: StageKind::Exec {
372                        cmd: vec!["true".into()],
373                    },
374                },
375                Stage {
376                    name: "merge".into(),
377                    kind: StageKind::Merge,
378                },
379            ],
380        }
381    }
382
383    fn passed(stage: &str) -> StageEvidence {
384        StageEvidence {
385            stage: stage.into(),
386            verdict: Verdict::Pass,
387            source: VerdictSource::ExitCode,
388            reason: None,
389        }
390    }
391
392    fn failed(stage: &str) -> StageEvidence {
393        StageEvidence {
394            stage: stage.into(),
395            verdict: Verdict::Fail,
396            source: VerdictSource::ExitCode,
397            reason: None,
398        }
399    }
400
401    #[test]
402    fn next_step_selects_the_first_stage_without_a_row() {
403        let flow = build_review_merge();
404
405        assert_eq!(next_step(&flow, &[]), Step::Run(&flow.stages[0]));
406        assert_eq!(
407            next_step(&flow, &[passed("build")]),
408            Step::Run(&flow.stages[1])
409        );
410        assert_eq!(
411            next_step(&flow, &[passed("build"), passed("review")]),
412            Step::Run(&flow.stages[2])
413        );
414    }
415
416    #[test]
417    fn next_step_is_complete_only_when_every_stage_passed() {
418        let flow = build_review_merge();
419
420        assert_eq!(
421            next_step(&flow, &[passed("build"), passed("review"), passed("merge")]),
422            Step::Complete
423        );
424        assert_ne!(
425            next_step(&flow, &[passed("build"), passed("review")]),
426            Step::Complete
427        );
428    }
429
430    #[test]
431    fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
432        let flow = build_review_merge();
433
434        // A `merge` row is present despite `review` failing first; the walk
435        // must still halt at `review`, proving stages after a failure are
436        // never requested even if evidence for them exists.
437        let evidence = [passed("build"), failed("review"), passed("merge")];
438
439        assert_eq!(
440            next_step(&flow, &evidence),
441            Step::Halted {
442                failed_stage: "review".into()
443            }
444        );
445    }
446
447    #[test]
448    fn resuming_with_identical_evidence_yields_an_identical_step() {
449        let flow = build_review_merge();
450        let evidence = [passed("build")];
451
452        assert_eq!(next_step(&flow, &evidence), next_step(&flow, &evidence));
453    }
454
455    #[test]
456    fn reported_verdicts_override_exit_code_for_ordinary_stages() {
457        let exec = StageKind::Exec {
458            cmd: vec!["true".into()],
459        };
460
461        assert_eq!(
462            resolve_verdict(&exec, Verdict::Pass, None),
463            (Verdict::Pass, VerdictSource::ExitCode, None)
464        );
465
466        let reported = Reported {
467            verdict: Verdict::Fail,
468            reason: Some("changes requested".into()),
469        };
470        assert_eq!(
471            resolve_verdict(&exec, Verdict::Pass, Some(reported)),
472            (
473                Verdict::Fail,
474                VerdictSource::Reported,
475                Some("changes requested".into())
476            )
477        );
478    }
479
480    #[test]
481    fn build_stage_ignores_reported_verdicts() {
482        let reported = Reported {
483            verdict: Verdict::Pass,
484            reason: Some("looks fine to me".into()),
485        };
486
487        assert_eq!(
488            resolve_verdict(&StageKind::Build, Verdict::Fail, Some(reported)),
489            (Verdict::Fail, VerdictSource::ExitCode, None)
490        );
491    }
492}