1use std::collections::HashSet;
8
9use serde::{Deserialize, Deserializer, Serialize};
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 the verdict with `sloop verdict pass|fail --reason <text>` exactly once.";
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Flow {
17 pub name: String,
18 pub stages: Vec<Stage>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct Stage {
23 pub name: String,
24 pub kind: StageKind,
25 pub verdict: VerdictPolicy,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub on_fail: Option<OnFail>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct OnFail {
39 pub agent: String,
41 pub attempts: u32,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub target: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub model: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub effort: Option<String>,
53}
54
55pub const MAX_ON_FAIL_ATTEMPTS: u32 = 3;
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub enum StageKind {
60 #[serde(alias = "Build")]
61 Agent,
62 Merge,
63 Exec {
64 cmd: Vec<String>,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub enum VerdictPolicy {
70 Exit,
71 Commits,
72 Check { cmd: Vec<String> },
73 Reported,
74}
75
76impl<'de> Deserialize<'de> for Stage {
77 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
78 where
79 D: Deserializer<'de>,
80 {
81 #[derive(Deserialize)]
82 struct SnapshotStage {
83 name: String,
84 kind: StageKind,
85 verdict: Option<VerdictPolicy>,
86 #[serde(default)]
87 on_fail: Option<OnFail>,
88 }
89
90 let stage = SnapshotStage::deserialize(deserializer)?;
91 let verdict = stage.verdict.unwrap_or(match &stage.kind {
92 StageKind::Agent => VerdictPolicy::Commits,
93 StageKind::Exec { .. } | StageKind::Merge => VerdictPolicy::Exit,
94 });
95 Ok(Self {
96 name: stage.name,
97 kind: stage.kind,
98 verdict,
99 on_fail: stage.on_fail,
100 })
101 }
102}
103
104pub fn parse(name: &str, contents: &str) -> Result<Flow, String> {
105 let file: RawFlowFile = serde_yaml::from_str(contents).map_err(|error| error.to_string())?;
106 let raw_stages = match file {
107 RawFlowFile::List(stages) => stages,
108 RawFlowFile::Map { stages } => stages,
109 };
110
111 let mut stages = Vec::with_capacity(raw_stages.len());
112 let mut names = HashSet::new();
113 for raw in raw_stages {
114 if !names.insert(raw.name.clone()) {
115 return Err(format!("duplicate stage name `{}`", raw.name));
116 }
117 let kind = match raw.kind.as_str() {
118 "agent" | "build" => {
119 if raw.cmd.is_some() {
120 return Err(format!("agent stage `{}` must not define `cmd`", raw.name));
121 }
122 StageKind::Agent
123 }
124 "merge" => {
125 if raw.cmd.is_some() {
126 return Err(format!("merge stage `{}` must not define `cmd`", raw.name));
127 }
128 StageKind::Merge
129 }
130 "exec" => {
131 let cmd = raw.cmd.unwrap_or_default();
132 if cmd.is_empty() {
133 return Err(format!(
134 "exec stage `{}` must define a non-empty `cmd`",
135 raw.name
136 ));
137 }
138 StageKind::Exec { cmd }
139 }
140 kind => return Err(format!("stage `{}` has unknown kind `{kind}`", raw.name)),
141 };
142 let verdict = match (&kind, raw.verdict) {
143 (StageKind::Merge, Some(_)) => {
144 return Err(format!(
145 "merge stage `{}` must not define `verdict`",
146 raw.name
147 ));
148 }
149 (StageKind::Merge | StageKind::Exec { .. }, None) => VerdictPolicy::Exit,
150 (StageKind::Agent, None) => VerdictPolicy::Commits,
151 (_, Some(RawVerdict::Name(name))) => match name.as_str() {
152 "exit" => VerdictPolicy::Exit,
153 "commits" => VerdictPolicy::Commits,
154 "reported" => VerdictPolicy::Reported,
155 _ => {
156 return Err(format!(
157 "stage `{}` has unknown verdict policy `{name}`",
158 raw.name
159 ));
160 }
161 },
162 (_, Some(RawVerdict::Check { check })) => {
163 if check.is_empty() {
164 return Err(format!(
165 "stage `{}` check verdict must define a non-empty command",
166 raw.name
167 ));
168 }
169 VerdictPolicy::Check { cmd: check }
170 }
171 };
172 let on_fail = match raw.on_fail {
173 None => None,
174 Some(_) if kind == StageKind::Agent => {
175 return Err(format!(
176 "agent stage `{}` must not define `on_fail`",
177 raw.name
178 ));
179 }
180 Some(on_fail) => Some(validate_on_fail(&raw.name, on_fail)?),
181 };
182 stages.push(Stage {
183 name: raw.name,
184 kind,
185 verdict,
186 on_fail,
187 });
188 }
189
190 validate_order(&stages)?;
191 Ok(Flow {
192 name: name.to_owned(),
193 stages,
194 })
195}
196
197fn validate_on_fail(stage: &str, raw: RawOnFail) -> Result<OnFail, String> {
200 if raw.agent.trim().is_empty() {
201 return Err(format!(
202 "stage `{stage}` on_fail must define a non-empty `agent` prompt"
203 ));
204 }
205 let attempts = raw.attempts.unwrap_or(1);
206 if attempts == 0 || attempts > MAX_ON_FAIL_ATTEMPTS {
207 return Err(format!(
208 "stage `{stage}` on_fail attempts must be between 1 and {MAX_ON_FAIL_ATTEMPTS}"
209 ));
210 }
211 Ok(OnFail {
212 agent: raw.agent,
213 attempts,
214 target: raw.target,
215 model: raw.model,
216 effort: raw.effort,
217 })
218}
219
220pub(crate) fn built_in_default() -> Flow {
221 let stages = vec![
222 Stage {
223 name: "build".into(),
224 kind: StageKind::Agent,
225 verdict: VerdictPolicy::Commits,
226 on_fail: None,
227 },
228 Stage {
229 name: "merge".into(),
230 kind: StageKind::Merge,
231 verdict: VerdictPolicy::Exit,
232 on_fail: None,
233 },
234 ];
235 Flow {
236 name: DEFAULT_FLOW_NAME.into(),
237 stages,
238 }
239}
240
241fn validate_order(stages: &[Stage]) -> Result<(), String> {
242 if !stages
243 .first()
244 .is_some_and(|stage| stage.kind == StageKind::Agent)
245 {
246 return Err("the first stage must be an agent stage".into());
247 }
248 let agent_count = stages
249 .iter()
250 .filter(|stage| stage.kind == StageKind::Agent)
251 .count();
252 if agent_count > 1 {
253 return Err(
254 "only the first stage may be an agent stage; additional agent stages require runner support"
255 .into(),
256 );
257 }
258
259 let merge_count = stages
260 .iter()
261 .filter(|stage| stage.kind == StageKind::Merge)
262 .count();
263 if merge_count > 1 {
264 return Err(format!(
265 "flow may contain at most one merge stage; found {merge_count}"
266 ));
267 }
268 if merge_count == 1 && stages.last().map(|stage| &stage.kind) != Some(&StageKind::Merge) {
269 return Err("merge stage must be last".into());
270 }
271 Ok(())
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum Verdict {
278 Pass,
279 Fail,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum VerdictSource {
285 ExitCode,
288 Reported,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Reported {
296 pub verdict: Verdict,
297 pub reason: Option<String>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct StageEvidence {
305 pub stage: String,
306 pub verdict: Verdict,
307 pub source: VerdictSource,
308 pub reason: Option<String>,
309}
310
311pub fn resolve_verdict(
315 policy: &VerdictPolicy,
316 exit: Verdict,
317 reported: Option<Reported>,
318) -> (Verdict, VerdictSource, Option<String>) {
319 if *policy != VerdictPolicy::Reported {
320 return (exit, VerdictSource::ExitCode, None);
321 }
322 match reported {
323 Some(reported) => (reported.verdict, VerdictSource::Reported, reported.reason),
324 None => (
325 Verdict::Fail,
326 VerdictSource::Reported,
327 Some("no verdict reported".into()),
328 ),
329 }
330}
331
332#[derive(Debug, PartialEq, Eq)]
334pub enum Step<'a> {
335 Run(&'a Stage),
338 Halted { failed_stage: String },
341 Complete,
343}
344
345pub fn next_step<'a>(flow: &'a Flow, evidence: &[StageEvidence]) -> Step<'a> {
353 for stage in &flow.stages {
354 match evidence.iter().find(|row| row.stage == stage.name) {
355 None => return Step::Run(stage),
356 Some(row) if row.verdict == Verdict::Pass => continue,
357 Some(row) => {
358 return Step::Halted {
359 failed_stage: row.stage.clone(),
360 };
361 }
362 }
363 }
364 Step::Complete
365}
366
367#[derive(Debug, Deserialize)]
368#[serde(untagged)]
369enum RawFlowFile {
370 List(Vec<RawStage>),
371 Map { stages: Vec<RawStage> },
372}
373
374#[derive(Debug, Deserialize)]
375struct RawStage {
376 name: String,
377 kind: String,
378 cmd: Option<Vec<String>>,
379 verdict: Option<RawVerdict>,
380 on_fail: Option<RawOnFail>,
381}
382
383#[derive(Debug, Deserialize)]
384#[serde(deny_unknown_fields)]
385struct RawOnFail {
386 agent: String,
387 attempts: Option<u32>,
388 target: Option<String>,
389 model: Option<String>,
390 effort: Option<String>,
391}
392
393#[derive(Debug, Deserialize)]
394#[serde(untagged)]
395enum RawVerdict {
396 Name(String),
397 Check { check: Vec<String> },
398}
399
400#[cfg(test)]
401mod tests {
402 use super::{
403 Flow, Reported, Stage, StageEvidence, StageKind, Step, Verdict, VerdictPolicy,
404 VerdictSource, next_step, parse, resolve_verdict,
405 };
406
407 fn error(yaml: &str) -> String {
408 parse("example", yaml).unwrap_err()
409 }
410
411 #[test]
412 fn valid_multi_stage_flow_parses_in_order() {
413 let flow = parse(
414 "release",
415 "stages:\n - name: build\n kind: agent\n - name: test\n kind: exec\n cmd: [cargo, test]\n verdict: { check: [cargo, clippy] }\n - name: merge\n kind: merge\n",
416 )
417 .unwrap();
418
419 assert_eq!(
420 flow,
421 Flow {
422 name: "release".into(),
423 stages: vec![
424 Stage {
425 name: "build".into(),
426 kind: StageKind::Agent,
427 verdict: VerdictPolicy::Commits,
428 on_fail: None,
429 },
430 Stage {
431 name: "test".into(),
432 kind: StageKind::Exec {
433 cmd: vec!["cargo".into(), "test".into()],
434 },
435 verdict: VerdictPolicy::Check {
436 cmd: vec!["cargo".into(), "clippy".into()],
437 },
438 on_fail: None,
439 },
440 Stage {
441 name: "merge".into(),
442 kind: StageKind::Merge,
443 verdict: VerdictPolicy::Exit,
444 on_fail: None,
445 },
446 ],
447 }
448 );
449 }
450
451 #[test]
452 fn build_is_a_deprecated_alias_for_agent() {
453 let flow = parse("example", "- { name: build, kind: build }\n").unwrap();
454 assert_eq!(flow.stages[0].kind, StageKind::Agent);
455 assert_eq!(flow.stages[0].verdict, VerdictPolicy::Commits);
456 }
457
458 #[test]
459 fn old_build_snapshots_deserialize_with_the_agent_default() {
460 let flow: Flow = serde_json::from_str(
461 r#"{"name":"example","stages":[{"name":"build","kind":"Build"}]}"#,
462 )
463 .unwrap();
464 assert_eq!(flow.stages[0].kind, StageKind::Agent);
465 assert_eq!(flow.stages[0].verdict, VerdictPolicy::Commits);
466 }
467
468 #[test]
469 fn verdict_policies_and_defaults_parse() {
470 let flow = parse(
471 "example",
472 "- { name: build, kind: agent, verdict: exit }\n- { name: test, kind: exec, cmd: ['true'], verdict: commits }\n- { name: review, kind: exec, cmd: ['true'], verdict: reported }\n",
473 )
474 .unwrap();
475 assert_eq!(flow.stages[0].verdict, VerdictPolicy::Exit);
476 assert_eq!(flow.stages[1].verdict, VerdictPolicy::Commits);
477 assert_eq!(flow.stages[2].verdict, VerdictPolicy::Reported);
478
479 let defaults = parse(
480 "example",
481 "- { name: build, kind: agent }\n- { name: test, kind: exec, cmd: ['true'] }\n",
482 )
483 .unwrap();
484 assert_eq!(defaults.stages[0].verdict, VerdictPolicy::Commits);
485 assert_eq!(defaults.stages[1].verdict, VerdictPolicy::Exit);
486 }
487
488 #[test]
489 fn on_fail_parses_with_defaults_and_overrides() {
490 let flow = parse(
491 "example",
492 "- { name: build, kind: agent }\n- name: test\n kind: exec\n cmd: [cargo, test]\n on_fail:\n agent: fix the tests\n- name: merge\n kind: merge\n on_fail:\n agent: integrate the default branch\n attempts: 2\n target: claude\n model: haiku\n effort: low\n",
493 )
494 .unwrap();
495
496 let test = flow.stages[1].on_fail.as_ref().unwrap();
497 assert_eq!(test.agent, "fix the tests");
498 assert_eq!(test.attempts, 1);
499 assert_eq!(test.target, None);
500
501 let merge = flow.stages[2].on_fail.as_ref().unwrap();
502 assert_eq!(merge.attempts, 2);
503 assert_eq!(merge.target.as_deref(), Some("claude"));
504 assert_eq!(merge.model.as_deref(), Some("haiku"));
505 assert_eq!(merge.effort.as_deref(), Some("low"));
506 }
507
508 #[test]
509 fn on_fail_survives_a_snapshot_round_trip() {
510 let flow = parse(
511 "example",
512 "- { name: build, kind: agent }\n- name: test\n kind: exec\n cmd: [cargo, test]\n on_fail:\n agent: fix the tests\n attempts: 3\n model: haiku\n",
513 )
514 .unwrap();
515 let snapshot = serde_json::to_string(&flow).unwrap();
516 let restored: Flow = serde_json::from_str(&snapshot).unwrap();
517 assert_eq!(flow, restored);
518 assert_eq!(restored.stages[1].on_fail.as_ref().unwrap().attempts, 3);
519 }
520
521 #[test]
522 fn on_fail_is_rejected_on_agent_stages() {
523 let error = error(
524 "- name: build\n kind: agent\n on_fail:\n agent: patch it\n- { name: merge, kind: merge }\n",
525 );
526 assert!(error.contains("agent stage `build`"), "{error}");
527 assert!(error.contains("must not define `on_fail`"), "{error}");
528 }
529
530 #[test]
531 fn on_fail_rejects_an_empty_prompt() {
532 let error = error(
533 "- { name: build, kind: agent }\n- name: test\n kind: exec\n cmd: ['true']\n on_fail:\n agent: ' '\n",
534 );
535 assert!(error.contains("stage `test`"), "{error}");
536 assert!(error.contains("non-empty `agent` prompt"), "{error}");
537 }
538
539 #[test]
540 fn on_fail_rejects_out_of_range_attempts() {
541 for attempts in ["0", "4"] {
542 let error = error(&format!(
543 "- {{ name: build, kind: agent }}\n- name: test\n kind: exec\n cmd: ['true']\n on_fail:\n agent: fix it\n attempts: {attempts}\n",
544 ));
545 assert!(error.contains("stage `test`"), "{error}");
546 assert!(
547 error.contains("attempts must be between 1 and 3"),
548 "{error}"
549 );
550 }
551 }
552
553 #[test]
554 fn merge_stages_reject_verdict_policies() {
555 let error = error(
556 "- { name: build, kind: agent }\n- { name: merge, kind: merge, verdict: exit }\n",
557 );
558 assert!(error.contains("must not define `verdict`"), "{error}");
559 }
560
561 #[test]
562 fn unknown_kinds_are_rejected() {
563 let error = error("- { name: build, kind: build }\n- { name: deploy, kind: magic }\n");
564 assert!(error.contains("unknown kind `magic`"), "{error}");
565 }
566
567 #[test]
568 fn duplicate_stage_names_are_rejected() {
569 let error = error("- { name: build, kind: build }\n- { name: build, kind: merge }\n");
570 assert!(error.contains("duplicate stage name `build`"), "{error}");
571 }
572
573 #[test]
574 fn exactly_one_first_agent_stage_is_required() {
575 let missing = error("- { name: check, kind: exec, cmd: ['true'] }\n");
576 assert!(
577 missing.contains("first stage must be an agent"),
578 "{missing}"
579 );
580
581 let duplicate = error("- { name: build, kind: agent }\n- { name: rebuild, kind: agent }\n");
582 assert!(duplicate.contains("require runner support"), "{duplicate}");
583 }
584
585 #[test]
586 fn at_most_one_merge_stage_is_allowed() {
587 let error = error(
588 "- { name: build, kind: build }\n- { name: merge-one, kind: merge }\n- { name: merge-two, kind: merge }\n",
589 );
590 assert!(error.contains("at most one merge stage"), "{error}");
591 }
592
593 #[test]
594 fn merge_stage_must_be_last() {
595 let error = error(
596 "- { name: build, kind: build }\n- { name: merge, kind: merge }\n- { name: check, kind: exec, cmd: ['true'] }\n",
597 );
598 assert!(error.contains("merge stage must be last"), "{error}");
599 }
600
601 #[test]
602 fn exec_stage_command_must_be_nonempty() {
603 for yaml in [
604 "- { name: build, kind: build }\n- { name: check, kind: exec }\n",
605 "- { name: build, kind: build }\n- { name: check, kind: exec, cmd: [] }\n",
606 ] {
607 let error = error(yaml);
608 assert!(error.contains("non-empty `cmd`"), "{error}");
609 }
610 }
611
612 fn build_review_merge() -> Flow {
613 Flow {
614 name: "example".into(),
615 stages: vec![
616 Stage {
617 name: "build".into(),
618 kind: StageKind::Agent,
619 verdict: VerdictPolicy::Commits,
620 on_fail: None,
621 },
622 Stage {
623 name: "review".into(),
624 kind: StageKind::Exec {
625 cmd: vec!["true".into()],
626 },
627 verdict: VerdictPolicy::Exit,
628 on_fail: None,
629 },
630 Stage {
631 name: "merge".into(),
632 kind: StageKind::Merge,
633 verdict: VerdictPolicy::Exit,
634 on_fail: None,
635 },
636 ],
637 }
638 }
639
640 fn passed(stage: &str) -> StageEvidence {
641 StageEvidence {
642 stage: stage.into(),
643 verdict: Verdict::Pass,
644 source: VerdictSource::ExitCode,
645 reason: None,
646 }
647 }
648
649 fn failed(stage: &str) -> StageEvidence {
650 StageEvidence {
651 stage: stage.into(),
652 verdict: Verdict::Fail,
653 source: VerdictSource::ExitCode,
654 reason: None,
655 }
656 }
657
658 #[test]
659 fn next_step_selects_the_first_stage_without_a_row() {
660 let flow = build_review_merge();
661
662 assert_eq!(next_step(&flow, &[]), Step::Run(&flow.stages[0]));
663 assert_eq!(
664 next_step(&flow, &[passed("build")]),
665 Step::Run(&flow.stages[1])
666 );
667 assert_eq!(
668 next_step(&flow, &[passed("build"), passed("review")]),
669 Step::Run(&flow.stages[2])
670 );
671 }
672
673 #[test]
674 fn next_step_is_complete_only_when_every_stage_passed() {
675 let flow = build_review_merge();
676
677 assert_eq!(
678 next_step(&flow, &[passed("build"), passed("review"), passed("merge")]),
679 Step::Complete
680 );
681 assert_ne!(
682 next_step(&flow, &[passed("build"), passed("review")]),
683 Step::Complete
684 );
685 }
686
687 #[test]
688 fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
689 let flow = build_review_merge();
690
691 let evidence = [passed("build"), failed("review"), passed("merge")];
695
696 assert_eq!(
697 next_step(&flow, &evidence),
698 Step::Halted {
699 failed_stage: "review".into()
700 }
701 );
702 }
703
704 #[test]
705 fn resuming_with_identical_evidence_yields_an_identical_step() {
706 let flow = build_review_merge();
707 let evidence = [passed("build")];
708
709 assert_eq!(next_step(&flow, &evidence), next_step(&flow, &evidence));
710 }
711
712 #[test]
713 fn only_reported_policy_consults_reported_verdicts() {
714 assert_eq!(
715 resolve_verdict(&VerdictPolicy::Exit, Verdict::Pass, None),
716 (Verdict::Pass, VerdictSource::ExitCode, None)
717 );
718
719 let reported = Reported {
720 verdict: Verdict::Fail,
721 reason: Some("changes requested".into()),
722 };
723 assert_eq!(
724 resolve_verdict(&VerdictPolicy::Reported, Verdict::Pass, Some(reported)),
725 (
726 Verdict::Fail,
727 VerdictSource::Reported,
728 Some("changes requested".into())
729 )
730 );
731 }
732
733 #[test]
734 fn non_reported_policies_ignore_reports() {
735 let reported = Reported {
736 verdict: Verdict::Pass,
737 reason: Some("looks fine to me".into()),
738 };
739
740 assert_eq!(
741 resolve_verdict(&VerdictPolicy::Commits, Verdict::Fail, Some(reported)),
742 (Verdict::Fail, VerdictSource::ExitCode, None)
743 );
744 }
745
746 #[test]
747 fn missing_report_is_a_failed_reported_verdict() {
748 assert_eq!(
749 resolve_verdict(&VerdictPolicy::Reported, Verdict::Pass, None),
750 (
751 Verdict::Fail,
752 VerdictSource::Reported,
753 Some("no verdict reported".into())
754 )
755 );
756 }
757}