1use std::collections::{BTreeMap, BTreeSet};
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::process::Command;
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::render::to_json;
35use crate::{EXIT_FAILURE, EXIT_OK, EXIT_USAGE, Rendered, evidence, flows, run};
36
37const CURSOR_SUFFIX: &str = ".cursor.json";
43
44const EVENTS_SUBDIR: &str = "events";
48const EVENTS_EXT: &str = "ndjson";
49
50const SIM_CRASH_EXIT_CODE: i32 = 137;
54
55const DEFAULT_MAX_RESTARTS: u32 = 8;
56
57const LEASE_GRACE_MS: u64 = 200;
61
62#[derive(Debug, Clone, Deserialize)]
66struct SimPlan {
67 target: String,
69 #[serde(default)]
70 args: Vec<String>,
71 #[serde(default = "default_max_restarts")]
74 max_restarts: u32,
75 #[serde(default)]
83 flow_lease_ms: Option<u64>,
84 #[serde(default)]
85 assert: SimAssertions,
86}
87
88const fn default_max_restarts() -> u32 {
89 DEFAULT_MAX_RESTARTS
90}
91
92#[derive(Debug, Clone, Default, Deserialize)]
94struct SimAssertions {
95 #[serde(default)]
97 max_attempts: BTreeMap<String, u32>,
98 #[serde(default)]
100 breaker_open: Vec<String>,
101 #[serde(default)]
103 no_breaker_open: Vec<String>,
104 #[serde(default)]
108 flow_status: Option<String>,
109}
110
111#[derive(Debug, Serialize)]
114struct SimFinding {
115 detail: String,
116 level: &'static str,
117 topic: &'static str,
118}
119
120#[derive(Debug, Serialize)]
122struct SimReport {
123 exit_code: i32,
124 findings: Vec<SimFinding>,
125 ok: bool,
126 plan: String,
127 restarts: u32,
128}
129
130pub fn run(project: &Path, plan_path: &str) -> Rendered {
132 run_with(project, plan_path, &|_cmd| {})
133}
134
135pub fn run_with(project: &Path, plan_path: &str, configure: &dyn Fn(&mut Command)) -> Rendered {
140 let path = Path::new(plan_path);
141 let text = match fs::read_to_string(path) {
142 Ok(t) => t,
143 Err(err) => return soft_error(&format!("could not read {plan_path}: {err}.")),
144 };
145 let plan: SimPlan = match serde_json::from_str(&text) {
146 Ok(p) => p,
147 Err(err) => {
148 return soft_error(&format!(
149 "{plan_path} is not a valid sim plan (docs/sim-format.md): {err}."
150 ));
151 }
152 };
153 let abs_plan = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
154 let _ = fs::remove_file(cursor_path_for(&abs_plan));
157
158 let run_plan = match run::plan(&plan.target, &plan.args, false) {
159 Ok(p) => p,
160 Err(e) => return e.render(),
161 };
162
163 let events_dir = project.join(".keel").join(EVENTS_SUBDIR);
164 let before = existing_event_files(&events_dir);
165 let result = drive(
166 &run_plan,
167 &abs_plan,
168 plan.max_restarts,
169 plan.flow_lease_ms,
170 configure,
171 );
172 let observed = scan_events(&new_event_files(&events_dir, &before));
173
174 let mut findings = Vec::new();
175 check_assertions(&plan.assert, &observed, project, &mut findings);
176 if result.crashed {
177 findings.push(SimFinding {
178 level: "error",
179 topic: "crash-restart",
180 detail: format!(
181 "the target kept crashing after {} restart(s) (max_restarts={}); it never \
182 reached a terminal state.",
183 result.restarts, plan.max_restarts
184 ),
185 });
186 }
187
188 let report = SimReport {
189 exit_code: result.exit_code,
190 ok: findings.is_empty(),
191 plan: plan_path.to_owned(),
192 restarts: result.restarts,
193 findings,
194 };
195 let exit = if report.ok { EXIT_OK } else { EXIT_USAGE };
196 let human = human(&report);
197 Rendered::ok(human, to_json(&report)).with_exit(exit)
198}
199
200fn cursor_path_for(plan_path: &Path) -> PathBuf {
201 let mut s = plan_path.as_os_str().to_owned();
202 s.push(CURSOR_SUFFIX);
203 PathBuf::from(s)
204}
205
206struct DriveResult {
208 exit_code: i32,
209 restarts: u32,
210 crashed: bool,
214}
215
216fn drive(
224 plan: &run::RunPlan,
225 abs_plan_path: &Path,
226 max_restarts: u32,
227 flow_lease_ms: Option<u64>,
228 configure: &dyn Fn(&mut Command),
229) -> DriveResult {
230 let mut restarts = 0u32;
231 loop {
232 let mut cmd = Command::new(&plan.program);
233 cmd.args(&plan.argv);
234 if plan.disable {
235 cmd.env("KEEL_DISABLE", "1");
236 }
237 cmd.env("KEEL_SIM_PLAN", abs_plan_path);
238 cmd.env("KEEL_EVENTS", "1");
239 if let Some(lease_ms) = flow_lease_ms {
240 cmd.env("KEEL_FLOW_LEASE_MS", lease_ms.to_string());
241 }
242 configure(&mut cmd);
243 let Ok(status) = cmd.status() else {
244 return DriveResult {
245 exit_code: EXIT_FAILURE,
246 restarts,
247 crashed: false,
248 };
249 };
250 if !crashed(status) {
251 return DriveResult {
252 exit_code: status.code().unwrap_or(EXIT_FAILURE),
253 restarts,
254 crashed: false,
255 };
256 }
257 if restarts >= max_restarts {
258 return DriveResult {
259 exit_code: SIM_CRASH_EXIT_CODE,
260 restarts,
261 crashed: true,
262 };
263 }
264 restarts += 1;
265 if let Some(lease_ms) = flow_lease_ms {
266 std::thread::sleep(std::time::Duration::from_millis(lease_ms + LEASE_GRACE_MS));
267 }
268 }
269}
270
271#[cfg(unix)]
278fn crashed(status: std::process::ExitStatus) -> bool {
279 use std::os::unix::process::ExitStatusExt;
280 status.signal().is_some()
281}
282
283#[cfg(not(unix))]
284fn crashed(_status: std::process::ExitStatus) -> bool {
285 false
286}
287
288fn existing_event_files(dir: &Path) -> BTreeSet<String> {
291 fs::read_dir(dir)
292 .into_iter()
293 .flatten()
294 .flatten()
295 .filter_map(|e| e.file_name().into_string().ok())
296 .collect()
297}
298
299fn new_event_files(dir: &Path, before: &BTreeSet<String>) -> Vec<PathBuf> {
303 let mut names: Vec<String> = fs::read_dir(dir)
304 .into_iter()
305 .flatten()
306 .flatten()
307 .filter_map(|e| e.file_name().into_string().ok())
308 .filter(|n| n.ends_with(&format!(".{EVENTS_EXT}")) && !before.contains(n))
309 .collect();
310 names.sort();
311 names.into_iter().map(|n| dir.join(n)).collect()
312}
313
314#[derive(Debug, Default)]
317struct Observed {
318 max_attempts: BTreeMap<String, u32>,
320 breaker_open: BTreeSet<String>,
322 saw_any_event: bool,
329}
330
331fn scan_events(files: &[PathBuf]) -> Observed {
335 let mut out = Observed::default();
336 for path in files {
337 let Ok(text) = fs::read_to_string(path) else {
338 continue;
339 };
340 for line in text.lines() {
341 let line = line.trim();
342 if line.is_empty() {
343 continue;
344 }
345 let Ok(v) = serde_json::from_str::<Value>(line) else {
346 continue;
347 };
348 match v.get("event").and_then(Value::as_str) {
349 Some("call_end") => {
350 out.saw_any_event = true;
351 let Some(target) = v.get("target").and_then(Value::as_str) else {
352 continue;
353 };
354 let attempts =
355 u32::try_from(v.get("attempts").and_then(Value::as_u64).unwrap_or(0))
356 .unwrap_or(u32::MAX);
357 let entry = out.max_attempts.entry(target.to_owned()).or_insert(0);
358 if attempts > *entry {
359 *entry = attempts;
360 }
361 }
362 Some("breaker_open") => {
363 out.saw_any_event = true;
364 if let Some(target) = v.get("target").and_then(Value::as_str) {
365 out.breaker_open.insert(target.to_owned());
366 }
367 }
368 _ => {}
369 }
370 }
371 }
372 out
373}
374
375fn check_assertions(
378 assert: &SimAssertions,
379 observed: &Observed,
380 project: &Path,
381 findings: &mut Vec<SimFinding>,
382) {
383 let wants_events = !assert.max_attempts.is_empty() || !assert.breaker_open.is_empty();
384 if wants_events && !observed.saw_any_event {
385 findings.push(SimFinding {
386 level: "error",
387 topic: "no-events",
388 detail: "max_attempts/breaker_open assertions were requested, but no Tier 1 events \
389 were observed at all. The event sink is a native-core-only feature \
390 (crates/keel-core/src/events.rs) — set KEEL_BACKEND=native (a built \
391 keel_core), or drop these assertions for a pure stub/dev-backend sim."
392 .to_owned(),
393 });
394 }
395 for (target, cap) in &assert.max_attempts {
396 if let Some(&seen) = observed.max_attempts.get(target)
397 && seen > *cap
398 {
399 findings.push(SimFinding {
400 level: "error",
401 topic: "max-attempts",
402 detail: format!(
403 "{target} made {seen} attempt(s) on one call, exceeding the configured cap \
404 of {cap}."
405 ),
406 });
407 }
408 }
409 for target in &assert.breaker_open {
410 if !observed.breaker_open.contains(target) {
411 findings.push(SimFinding {
412 level: "error",
413 topic: "breaker",
414 detail: format!(
415 "expected the breaker for {target} to open under the fault plan, but it \
416 never did."
417 ),
418 });
419 }
420 }
421 for target in &assert.no_breaker_open {
422 if observed.breaker_open.contains(target) {
423 findings.push(SimFinding {
424 level: "error",
425 topic: "breaker",
426 detail: format!(
427 "the breaker for {target} opened, but the plan asserted it must not."
428 ),
429 });
430 }
431 }
432 if let Some(want) = &assert.flow_status {
433 match newest_flow_status(project) {
434 Some(got) if &got == want => {}
435 Some(got) => findings.push(SimFinding {
436 level: "error",
437 topic: "flow-status",
438 detail: format!("expected the flow to end {want}, but it is {got}."),
439 }),
440 None => findings.push(SimFinding {
441 level: "error",
442 topic: "flow-status",
443 detail: format!(
444 "expected the flow to end {want}, but no flow was found in \
445 .keel/journal.db."
446 ),
447 }),
448 }
449 }
450}
451
452fn newest_flow_status(project: &Path) -> Option<String> {
455 let path = evidence::resolved_journal(project).path;
456 if !path.exists() {
457 return None;
458 }
459 let conn = flows::open_ro(&path).ok()?;
460 conn.query_row(
461 "SELECT status FROM flows ORDER BY updated_at DESC, flow_id DESC LIMIT 1",
462 [],
463 |row| row.get::<_, String>(0),
464 )
465 .ok()
466}
467
468fn human(report: &SimReport) -> String {
469 let restarts_note = if report.restarts > 0 {
470 format!(", {} restart(s)", report.restarts)
471 } else {
472 String::new()
473 };
474 if report.ok {
475 return format!(
476 "keel \u{25b8} sim {} passed \u{2014} exit {}{restarts_note}.",
477 report.plan, report.exit_code
478 );
479 }
480 let mut lines = vec![format!(
481 "keel \u{25b8} sim {} found {} problem(s) (exit {}{restarts_note}):\n",
482 report.plan,
483 report.findings.len(),
484 report.exit_code
485 )];
486 for f in &report.findings {
487 lines.push(format!(" [{}] {}: {}\n", f.level, f.topic, f.detail));
488 }
489 lines.concat()
490}
491
492fn soft_error(message: &str) -> Rendered {
495 #[derive(Serialize)]
496 struct ErrReport<'a> {
497 error: &'a str,
498 }
499 Rendered {
500 human: format!("keel \u{25b8} {message}"),
501 json: to_json(&ErrReport { error: message }),
502 exit: EXIT_FAILURE,
503 to_stderr: true,
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use std::io::Write as _;
511 use std::os::unix::fs::PermissionsExt;
512 use tempfile::TempDir;
513
514 fn project() -> TempDir {
515 TempDir::new().unwrap()
516 }
517
518 fn write_plan(dir: &Path, name: &str, json: &str) -> PathBuf {
519 let path = dir.join(name);
520 fs::write(&path, json).unwrap();
521 path
522 }
523
524 fn write_script(dir: &Path, name: &str, body: &str) -> PathBuf {
530 let path = dir.join(name);
531 fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
532 let mut perms = fs::metadata(&path).unwrap().permissions();
533 perms.set_mode(0o755);
534 fs::set_permissions(&path, perms).unwrap();
535 path
536 }
537
538 #[test]
539 fn missing_plan_file_is_a_precise_error() {
540 let dir = project();
541 let r = run(dir.path(), "does-not-exist.json");
542 assert_eq!(r.exit, EXIT_FAILURE);
543 assert!(r.human.contains("could not read"));
544 }
545
546 #[test]
547 fn malformed_plan_json_is_a_precise_error() {
548 let dir = project();
549 let plan = write_plan(dir.path(), "plan.json", "not json");
550 let r = run(dir.path(), &plan.to_string_lossy());
551 assert_eq!(r.exit, EXIT_FAILURE);
552 assert!(r.human.contains("not a valid sim plan"));
553 }
554
555 #[test]
556 fn unresolvable_target_reports_the_same_error_keel_run_would() {
557 let dir = project();
558 let plan = write_plan(
559 dir.path(),
560 "plan.json",
561 r#"{"v":1,"target":"does-not-exist.py"}"#,
562 );
563 let r = run(dir.path(), &plan.to_string_lossy());
564 assert_eq!(r.exit, EXIT_USAGE);
565 assert!(r.human.contains("no such file or directory"));
566 }
567
568 #[test]
569 fn clean_run_with_no_assertions_passes() {
570 let dir = project();
571 write_script(dir.path(), "ok.sh", "exit 0");
572 let plan = write_plan(
573 dir.path(),
574 "plan.json",
575 &format!(
576 r#"{{"v":1,"target":{:?}}}"#,
577 dir.path().join("ok.sh").to_string_lossy()
578 ),
579 );
580 let run_plan = run::RunPlan {
584 program: dir.path().join("ok.sh").to_string_lossy().into_owned(),
585 argv: vec![],
586 disable: false,
587 };
588 let result = drive(&run_plan, &plan, 4, None, &|_cmd| {});
589 assert_eq!(result.exit_code, 0);
590 assert_eq!(result.restarts, 0);
591 assert!(!result.crashed);
592 }
593
594 #[test]
595 fn a_self_killed_child_is_retried_up_to_max_restarts() {
596 let dir = project();
597 let counter = dir.path().join("count");
601 fs::write(&counter, "0").unwrap();
602 let script = write_script(
603 dir.path(),
604 "flaky.sh",
605 &format!(
606 "n=$(cat {0})\nn=$((n+1))\necho $n > {0}\nif [ \"$n\" -le 2 ]; then kill -9 $$; fi\nexit 0",
607 counter.display()
608 ),
609 );
610 let plan = write_plan(dir.path(), "plan.json", r#"{"v":1,"target":"x"}"#);
611 let run_plan = run::RunPlan {
612 program: script.to_string_lossy().into_owned(),
613 argv: vec![],
614 disable: false,
615 };
616 let result = drive(&run_plan, &plan, 4, None, &|_cmd| {});
617 assert_eq!(result.exit_code, 0);
618 assert_eq!(result.restarts, 2);
619 assert!(!result.crashed);
620 }
621
622 #[test]
623 fn exhausting_max_restarts_while_still_crashing_is_flagged() {
624 let dir = project();
625 let script = write_script(dir.path(), "always_dies.sh", "kill -9 $$");
626 let plan = write_plan(dir.path(), "plan.json", r#"{"v":1,"target":"x"}"#);
627 let run_plan = run::RunPlan {
628 program: script.to_string_lossy().into_owned(),
629 argv: vec![],
630 disable: false,
631 };
632 let result = drive(&run_plan, &plan, 2, None, &|_cmd| {});
633 assert_eq!(result.restarts, 2);
634 assert!(result.crashed);
635 assert_eq!(result.exit_code, SIM_CRASH_EXIT_CODE);
636 }
637
638 #[test]
639 fn cursor_sidecar_is_reset_before_the_first_spawn() {
640 let dir = project();
645 write_script(dir.path(), "ok.sh", "exit 0");
646 let target = dir.path().join("ok.sh").to_string_lossy().into_owned();
647 let plan_path = write_plan(
648 dir.path(),
649 "plan.json",
650 &format!(r#"{{"v":1,"target":{target:?}}}"#),
651 );
652 let abs = fs::canonicalize(&plan_path).unwrap();
653 let cursor = cursor_path_for(&abs);
654 fs::write(&cursor, r#"{"stale":"leftover"}"#).unwrap();
655 assert!(cursor.exists());
656 let r = run(dir.path(), &plan_path.to_string_lossy());
657 assert_eq!(r.exit, EXIT_USAGE, "{r:?}"); assert!(!cursor.exists(), "stale cursor must be cleared regardless");
659 }
660
661 #[test]
662 fn scan_events_finds_max_attempts_and_breaker_open() {
663 let dir = project();
664 let events = dir.path().join("run.ndjson");
665 let mut f = fs::File::create(&events).unwrap();
666 writeln!(
667 f,
668 r#"{{"v":1,"seq":0,"ms":0,"event":"run_start","run":"r"}}"#
669 )
670 .unwrap();
671 writeln!(
672 f,
673 r#"{{"v":1,"seq":1,"ms":1,"event":"call_end","call":"t-1","target":"api.a","result":"ok","attempts":3}}"#
674 )
675 .unwrap();
676 writeln!(
677 f,
678 r#"{{"v":1,"seq":2,"ms":2,"event":"breaker_open","call":"t-2","target":"api.b","cooldown_ms":500}}"#
679 )
680 .unwrap();
681 let observed = scan_events(&[events]);
682 assert_eq!(observed.max_attempts.get("api.a"), Some(&3));
683 assert!(observed.breaker_open.contains("api.b"));
684 }
685
686 #[test]
687 fn max_attempts_violation_is_a_finding() {
688 let observed = Observed {
689 max_attempts: BTreeMap::from([("api.a".to_owned(), 5)]),
690 breaker_open: BTreeSet::new(),
691 saw_any_event: true,
692 };
693 let assert = SimAssertions {
694 max_attempts: BTreeMap::from([("api.a".to_owned(), 3)]),
695 ..Default::default()
696 };
697 let mut findings = Vec::new();
698 check_assertions(&assert, &observed, Path::new("."), &mut findings);
699 assert_eq!(findings.len(), 1, "{findings:?}");
700 assert_eq!(findings[0].topic, "max-attempts");
701 assert!(findings[0].detail.contains("5 attempt"));
702 }
703
704 #[test]
705 fn breaker_open_expected_but_absent_is_a_finding() {
706 let observed = Observed {
707 saw_any_event: true,
708 ..Default::default()
709 };
710 let assert = SimAssertions {
711 breaker_open: vec!["api.flaky".to_owned()],
712 ..Default::default()
713 };
714 let mut findings = Vec::new();
715 check_assertions(&assert, &observed, Path::new("."), &mut findings);
716 assert_eq!(findings.len(), 1, "{findings:?}");
717 assert_eq!(findings[0].topic, "breaker");
718 }
719
720 #[test]
721 fn no_breaker_open_violated_is_a_finding() {
722 let observed = Observed {
723 max_attempts: BTreeMap::new(),
724 breaker_open: BTreeSet::from(["api.pay".to_owned()]),
725 saw_any_event: true,
726 };
727 let assert = SimAssertions {
728 no_breaker_open: vec!["api.pay".to_owned()],
729 ..Default::default()
730 };
731 let mut findings = Vec::new();
732 check_assertions(&assert, &observed, Path::new("."), &mut findings);
733 assert_eq!(findings.len(), 1, "{findings:?}");
734 }
735
736 #[test]
737 fn no_events_at_all_is_a_finding_when_event_based_assertions_were_requested() {
738 let observed = Observed::default();
742 let assert = SimAssertions {
743 max_attempts: BTreeMap::from([("api.a".to_owned(), 3)]),
744 ..Default::default()
745 };
746 let mut findings = Vec::new();
747 check_assertions(&assert, &observed, Path::new("."), &mut findings);
748 assert_eq!(findings.len(), 1, "{findings:?}");
749 assert_eq!(findings[0].topic, "no-events");
750 }
751
752 #[test]
753 fn no_breaker_open_assertion_alone_never_needs_events() {
754 let observed = Observed::default();
758 let assert = SimAssertions {
759 no_breaker_open: vec!["api.pay".to_owned()],
760 ..Default::default()
761 };
762 let mut findings = Vec::new();
763 check_assertions(&assert, &observed, Path::new("."), &mut findings);
764 assert!(findings.is_empty(), "{findings:?}");
765 }
766
767 #[test]
768 fn flow_status_mismatch_and_missing_are_findings() {
769 let dir = project();
770 let observed = Observed::default();
771 let assert = SimAssertions {
772 flow_status: Some("completed".to_owned()),
773 ..Default::default()
774 };
775 let mut findings = Vec::new();
776 check_assertions(&assert, &observed, dir.path(), &mut findings);
777 assert_eq!(findings.len(), 1);
778 assert!(findings[0].detail.contains("no flow was found"));
779 }
780
781 fn python3_present() -> bool {
782 Command::new("python3")
783 .arg("--version")
784 .output()
785 .is_ok_and(|o| o.status.success())
786 }
787
788 fn python_path() -> String {
793 let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
794 format!(
795 "{}:{}",
796 manifest.join("../../python/keel/src").display(),
797 manifest.join("../../python/keel-core-stub").display(),
798 )
799 }
800
801 #[test]
802 fn end_to_end_report_is_ok_for_a_clean_run() {
803 if !python3_present() {
804 eprintln!("skip: python3 not available");
805 return;
806 }
807 let dir = project();
808 fs::write(dir.path().join("app.py"), "print(\"hi\")\n").unwrap();
809 let target = dir.path().join("app.py").to_string_lossy().into_owned();
810 let plan_path = write_plan(
811 dir.path(),
812 "plan.json",
813 &format!(r#"{{"v":1,"target":{target:?}}}"#),
814 );
815 let pythonpath = python_path();
816 let r = run_with(dir.path(), &plan_path.to_string_lossy(), &move |cmd| {
817 cmd.env("PYTHONPATH", &pythonpath);
818 cmd.env("KEEL_BACKEND", "stub");
819 cmd.env("KEEL_QUIET", "1");
820 });
821 assert_eq!(r.exit, EXIT_OK, "{r:?}");
822 assert_eq!(r.json["ok"], true);
823 assert_eq!(r.json["exit_code"], 0);
824 assert_eq!(r.json["restarts"], 0);
825 assert!(r.human.contains("passed"));
826 }
827
828 fn write_wrappable_target(dir: &Path) -> String {
843 fs::write(dir.join("keel.toml"), "[target.\"py:lib.call_api\"]\n").unwrap();
844 fs::write(
845 dir.join("lib.py"),
846 "import os\n\nCALLS_FILE = os.path.join(os.path.dirname(__file__), \"calls.txt\")\n\n\ndef call_api():\n with open(CALLS_FILE, \"a\", encoding=\"utf-8\") as f:\n f.write(\"call\\n\")\n return {\"ok\": True}\n",
847 )
848 .unwrap();
849 fs::write(
850 dir.join("app.py"),
851 "import lib\n\n\ndef main():\n print(lib.call_api())\n\n\nif __name__ == \"__main__\":\n main()\n",
852 )
853 .unwrap();
854 dir.join("app.py").to_string_lossy().into_owned()
855 }
856
857 fn calls_count(dir: &Path) -> usize {
858 fs::read_to_string(dir.join("calls.txt"))
859 .map_or(0, |t| t.lines().filter(|l| !l.trim().is_empty()).count())
860 }
861
862 #[test]
872 fn front_end_fault_injection_exhausts_real_retries_and_fails() {
873 let bin_dir = venv_bin_dir();
874 if !python3_present() || !native_core_present(bin_dir.as_deref()) {
875 eprintln!("skip: native core (keel_core) not available");
876 return;
877 }
878 let dir = project();
879 let target = write_wrappable_target(dir.path());
880 let plan_path = write_plan(
881 dir.path(),
882 "plan.json",
883 &format!(
884 r#"{{"v":1,"target":{target:?},"faults":{{"py:lib.call_api":[{{"kind":"timeout"}},{{"kind":"timeout"}},{{"kind":"timeout"}},{{"kind":"timeout"}}]}},"assert":{{"max_attempts":{{"py:lib.call_api":3}}}}}}"#
885 ),
886 );
887 let pythonpath = python_path();
888 let path_env = bin_dir.map_or_else(
889 || std::env::var("PATH").unwrap_or_default(),
890 |d| {
891 format!(
892 "{}:{}",
893 d.display(),
894 std::env::var("PATH").unwrap_or_default()
895 )
896 },
897 );
898 let project_dir = dir.path().to_path_buf();
902 let r = run_with(dir.path(), &plan_path.to_string_lossy(), &move |cmd| {
903 cmd.current_dir(&project_dir);
904 cmd.env("PATH", &path_env);
905 cmd.env("PYTHONPATH", &pythonpath);
906 cmd.env("KEEL_BACKEND", "native");
907 cmd.env("KEEL_QUIET", "1");
908 });
909 assert_ne!(r.json["exit_code"], 0, "{r:?}");
913 assert_eq!(calls_count(dir.path()), 0, "the real effect must never run");
914 assert_eq!(r.json["ok"], true, "{r:?}"); }
916
917 #[test]
923 fn front_end_fault_injection_absorbs_retries_then_succeeds() {
924 let bin_dir = venv_bin_dir();
925 if !python3_present() || !native_core_present(bin_dir.as_deref()) {
926 eprintln!("skip: native core (keel_core) not available");
927 return;
928 }
929 let dir = project();
930 let target = write_wrappable_target(dir.path());
931 let plan_path = write_plan(
932 dir.path(),
933 "plan.json",
934 &format!(
935 r#"{{"v":1,"target":{target:?},"faults":{{"py:lib.call_api":[{{"kind":"timeout"}},{{"kind":"5xx","status":503}},{{"kind":"ok"}}]}},"assert":{{"max_attempts":{{"py:lib.call_api":3}}}}}}"#
936 ),
937 );
938 let pythonpath = python_path();
939 let path_env = bin_dir.map_or_else(
940 || std::env::var("PATH").unwrap_or_default(),
941 |d| {
942 format!(
943 "{}:{}",
944 d.display(),
945 std::env::var("PATH").unwrap_or_default()
946 )
947 },
948 );
949 let project_dir = dir.path().to_path_buf();
950 let r = run_with(dir.path(), &plan_path.to_string_lossy(), &move |cmd| {
951 cmd.current_dir(&project_dir);
952 cmd.env("PATH", &path_env);
953 cmd.env("PYTHONPATH", &pythonpath);
954 cmd.env("KEEL_BACKEND", "native");
955 cmd.env("KEEL_QUIET", "1");
956 });
957 assert_eq!(r.exit, EXIT_OK, "{r:?}");
958 assert_eq!(r.json["ok"], true, "{r:?}");
959 assert_eq!(r.json["exit_code"], 0);
960 assert_eq!(
961 calls_count(dir.path()),
962 1,
963 "the real effect ran exactly once"
964 );
965 }
966
967 #[test]
973 fn front_end_fault_injection_exhausts_real_retries_on_the_stub() {
974 if !python3_present() {
975 eprintln!("skip: python3 not available");
976 return;
977 }
978 let dir = project();
979 let target = write_wrappable_target(dir.path());
980 let plan_path = write_plan(
981 dir.path(),
982 "plan.json",
983 &format!(
984 r#"{{"v":1,"target":{target:?},"faults":{{"py:lib.call_api":[{{"kind":"timeout"}},{{"kind":"timeout"}},{{"kind":"timeout"}},{{"kind":"timeout"}}]}}}}"#
985 ),
986 );
987 let pythonpath = python_path();
988 let project_dir = dir.path().to_path_buf();
995 let r = run_with(dir.path(), &plan_path.to_string_lossy(), &move |cmd| {
996 cmd.current_dir(&project_dir);
997 cmd.env("PYTHONPATH", &pythonpath);
998 cmd.env("KEEL_BACKEND", "stub");
999 cmd.env("KEEL_QUIET", "1");
1000 });
1001 assert_ne!(r.json["exit_code"], 0, "{r:?}");
1002 assert_eq!(calls_count(dir.path()), 0, "the real effect must never run");
1003 }
1004
1005 fn venv_bin_dir() -> Option<PathBuf> {
1014 let out = Command::new("git")
1015 .args(["rev-parse", "--git-common-dir"])
1016 .current_dir(env!("CARGO_MANIFEST_DIR"))
1017 .output()
1018 .ok()?;
1019 if !out.status.success() {
1020 return None;
1021 }
1022 let git_dir = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
1023 let repo_root = fs::canonicalize(git_dir).ok()?.parent()?.to_path_buf();
1024 let bin = repo_root.join(".venv/bin");
1025 bin.join("python3").is_file().then_some(bin)
1026 }
1027
1028 fn native_core_present(bin_dir: Option<&Path>) -> bool {
1029 let mut cmd = Command::new("python3");
1030 cmd.arg("-c").arg("import keel_core");
1031 if let Some(dir) = bin_dir {
1032 let path = std::env::var("PATH").unwrap_or_default();
1033 cmd.env("PATH", format!("{}:{path}", dir.display()));
1034 }
1035 cmd.status().is_ok_and(|s| s.success())
1036 }
1037
1038 #[test]
1045 fn crash_restart_resumes_a_real_tier_2_flow() {
1046 let bin_dir = venv_bin_dir();
1047 if !python3_present() || !native_core_present(bin_dir.as_deref()) {
1048 eprintln!("skip: native core (keel_core) not available");
1049 return;
1050 }
1051 let dir = project();
1052 fs::write(
1053 dir.path().join("keel.toml"),
1054 "[flows]\nentrypoints = [\"py:pipeline:main\"]\n\n[target.\"py:pipeline.do_step\"]\n",
1055 )
1056 .unwrap();
1057 fs::write(
1058 dir.path().join("pipeline.py"),
1059 "import os\n\n_LOG = os.path.join(os.path.dirname(__file__), \"steps.log\")\n\n\ndef do_step(n):\n with open(_LOG, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"step-{n}\\n\")\n return {\"step\": n}\n\n\ndef main():\n for n in range(1, 6):\n do_step(n)\n print(\"PIPELINE_COMPLETE\")\n",
1060 )
1061 .unwrap();
1062 let target = dir
1063 .path()
1064 .join("pipeline.py")
1065 .to_string_lossy()
1066 .into_owned();
1067 let plan_path = write_plan(
1068 dir.path(),
1069 "plan.json",
1070 &format!(
1071 r#"{{"v":1,"target":{target:?},"max_restarts":2,"flow_lease_ms":300,"faults":{{"py:pipeline.do_step":[{{"kind":"ok"}},{{"kind":"ok"}},{{"kind":"ok"}},{{"kind":"crash"}}]}},"assert":{{"flow_status":"completed"}}}}"#
1072 ),
1073 );
1074 let pythonpath = python_path();
1075 let project_dir = dir.path().to_path_buf();
1076 let path_env = bin_dir.map_or_else(
1077 || std::env::var("PATH").unwrap_or_default(),
1078 |d| {
1079 format!(
1080 "{}:{}",
1081 d.display(),
1082 std::env::var("PATH").unwrap_or_default()
1083 )
1084 },
1085 );
1086 let r = run_with(dir.path(), &plan_path.to_string_lossy(), &move |cmd| {
1087 cmd.current_dir(&project_dir);
1088 cmd.env("PATH", &path_env);
1089 cmd.env("PYTHONPATH", &pythonpath);
1090 cmd.env("KEEL_BACKEND", "native");
1091 cmd.env("KEEL_QUIET", "1");
1092 });
1093 assert_eq!(r.json["restarts"], 1, "{r:?}");
1094 assert_eq!(r.json["ok"], true, "{r:?}");
1095 assert_eq!(r.json["exit_code"], 0, "{r:?}");
1096 let log = fs::read_to_string(dir.path().join("steps.log")).unwrap();
1103 let mut lines: Vec<&str> = log.lines().collect();
1104 lines.sort_unstable();
1105 assert_eq!(
1106 lines,
1107 vec!["step-1", "step-2", "step-3", "step-4", "step-5"]
1108 );
1109 }
1110}