1use 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 .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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum Verdict {
159 Pass,
160 Fail,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum VerdictSource {
166 ExitCode,
169 Reported,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct Reported {
177 pub verdict: Verdict,
178 pub reason: Option<String>,
179}
180
181#[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
192pub 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#[derive(Debug, PartialEq, Eq)]
216pub enum Step<'a> {
217 Run(&'a Stage),
220 Halted { failed_stage: String },
223 Complete,
225}
226
227pub 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 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}