Skip to main content

keel_cli/
sim.rs

1//! `keel sim <plan>` — fault/latency/crash-restart simulation over a
2//! declarative plan (`docs/sim-format.md`; architecture-spec §8: v1 scope is
3//! ADAPTER-LEVEL fault injection, not full hermetic/wasmtime determinism).
4//!
5//! This module owns the parts that are genuinely CLI-shaped:
6//!
7//! - dispatch the plan's `target`/`args` exactly like `keel run` (via
8//!   [`run::plan`]), with `KEEL_SIM_PLAN` (read by the language front ends'
9//!   `SimBackend`) and `KEEL_EVENTS=1` (force the Tier 1 event sink on, so
10//!   assertions always have something to read) layered onto the child;
11//! - detect a child that died to a signal (a `"crash"` directive fired) and
12//!   re-invoke the same plan against the same script, up to `max_restarts`
13//!   times — the fault-plan front end persists its own per-target cursor
14//!   across the restart (`docs/sim-format.md` "Crash-restart"), so this loop
15//!   only needs to know "did it die, and how many times has that happened";
16//! - after the loop settles, aggregate every event file the run(s) produced
17//!   under `.keel/events/` (diffed against what existed before the sim
18//!   started) and check the plan's `assert` block against them, plus the
19//!   newest flow row in `.keel/journal.db` when `assert.flow_status` is set.
20//!
21//! What actually injects a fault into an attempt lives entirely in the front
22//! ends (`python/keel/src/keel/_sim.py`, `node/keel/src/sim.mjs`) — this
23//! module never parses the plan's `faults` block, only `target`/`args`/
24//! `max_restarts`/`assert` (the fields it needs to drive and grade the run).
25
26use 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
37/// Appended to the plan's path to get its cursor sidecar
38/// (`docs/sim-format.md`): `_sim.py`/`sim.mjs` persist per-target consumed
39/// counts there so a crash-restart continues the fault sequence rather than
40/// replaying it from the top. `keel sim` deletes any stale one before the
41/// first spawn of a run, so re-running the same plan is always deterministic.
42const CURSOR_SUFFIX: &str = ".cursor.json";
43
44/// Mirrors `crates/keel-core/src/events.rs::EVENTS_SUBDIR` — duplicated
45/// (like `tail.rs` already does) rather than pulled in as a dependency, since
46/// this crate only reads the plain-file convention, never the sink itself.
47const EVENTS_SUBDIR: &str = "events";
48const EVENTS_EXT: &str = "ndjson";
49
50/// Exit code a crashed-and-exhausted sim settles on: 128 + SIGKILL(9), the
51/// code a POSIX shell reports for a process a real `kill -9` terminated —
52/// the front ends' `_default_crash`/`_defaultCrash` document the same value.
53const SIM_CRASH_EXIT_CODE: i32 = 137;
54
55const DEFAULT_MAX_RESTARTS: u32 = 8;
56
57/// Slack added on top of `flow_lease_ms` before a crash-restart respawns, so
58/// a lease that expires exactly at `flow_lease_ms` has definitely lapsed by
59/// the time the new process asks for it (clock/scheduling slop).
60const LEASE_GRACE_MS: u64 = 200;
61
62/// The fields `keel sim` itself needs from the plan JSON. Everything else
63/// (`v`, `faults`) is the front end's concern and is never parsed here —
64/// unknown fields are simply ignored (no `deny_unknown_fields`).
65#[derive(Debug, Clone, Deserialize)]
66struct SimPlan {
67    /// The script to dispatch, exactly as `keel run <target>` would.
68    target: String,
69    #[serde(default)]
70    args: Vec<String>,
71    /// How many times to re-invoke `target` after a `"crash"` directive kills
72    /// it, before giving up.
73    #[serde(default = "default_max_restarts")]
74    max_restarts: u32,
75    /// Forwarded as `KEEL_FLOW_LEASE_MS` on every spawn when `target` is a
76    /// Tier 2 flow entrypoint: a resumed flow's lease must have expired
77    /// before a fresh process can re-acquire it (KEEL-E030 otherwise), so a
78    /// crash-restart sleeps `flow_lease_ms + LEASE_GRACE_MS` before
79    /// respawning (the same wait `demos/durable-pipeline/run.sh` does by
80    /// hand). `None` (a non-flow or already-fast-leased target) skips the
81    /// wait entirely.
82    #[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/// The plan's `assert` block (`docs/sim-format.md` "Assertions").
93#[derive(Debug, Clone, Default, Deserialize)]
94struct SimAssertions {
95    /// Per-target cap on the attempts a single call may take.
96    #[serde(default)]
97    max_attempts: BTreeMap<String, u32>,
98    /// Targets whose breaker must have opened at least once.
99    #[serde(default)]
100    breaker_open: Vec<String>,
101    /// Targets whose breaker must never have opened.
102    #[serde(default)]
103    no_breaker_open: Vec<String>,
104    /// The status the newest `.keel/journal.db` flow row must settle on
105    /// (`"completed"`, `"failed"`, `"dead"`). `None` skips the check — most
106    /// sims of a non-flow target have no journal at all.
107    #[serde(default)]
108    flow_status: Option<String>,
109}
110
111/// One violation, `keel doctor`'s finding shape (`level`, `topic`, `detail`)
112/// so the two reports read the same way.
113#[derive(Debug, Serialize)]
114struct SimFinding {
115    detail: String,
116    level: &'static str,
117    topic: &'static str,
118}
119
120/// The whole `keel sim` report.
121#[derive(Debug, Serialize)]
122struct SimReport {
123    exit_code: i32,
124    findings: Vec<SimFinding>,
125    ok: bool,
126    plan: String,
127    restarts: u32,
128}
129
130/// `keel sim <plan>` for `project`: dispatch, crash-restart-drive, then grade.
131pub fn run(project: &Path, plan_path: &str) -> Rendered {
132    run_with(project, plan_path, &|_cmd| {})
133}
134
135/// Like [`run`], but lets the caller layer extra configuration onto every
136/// spawned `Command` before it runs (an integration test's `PYTHONPATH`, a
137/// hermetic `KEEL_BACKEND`, …) — mirrors `crate::run::exec_with`'s
138/// `configure` hook. `run` is exactly `run_with(project, plan_path, &|_| {})`.
139pub 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    // Every `keel sim` starts a plan's fault sequence from directive 0 — drop
155    // any cursor a previous invocation left behind.
156    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
206/// The outcome of driving the crash-restart loop.
207struct DriveResult {
208    exit_code: i32,
209    restarts: u32,
210    /// True when the loop stopped because `max_restarts` was exhausted while
211    /// the child was still dying to the crash directive, not because it ran
212    /// to a real terminal exit.
213    crashed: bool,
214}
215
216/// Spawn `plan` with `KEEL_SIM_PLAN`/`KEEL_EVENTS` layered on, re-invoking it
217/// after every crash (a child that died to a signal) up to `max_restarts`
218/// times. The SAME `abs_plan_path` is passed every time — the front end's own
219/// cursor sidecar (not this loop) is what makes the fault sequence continue
220/// rather than restart. When `flow_lease_ms` is set, a crash-restart sleeps
221/// `flow_lease_ms + LEASE_GRACE_MS` first, so a resumed Tier 2 flow's lease
222/// has genuinely expired before the new process asks for it.
223fn 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/// Whether `status` shows the child died to a signal (the shape a real `kill
272/// -9`, or `_sim.py`/`sim.mjs`'s self-`SIGKILL`, leaves) rather than exiting
273/// normally. On a platform with no signal model, a non-portable process
274/// cannot be "crashed" this way — treat it as never crashed (the front end's
275/// fallback `os._exit(137)` still surfaces as that literal exit code, which
276/// `max_restarts=0` callers can still key off via `exit_code`).
277#[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
288/// Every file currently under `dir` (an empty set for a directory that
289/// doesn't exist yet — the sink creates it on first write).
290fn 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
299/// Event files under `dir` NOT in `before`, oldest first (run ids are
300/// zero-padded epoch-ms hex — lexically sortable) — i.e. every run this sim
301/// invocation's process(es) produced, across every restart.
302fn 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/// What the sim's assertions are checked against: derived from every event
315/// line across every run in this sim invocation.
316#[derive(Debug, Default)]
317struct Observed {
318    /// The most attempts any single `call_end` for a target recorded.
319    max_attempts: BTreeMap<String, u32>,
320    /// Targets that ever emitted a `breaker_open` event.
321    breaker_open: BTreeSet<String>,
322    /// Whether ANY `call_end`/`breaker_open` event was seen at all — the
323    /// event sink (`crates/keel-core/src/events.rs`) is a NATIVE-core-only
324    /// feature (`KEEL_BACKEND=native`); a sim run entirely on the pure
325    /// stub/dev backend writes no `.keel/events/` at all, which must be a
326    /// loud finding when an event-based assertion was requested, never a
327    /// silent (and wrong) pass.
328    saw_any_event: bool,
329}
330
331/// Fold every NDJSON line in `files` into [`Observed`]. Unreadable files and
332/// unparseable/foreign lines are skipped, not fatal — mirrors `keel tail`'s
333/// and `keel record list`'s line hygiene.
334fn 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
375/// Check `assert` against `observed` (and, for `flow_status`, `project`'s
376/// journal), appending one [`SimFinding`] per violation.
377fn 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
452/// The status of the most-recently-updated row in `.keel/journal.db`'s
453/// `flows` table, if any journal/flow exists at all.
454fn 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
492/// A precise, non-fatal-to-the-process guidance error — mirrors
493/// `crate::record::soft_error`.
494fn 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    /// A tiny executable shell script standing in for a `keel run` target —
525    /// dispatch through `.py`/`.mjs` would need a real interpreter, but the
526    /// crash-restart LOOP itself is language-agnostic, so a `sh` script
527    /// wired through a `RunPlan` directly (bypassing `run::plan`'s
528    /// extension sniffing) exercises the exact same `drive` logic.
529    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        // Drive the shell script directly through a hand-built RunPlan since
581        // `run::plan` only dispatches `.py`/node extensions; the loop under
582        // test is `drive`, not extension sniffing (covered by `run.rs`).
583        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        // Crashes (SIGKILL's itself) on its first two invocations, then a
598        // third invocation exits cleanly — a stand-in for a `"crash"`
599        // directive that has exhausted its queue after two consumptions.
600        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        // The cursor is cleared BEFORE `run::plan` validates the target, so a
641        // target `keel run` cannot even dispatch (`.sh` is not a Keel
642        // extension) still gets a fresh fault sequence next time — clearing
643        // it never depends on the child actually spawning.
644        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:?}"); // `.sh` is not a dispatchable target
658        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        // The pure stub/dev backend writes no `.keel/events/` at all — a
739        // `max_attempts`/`breaker_open` assertion over an empty `Observed`
740        // must be a loud, explicit finding, never a silent (and wrong) pass.
741        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        // `no_breaker_open` is satisfied trivially by an absence of events —
755        // asserting "must not have opened" over a backend with no event sink
756        // at all is a legitimate (if weak) no-op, not a finding.
757        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    /// This crate's own `../../python/keel/src` (and the pure stub, for a
789    /// script with no native-only feature) — set as `PYTHONPATH` so a spawned
790    /// `python3 -m keel run` dispatches against THIS checkout's front end
791    /// without needing it `pip install`ed.
792    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    /// Writes a `py:` target that is genuinely wrappable: `call_api` lives in
829    /// its OWN module (`lib.py`), imported (not defined) by the dispatched
830    /// script (`app.py`) — a real `import lib` goes through `sys.meta_path`
831    /// and hits `KeelFinder`, unlike a function defined directly in the
832    /// `__main__`-executed file (`runpy.run_path` never registers that file
833    /// under an importable module name, so `[target."py:app.<fn>"]` for a
834    /// same-named top-level function is never actually wrapped — a real gap,
835    /// but an orthogonal one; this fixture sidesteps it the same way
836    /// `demos/durable-pipeline` does by pointing `[target."…"]` at a
837    /// separately-imported module). `call_api` appends one line to
838    /// `calls.txt` — the only reliable signal that the REAL effect ran,
839    /// since its return value alone (`{"ok": true}`) can't distinguish a
840    /// genuinely-wrapped-and-retried call from an unwrapped, fault-plan-blind
841    /// one.
842    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    /// The real front-end integration, exhaustion leg: FOUR straight
863    /// `timeout` directives exceed the default `defaults.outbound` retry cap
864    /// (3 attempts) — this can only make the script fail if the fault plan
865    /// genuinely reached `_sim.py` and Tier 1's real retry loop genuinely
866    /// exhausted on the injected outcomes (an unwrapped/no-op injection would
867    /// let `call_api` succeed immediately, contradicting this). Also proves
868    /// the real effect never ran (`calls.txt` absent — every attempt was
869    /// synthetic) and, via `assert.max_attempts`, that the native core's
870    /// event feed recorded exactly 3 attempts.
871    #[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        // The event sink resolves its directory from the child's OWN cwd
899        // (`EventsEnv::capture`'s `base_dir`), not from `project` — pin it to
900        // the same tempdir `run_with`'s post-hoc scan will look under.
901        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        // The wrapped call raises after exhausting its 3-attempt budget,
910        // propagating unchanged (dx-spec invariant 5) — the script's own
911        // process exit is non-zero, distinct from `keel sim`'s own findings.
912        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:?}"); // the ASSERTED max_attempts (3) held exactly
915    }
916
917    /// The real front-end integration, absorbed leg: `timeout` then `5xx`
918    /// then `ok` — Tier 1 retries the first two synthetic failures and lets
919    /// the third (real) attempt through, so the script succeeds with the
920    /// real effect having run EXACTLY once (proving the first two attempts
921    /// were genuinely intercepted, not passed through).
922    #[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    /// A cheap always-runs (no native core needed) leg: the exhaustion
968    /// scenario doesn't need the event feed at all (its signal is the
969    /// script's own exit code and `calls.txt`), so it is a genuine,
970    /// hermetic, falsifiable proof that fault injection reached `_sim.py`
971    /// even under the pure Python stub.
972    #[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        // `keel run`'s python3 child always inherits the KEEL PROCESS's cwd
989        // (real usage: the user's project dir, since nothing ever calls
990        // `Command::current_dir` — the child just fork+execs in place); pin
991        // it here since a `cargo test` process's own cwd is this crate's
992        // build dir, not the fixture's tempdir, and `keel.toml` resolves
993        // relative to the child's cwd (`bootstrap.load_policy`).
994        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    /// This repo's own native-core venv directory
1006    /// (`demos/durable-pipeline/run.sh`'s own `.venv` convention), if built —
1007    /// `<repo>/.venv/bin`, prepended onto `PATH` so `run::plan`'s hardcoded
1008    /// `python3` (`crate::run::python_plan`) resolves to the interpreter
1009    /// `keel_core` was installed into, not the bare system one. `<repo>` is
1010    /// resolved via the git COMMON dir (not `CARGO_MANIFEST_DIR/../..`,
1011    /// which is only this checkout's own worktree root) since the venv is a
1012    /// shared resource one `maturin develop` builds for every worktree.
1013    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    /// The genuine Tier 2 mechanical pattern (`demos/durable-pipeline`'s
1039    /// `kill -9` resume, mirrored here but crashed by a fault-plan `"crash"`
1040    /// directive instead of a hand-rolled env var): a 5-step flow crashes
1041    /// (real `SIGKILL`, via `_sim.py`'s `SimBackend`) mid-step-4, and `keel
1042    /// sim` — after waiting out the flow's lease — re-invokes it, resuming
1043    /// from the journal (steps 1-3 substituted, 4-5 run live) to completion.
1044    #[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        // The real proof of substitution (not a naive full re-run): each of
1097        // the 5 steps fired EXACTLY once across both process incarnations —
1098        // steps 1-3 substituted from the journal on resume (no new lines),
1099        // 4-5 ran live for the first time (mirrors
1100        // `demos/durable-pipeline/run.sh`'s own "expect 10, each exactly
1101        // once" assertion).
1102        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}