sloop/flow/mod.rs
1//! Flow definitions and the pure walk over them. Parsing turns a committed
2//! YAML file into a validated `Flow`; `next_step` then replays the run's
3//! ordered evidence log over that flow to derive the next stage to run or a
4//! terminal reading. Neither half touches a clock, a process, or the store,
5//! so policy can be tested without a daemon.
6//!
7//! The halves live in their own files and meet only here. This file is the
8//! vocabulary: the types a stage is written in, and nothing that reads or
9//! replays them. `parse` holds the YAML grammar and the `Raw*` wire types that
10//! are its only readers; `walk` holds the replay, which reads exactly three
11//! things from the vocabulary and never mentions the rest; `panel` holds the
12//! panel slice — its types, its parse validation, and its aggregation — which
13//! the walk does not know exists.
14
15mod panel;
16mod parse;
17mod walk;
18
19pub use panel::{
20 Confidence, MAX_PANEL_REVIEWERS, MIN_PANEL_REVIEWERS, NO_VERDICT_REPORTED, PANEL_PROMPT_ROOT,
21 Panel, PanelOutcome, Reviewer, ReviewerReport, aggregate,
22};
23pub use parse::parse;
24pub use walk::{
25 HaltReason, Reported, StageEvidence, Step, Verdict, VerdictSource, next_step, resolve_verdict,
26 return_trigger,
27};
28
29use serde::{Deserialize, Serialize};
30
31pub const DEFAULT_FLOW_NAME: &str = "default";
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Flow {
35 pub name: String,
36 pub stages: Vec<Stage>,
37}
38
39/// One stage: an untrusted `action`, an independent `result_check` that
40/// judges it, and what to do when the reading is `Fail`.
41///
42/// The split is the point. The action is whatever produces the work and is
43/// never trusted to grade itself; the check is a separate actor (or the
44/// action's own exit, or a worker's report) that decides. Every judgement a
45/// stage can carry is a configuration of this one shape rather than a policy
46/// of its own.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Stage {
49 pub name: String,
50 pub action: Actor,
51 pub result_check: Check,
52 pub fail_action: FailAction,
53 /// The merge builtin's fast-forward-only mode, written inside the action
54 /// as `{ builtin: merge, ff_only: true }`. It is refused on every other
55 /// action, so a `true` here always describes a merge stage.
56 ///
57 /// It rides on the stage rather than inside `Actor`, keeping
58 /// `Builtin::Merge`'s unit wire shape: an absent key is simply the mode
59 /// switched off, in the flow file and in the snapshot alike.
60 #[serde(default, skip_serializing_if = "is_false")]
61 pub ff_only: bool,
62}
63
64fn is_false(value: &bool) -> bool {
65 !*value
66}
67
68/// The inclusive upper bound on a `return_to` edge's attempt budget.
69pub const MAX_RETURN_ATTEMPTS: u32 = 3;
70
71/// The worst-case number of stage executions a flow may imply once every
72/// `return_to` budget is spent. A flow that could exceed it is rejected at
73/// parse time rather than discovered at runtime.
74pub const MAX_FLOW_EXECUTIONS: u64 = 32;
75
76/// Something that can be run within a stage. The same vocabulary serves
77/// both positions: an `Actor` is either the stage's action or the
78/// independent judge in its `result_check`.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub enum Actor {
81 /// Spawns the ticket's agent target in the run worktree. The prompt
82 /// comes from the ticket, not from the flow file.
83 Agent,
84 /// Runs an argv (no shell) in the run worktree.
85 Exec { cmd: Vec<String> },
86 /// A judgement Sloop makes itself, with no process to run.
87 Builtin(Builtin),
88}
89
90/// The actors Sloop implements internally.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92pub enum Builtin {
93 /// Applies the run branch to the default branch using Sloop's merge
94 /// policy. Only ever an action, never a check.
95 Merge,
96 /// Passes when Sloop observed at least one new commit on the run
97 /// branch. Only ever a check.
98 Commits,
99 /// Integrates the default branch into the run branch, inside the run
100 /// worktree. Only ever an action, never a check.
101 ///
102 /// It is the merge builtin's mirror image: the merge stage moves the
103 /// default branch and reads the run branch, and this one moves the run
104 /// branch and only ever reads the default branch. Putting it before the
105 /// stages that judge the work is what makes those stages judge the tree
106 /// the merge will actually land.
107 Sync,
108}
109
110/// How a stage's action is judged.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub enum Check {
113 /// The action's own exit status judges it: 0 passes.
114 None,
115 /// The worker must call `sloop verdict`; silence is a failure.
116 Reported,
117 /// An independent actor runs after the action and decides.
118 Actor(Actor),
119 /// Several independent reviewers each report, and a pure function over
120 /// their reports decides.
121 Panel(Panel),
122}
123
124/// What the walk does when a stage's reading is `Fail`.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub enum FailAction {
127 /// Stop the walk here, leaving the stages after it unrequested.
128 Halt,
129 /// Record the failure and carry on to the next stage.
130 Continue,
131 /// Re-run the span from `stage` through this one, up to `attempts`
132 /// times.
133 ReturnTo { stage: String, attempts: u32 },
134}
135
136pub(crate) fn built_in_default() -> Flow {
137 let stages = vec![
138 Stage {
139 name: "build".into(),
140 action: Actor::Agent,
141 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
142 fail_action: FailAction::Halt,
143 ff_only: false,
144 },
145 Stage {
146 name: "merge".into(),
147 action: Actor::Builtin(Builtin::Merge),
148 result_check: Check::None,
149 fail_action: FailAction::Halt,
150 ff_only: false,
151 },
152 ];
153 Flow {
154 name: DEFAULT_FLOW_NAME.into(),
155 stages,
156 }
157}