Skip to main content

keel_cli/
exec.rs

1//! `keel exec --flow <name> -- <program> [args…]` — wrap one external command
2//! as a journaled Tier-2 durable flow (CCR-4).
3//!
4//! What `keel exec` gives an arbitrary process that has no Keel front end (a
5//! shell script, a `uvx` server, a cron job): **at-most-once dispatch per
6//! identity** and **crash-safe retry gating**. It is emphatically NOT
7//! exactly-once execution *inside* the child — Keel cannot un-send whatever
8//! side effects a subprocess performs. The honest guarantee is at the
9//! dispatch boundary: a completed flow never re-runs its command (pure
10//! replay), a live holder is fenced or waited on per policy, and a re-dispatch
11//! after a failure is gated by a declared-side-effect snapshot compare
12//! (KEEL-E033) and the flow-level attempt cap (KEEL-E032).
13//!
14//! # Identity and fencing
15//!
16//! Flow identity is `(cmd:<name>, args_hash, --flow-id?)`; `args_hash =
17//! sha256(argv.join("\0"))[..16]` makes two different command lines distinct
18//! flows, and `code_hash = sha256(resolved_program_path + "\0" +
19//! argv.join("\0"))[..16]` fences replay when the program itself changes on
20//! disk. The lease holder is recorded as `"{hostname}:{pid}:{started_ms}"`;
21//! `started_ms` is kept for PID-reuse forensics but is not consulted in v1 —
22//! the lease TTL remains the reuse backstop.
23//!
24//! # Why the single step is driven through the `Journal` directly, not
25//! [`execute_step`](keel_core::FlowHandle::execute_step)
26//!
27//! A terminal `error` record would be replay-SUBSTITUTED on the next entry
28//! (rule 3 of the tier-2 semantics), so a failed command could never be re-run
29//! — but re-running after a failure IS `keel exec`'s retry model (gated by the
30//! snapshot compare + attempt cap). The single step is therefore driven
31//! through the [`Journal`] directly; [`FlowManager`] still owns
32//! identity/lease/attempts/completion, and a crashed run (a `running` step)
33//! resumes exactly like any tier-2 crash. Substitution is honored where it is
34//! correct: the completed-flow pure-replay path. This is the one-step v1 shape;
35//! multi-step `cmd` flows would revisit it.
36
37use std::io::{Read, Write};
38use std::path::{Path, PathBuf};
39use std::sync::Arc;
40use std::time::Duration;
41
42use keel_core::{Engine, FlowDescriptor, FlowHandle, FlowManager};
43use keel_core_api::policy::OnBusy;
44use keel_core_api::{ErrorClass, ErrorCode};
45use keel_journal::{
46    Clock, FlowId, FlowStatus, Journal, ProcessId, SqliteJournal, StepKey, StepKind, StepOutcome,
47    StepStatus, SystemClock,
48};
49use serde::{Deserialize, Serialize};
50use serde_json::{Value, json};
51
52use crate::render::to_json;
53use crate::{EXIT_FAILURE, EXIT_OK, EXIT_USAGE, Rendered, evidence, flows};
54
55/// The reserved marker seq holding the pre-run declared-side-effect snapshot.
56/// High above any real step count (steps are numbered from 1) and below
57/// [`keel_core`]'s branch lane (`BRANCH_SEQ_BASE = 1_000_000`) so the two
58/// reserved lanes never collide.
59const SNAP_BEFORE_SEQ: u64 = 500_000;
60/// The reserved marker seq holding the post-run snapshot (the record
61/// `keel trace` shows as the flow's after-state).
62const SNAP_AFTER_SEQ: u64 = 500_001;
63
64/// The single command step's seq. A `cmd` flow has exactly one step in v1.
65const STEP_SEQ: u64 = 1;
66
67/// How many trailing bytes of the child's stdout/stderr are captured into the
68/// step's trace tail (the live view is still forwarded byte-for-byte).
69const TAIL_CAP: usize = 4096;
70
71/// The schema tag stamped into every step payload — identical to
72/// `keel-core/src/flow.rs::STEP_PAYLOAD_SCHEMA` and `replay.rs`, so `keel
73/// replay --step` decodes an exec step's payload the same way it decodes any
74/// other step's.
75const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
76
77/// `keel exec` options (the parsed clap surface).
78#[derive(Debug, Clone)]
79pub struct ExecOptions {
80    /// Flow name; becomes the `cmd:<name>` entrypoint. `[a-z0-9][a-z0-9-]*`.
81    pub flow: String,
82    /// Explicit flow identity key (default: derived from name + argv).
83    pub flow_id: Option<String>,
84    /// Declared side-effect files: line count + content hash recorded
85    /// before/after; a change across a failed run gates re-dispatch (E033).
86    pub journal_files: Vec<PathBuf>,
87    /// Override the KEEL-E033 side-effect gate and re-dispatch anyway.
88    pub force: bool,
89    /// The command to run, after `--` (program then args).
90    pub command: Vec<String>,
91}
92
93// ---- pure, unit-testable pieces ------------------------------------------
94
95/// Whether `name` matches the CCR flow-name grammar `[a-z0-9][a-z0-9-]*`.
96fn valid_flow_name(name: &str) -> bool {
97    let mut chars = name.chars();
98    match chars.next() {
99        Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
100        _ => return false,
101    }
102    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
103}
104
105/// The first 16 hex chars of `sha256(bytes)` — the identity digest width.
106fn sha16(bytes: &[u8]) -> String {
107    use sha2::{Digest, Sha256};
108    let mut hasher = Sha256::new();
109    hasher.update(bytes);
110    format!("{:x}", hasher.finalize())[..16].to_owned()
111}
112
113/// `args_hash`: the identity digest over the full argv (NUL-joined so no argv
114/// value can forge a boundary).
115fn args_hash(command: &[String]) -> String {
116    sha16(command.join("\u{0}").as_bytes())
117}
118
119/// `code_hash`: fences replay across a changed program binary — the resolved
120/// program path plus the argv.
121fn code_hash(command: &[String]) -> String {
122    let program = resolve_program(&command[0]);
123    sha16(format!("{program}\u{0}{}", command.join("\u{0}")).as_bytes())
124}
125
126/// argv[0] through a PATH lookup (`which`-style); unresolvable -> verbatim (the
127/// hash still fences argv changes).
128fn resolve_program(argv0: &str) -> String {
129    if argv0.contains('/') {
130        return argv0.to_owned();
131    }
132    let Some(path) = std::env::var_os("PATH") else {
133        return argv0.to_owned();
134    };
135    for dir in std::env::split_paths(&path) {
136        let candidate = dir.join(argv0);
137        if candidate.is_file() {
138            return candidate.display().to_string();
139        }
140    }
141    argv0.to_owned()
142}
143
144/// A parsed `keel exec` lease holder.
145#[derive(Debug)]
146struct Holder<'a> {
147    host: &'a str,
148    pid: u32,
149}
150
151/// Format a lease holder: `"{hostname}:{pid}:{started_ms}"`.
152fn holder_string(host: &str, pid: u32, started_ms: i64) -> String {
153    format!("{host}:{pid}:{started_ms}")
154}
155
156/// Parse a `keel exec` holder (`"host:pid:started_ms"`). Legacy/foreign holder
157/// formats yield `None` — then only the lease TTL arbitrates.
158fn parse_holder(s: &str) -> Option<Holder<'_>> {
159    let mut parts = s.rsplitn(3, ':');
160    let _started: i64 = parts.next()?.parse().ok()?;
161    let pid: u32 = parts.next()?.parse().ok()?;
162    let host = parts.next()?;
163    Some(Holder { host, pid })
164}
165
166/// A declared side-effect file's recorded shape (line count + content hash).
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168struct FileSnap {
169    path: String,
170    exists: bool,
171    lines: u64,
172    sha256: String,
173}
174
175/// Snapshot each declared file's current `(exists, line_count, sha256)`.
176// `naive_bytecount`: adding the `bytecount` crate for a snapshot taken once per
177// exec (not a hot path) is not worth a new dependency.
178#[allow(clippy::naive_bytecount)]
179fn snapshot_files(files: &[PathBuf]) -> Vec<FileSnap> {
180    files
181        .iter()
182        .map(|p| match std::fs::read(p) {
183            Ok(bytes) => FileSnap {
184                path: p.display().to_string(),
185                exists: true,
186                lines: bytes.iter().filter(|&&b| b == b'\n').count() as u64,
187                sha256: sha16(&bytes),
188            },
189            Err(_) => FileSnap {
190                path: p.display().to_string(),
191                exists: false,
192                lines: 0,
193                sha256: String::new(),
194            },
195        })
196        .collect()
197}
198
199/// Paths whose recorded snapshot differs from the current one. A file with no
200/// recorded entry is NOT flagged (newly declared — nothing to compare).
201fn changed_files(recorded: &[FileSnap], current: &[FileSnap]) -> Vec<String> {
202    current
203        .iter()
204        .filter(|now| {
205            recorded
206                .iter()
207                .find(|r| r.path == now.path)
208                .is_some_and(|r| r != *now)
209        })
210        .map(|s| s.path.clone())
211        .collect()
212}
213
214// ---- payload codec (mirrors keel-core's schema-tagged envelope) -----------
215
216/// The schema-tagged step-payload envelope, written by reference (no clone).
217#[derive(Serialize)]
218struct StepPayloadRef<'a> {
219    schema: &'a str,
220    payload: &'a Value,
221}
222
223/// The owned form read back before its tag is verified.
224#[derive(Deserialize)]
225struct StepPayloadOwned {
226    schema: String,
227    payload: Value,
228}
229
230/// MessagePack-encode a step payload with its schema tag (journal.sql:
231/// `steps.payload` is "MessagePack, schema-tagged") — the exact convention the
232/// core writes and `replay.rs` reads.
233fn encode_payload(value: &Value) -> Option<Vec<u8>> {
234    rmp_serde::to_vec_named(&StepPayloadRef {
235        schema: STEP_PAYLOAD_SCHEMA,
236        payload: value,
237    })
238    .ok()
239}
240
241/// Decode a step payload: the schema-tagged envelope, else a bare value.
242fn decode_payload(bytes: &[u8]) -> Option<Value> {
243    if let Ok(envelope) = rmp_serde::from_slice::<StepPayloadOwned>(bytes)
244        && envelope.schema == STEP_PAYLOAD_SCHEMA
245    {
246        return Some(envelope.payload);
247    }
248    rmp_serde::from_slice(bytes).ok()
249}
250
251// ---- host/process probes (the only unsafe in this crate) ------------------
252
253/// This host's name, for fencing the dead-PID probe to the machine that
254/// recorded the holder. Uses `gethostname` on unix; elsewhere (and on any
255/// failure) an environment fallback. v1 simplification: the value only needs to
256/// be *stable per host* across two exec invocations — a shared journal spanning
257/// hosts that both fall back to `"localhost"` would be a false host match, but
258/// the v1 `file:` journal is machine-local.
259#[cfg(unix)]
260fn hostname() -> String {
261    let mut buf = [0u8; 256];
262    // SAFETY: `gethostname` writes at most `buf.len()` bytes into `buf` and
263    // NUL-terminates within it; we only read the returned prefix up to the NUL.
264    let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
265    if rc == 0 {
266        let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
267        if let Ok(s) = std::str::from_utf8(&buf[..end])
268            && !s.is_empty()
269        {
270            return s.to_owned();
271        }
272    }
273    env_hostname()
274}
275
276#[cfg(not(unix))]
277fn hostname() -> String {
278    env_hostname()
279}
280
281/// Best-effort hostname from the environment, defaulting to `"localhost"`.
282fn env_hostname() -> String {
283    std::env::var("HOSTNAME")
284        .or_else(|_| std::env::var("COMPUTERNAME"))
285        .unwrap_or_else(|_| "localhost".to_owned())
286}
287
288/// Whether `pid` is definitively gone on this host: `kill(pid, 0)` failing with
289/// `ESRCH`. A live process (or `EPERM`) reads as alive. Non-unix cannot probe
290/// portably, so it treats the holder as alive (the lease TTL is the backstop).
291#[cfg(unix)]
292fn pid_is_dead(pid: u32) -> bool {
293    let Ok(pid) = libc::pid_t::try_from(pid) else {
294        return false;
295    };
296    // SAFETY: `kill` with signal 0 performs the existence/permission check
297    // without delivering a signal; it has no memory effects.
298    let rc = unsafe { libc::kill(pid, 0) };
299    rc != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
300}
301
302#[cfg(not(unix))]
303fn pid_is_dead(_pid: u32) -> bool {
304    false
305}
306
307// ---- child process (tee-and-tail) ----------------------------------------
308
309/// The outcome of running the child: its exit code, the trailing tails, and
310/// whether the spawn itself failed (127).
311struct SpawnResult {
312    exit_code: i32,
313    stdout_tail: String,
314    stderr_tail: String,
315    spawn_failed: bool,
316}
317
318/// Forward `reader` to the parent's `stdout`/`stderr` byte-for-byte while
319/// keeping the last [`TAIL_CAP`] bytes as a decoded (lossy) tail.
320fn tee<R: Read>(mut reader: R, to_stderr: bool) -> String {
321    let mut ring: Vec<u8> = Vec::new();
322    let mut buf = [0u8; 8192];
323    loop {
324        match reader.read(&mut buf) {
325            Ok(0) | Err(_) => break,
326            Ok(n) => {
327                let chunk = &buf[..n];
328                if to_stderr {
329                    let _ = std::io::stderr().write_all(chunk);
330                } else {
331                    let _ = std::io::stdout().write_all(chunk);
332                }
333                ring.extend_from_slice(chunk);
334                if ring.len() > TAIL_CAP {
335                    let drop = ring.len() - TAIL_CAP;
336                    ring.drain(..drop);
337                }
338            }
339        }
340    }
341    String::from_utf8_lossy(&ring).into_owned()
342}
343
344/// The child's exit code, mapping a signal death to `128 + signal` on unix.
345fn exit_code_of(status: std::process::ExitStatus) -> i32 {
346    #[cfg(unix)]
347    {
348        use std::os::unix::process::ExitStatusExt;
349        status
350            .code()
351            .unwrap_or_else(|| status.signal().map_or(1, |s| 128 + s))
352    }
353    #[cfg(not(unix))]
354    {
355        status.code().unwrap_or(1)
356    }
357}
358
359/// Spawn `command`, teeing stdout/stderr to the parent while capturing tails.
360fn run_child(command: &[String]) -> SpawnResult {
361    use std::process::{Command, Stdio};
362    let mut cmd = Command::new(&command[0]);
363    cmd.args(&command[1..])
364        .stdout(Stdio::piped())
365        .stderr(Stdio::piped());
366    let mut child = match cmd.spawn() {
367        Ok(c) => c,
368        Err(e) => {
369            eprintln!("keel \u{25b8} exec: could not spawn `{}`: {e}", command[0]);
370            return SpawnResult {
371                exit_code: 127,
372                stdout_tail: String::new(),
373                stderr_tail: String::new(),
374                spawn_failed: true,
375            };
376        }
377    };
378    let out = child.stdout.take().expect("stdout piped");
379    let err = child.stderr.take().expect("stderr piped");
380    let out_h = std::thread::spawn(move || tee(out, false));
381    let err_h = std::thread::spawn(move || tee(err, true));
382    let status = child.wait();
383    let stdout_tail = out_h.join().unwrap_or_default();
384    let stderr_tail = err_h.join().unwrap_or_default();
385    let exit_code = status.map_or(127, exit_code_of);
386    SpawnResult {
387        exit_code,
388        stdout_tail,
389        stderr_tail,
390        spawn_failed: false,
391    }
392}
393
394// ---- the report ----------------------------------------------------------
395
396/// The `keel exec` `--json` twin.
397#[derive(Debug, Serialize)]
398struct ExecReport {
399    exit_code: i32,
400    flow_id: String,
401    entrypoint: String,
402    replayed: bool,
403    skipped: bool,
404    forced: bool,
405    journal_files: Vec<FileSnap>,
406}
407
408impl ExecReport {
409    fn render(self, human: String, to_stderr: bool) -> Rendered {
410        let exit = self.exit_code;
411        Rendered {
412            human,
413            json: to_json(&self),
414            exit,
415            to_stderr,
416        }
417    }
418}
419
420/// A usage error (exit 2, on stderr) — mirrors `resume.rs`'s `usage_pair`.
421fn usage_pair(message: &str) -> (Option<Rendered>, i32) {
422    #[derive(Serialize)]
423    struct UsageReport<'a> {
424        error: &'static str,
425        what: &'a str,
426    }
427    let r = Rendered {
428        human: format!("keel \u{25b8} {message}"),
429        json: to_json(&UsageReport {
430            error: "bad-usage",
431            what: message,
432        }),
433        exit: EXIT_USAGE,
434        to_stderr: true,
435    };
436    (Some(r), EXIT_USAGE)
437}
438
439/// A soft error (exit 1, on stderr) — mirrors `keel status`/`resume`.
440fn soft_pair(message: &str) -> (Option<Rendered>, i32) {
441    let r = flows::soft_error(message);
442    let code = r.exit;
443    (Some(r), code)
444}
445
446// ---- orchestration -------------------------------------------------------
447
448/// `keel exec` for `project`: wrap one command as a `cmd:` durable flow.
449///
450/// Returns the rendered report (if any) and the process exit code: the child's
451/// own exit code on a live/replay run, 127 on a spawn failure, 0 on a
452/// busy-skip, 1 on a refusal (E032/E033/E030-fail), 2 on a usage error.
453pub fn run(project: &Path, options: &ExecOptions) -> (Option<Rendered>, i32) {
454    // 1. Validate the name and the command.
455    if !valid_flow_name(&options.flow) {
456        return usage_pair(&format!(
457            "--flow must match [a-z0-9][a-z0-9-]* (the CCR flow-name grammar); got {:?}.",
458            options.flow
459        ));
460    }
461    if options.command.is_empty() {
462        return usage_pair(
463            "the command after `--` must be non-empty: `keel exec --flow <name> -- <program> \
464             [args…]`.",
465        );
466    }
467
468    // 2. Open the journal READ-WRITE, the way keel-core's journal_backend does
469    //    for a `file:` location. Every exec-time read AND write goes through
470    //    this ONE instance (issue #14: no second in-process reader).
471    let journal_path = evidence::resolved_journal(project).path;
472    if let Some(parent) = journal_path.parent()
473        && !parent.as_os_str().is_empty()
474        && let Err(e) = std::fs::create_dir_all(parent)
475    {
476        return soft_pair(&format!("could not create {}: {e}", parent.display()));
477    }
478    let journal: Arc<dyn Journal> = match SqliteJournal::open(&journal_path, SystemClock) {
479        Ok(j) => Arc::new(j),
480        Err(e) => {
481            return soft_pair(&format!(
482                "could not open the journal at {}: {e}",
483                journal_path.display()
484            ));
485        }
486    };
487
488    // 3. Derive identity and construct the manager over the SAME journal.
489    let entrypoint = format!("cmd:{}", options.flow);
490    let args_h = args_hash(&options.command);
491    let step_key = StepKey::new(format!("{entrypoint}#{args_h}"));
492    let desc = FlowDescriptor {
493        entrypoint: entrypoint.clone(),
494        args_hash: args_h,
495        explicit_key: options.flow_id.clone(),
496        code_hash: Some(code_hash(&options.command)),
497    };
498    let flow_id = desc.flow_id();
499
500    let started_ms = SystemClock.now_ms();
501    let holder = holder_string(&hostname(), std::process::id(), started_ms);
502    let engine = Arc::new(Engine::new());
503    let clock: Arc<dyn Clock> = Arc::new(SystemClock);
504    let manager = FlowManager::new(engine, Arc::clone(&journal), clock, ProcessId::new(holder));
505
506    // 4. Pre-entry side-effect gate (KEEL-E033).
507    if let Some(refusal) = side_effect_gate(
508        journal.as_ref(),
509        &flow_id,
510        &entrypoint,
511        &options.journal_files,
512        options.force,
513    ) {
514        return refusal;
515    }
516
517    // 5. Enter (with dead-PID abandonment / on_busy retry loop).
518    let mut handle = match enter_loop(&manager, journal.as_ref(), project, &desc, &flow_id) {
519        Ok(handle) => handle,
520        Err(terminal) => return terminal,
521    };
522
523    // 6. Completed flow -> pure replay-skip: do NOT spawn.
524    if handle.is_replay_only() {
525        let code = recorded_exit(journal.as_ref(), &flow_id);
526        let report = ExecReport {
527            exit_code: code,
528            flow_id: flow_id.to_string(),
529            entrypoint,
530            replayed: true,
531            skipped: false,
532            forced: options.force,
533            journal_files: snapshot_files(&options.journal_files),
534        };
535        let human = format!(
536            "keel \u{25b8} exec: flow {flow_id} already completed \u{2014} replaying recorded \
537             outcome (exit {code}); the command is NOT re-run.\n"
538        );
539        return (Some(report.render(human, false)), code);
540    }
541
542    // 7. Live run.
543    let result = live_run(journal.as_ref(), &flow_id, &step_key, options);
544    if result.exit_code == 0 && !result.spawn_failed {
545        handle.complete_success();
546    } else {
547        handle.complete_failed();
548    }
549    let code = result.exit_code;
550    let report = ExecReport {
551        exit_code: code,
552        flow_id: flow_id.to_string(),
553        entrypoint,
554        replayed: false,
555        skipped: false,
556        forced: options.force,
557        journal_files: snapshot_files(&options.journal_files),
558    };
559    let human = format!("keel \u{25b8} exec: flow {flow_id} exited {code}.\n");
560    (Some(report.render(human, code != EXIT_OK)), code)
561}
562
563/// The pre-entry side-effect gate (KEEL-E033). `Some(terminal)` refuses the
564/// re-dispatch; `None` proceeds. Only a `running`/`failed` flow with a recorded
565/// pre-run snapshot is gated; `--force` prints a loud note and proceeds.
566fn side_effect_gate(
567    journal: &dyn Journal,
568    flow_id: &FlowId,
569    entrypoint: &str,
570    journal_files: &[PathBuf],
571    force: bool,
572) -> Option<(Option<Rendered>, i32)> {
573    let flow = journal.get_flow(flow_id).ok().flatten()?;
574    if !matches!(flow.status, FlowStatus::Running | FlowStatus::Failed) {
575        return None;
576    }
577    let (_, marker) = journal.step_at(flow_id, SNAP_BEFORE_SEQ).ok().flatten()?;
578    let recorded: Vec<FileSnap> = marker
579        .payload
580        .as_deref()
581        .and_then(decode_payload)
582        .and_then(|v| v.get("files").cloned())
583        .and_then(|f| serde_json::from_value(f).ok())
584        .unwrap_or_default();
585    let current = snapshot_files(journal_files);
586    let changed = changed_files(&recorded, &current);
587    if changed.is_empty() {
588        return None;
589    }
590    if force {
591        eprintln!(
592            "keel \u{25b8} exec --force: {} declared side-effect file(s) changed since the last \
593             attempt ({}); re-dispatching anyway (KEEL-E033 overridden).",
594            changed.len(),
595            changed.join(", ")
596        );
597        return None;
598    }
599    let message = format!(
600        "refusing to re-dispatch flow {flow_id} ({entrypoint}): {} declared side-effect file(s) \
601         changed since the last attempt ({}). The previous run left partial effects; re-running \
602         could duplicate them. Re-run with --force to override (KEEL-E033); see `keel explain \
603         KEEL-E033`.",
604        changed.len(),
605        changed.join(", ")
606    );
607    Some(soft_pair(&message))
608}
609
610/// Enter the flow, handling KEEL-E030 (dead-PID abandonment or `on_busy`) and
611/// KEEL-E032 (dead flow). `Ok(handle)` is a live-or-replay handle; `Err` is a
612/// terminal `(rendered, code)` to return from [`run`].
613fn enter_loop(
614    manager: &FlowManager,
615    journal: &dyn Journal,
616    project: &Path,
617    desc: &FlowDescriptor,
618    flow_id: &FlowId,
619) -> Result<FlowHandle, (Option<Rendered>, i32)> {
620    let mut wait_iters: u32 = 0;
621    loop {
622        match manager.enter_flow(desc) {
623            Ok(handle) => return Ok(handle),
624            Err(e) => match e.code {
625                ErrorCode::FlowLeaseHeld => {
626                    if let Some(terminal) =
627                        handle_busy(journal, project, flow_id, &e.message, &mut wait_iters)
628                    {
629                        return Err(terminal);
630                    }
631                    // Retry: an abandoned dead holder, or an `on_busy = wait`
632                    // sleep, has cleared the way.
633                }
634                ErrorCode::FlowDead => {
635                    return Err(soft_pair(&format!(
636                        "{} Inspect with `keel trace {flow_id}`; see `keel explain KEEL-E032`.",
637                        e.message
638                    )));
639                }
640                _ => {
641                    return Err(soft_pair(&format!(
642                        "could not enter flow {flow_id}: {}",
643                        e.message
644                    )));
645                }
646            },
647        }
648    }
649}
650
651/// How many `wait`-mode iterations ([`Duration::from_millis`]`(500)` apart)
652/// between "still waiting" safety prints — `60 * 500ms = 30s`. No timeout in
653/// v1 (the operator can ^C); this print is the only feedback a long wait
654/// gives, so it must actually recur, not fire once and go silent.
655const WAIT_NOTICE_EVERY: u32 = 60;
656
657/// Handle a held lease: abandon a dead same-host holder (then retry), else
658/// apply `[flows].on_busy`. `Some(terminal)` ends the run; `None` retries.
659/// `wait_iters` threads the `wait`-mode iteration count across calls (one
660/// call per [`enter_loop`] retry) so the safety print fires on a cadence
661/// instead of every 500ms or never.
662fn handle_busy(
663    journal: &dyn Journal,
664    project: &Path,
665    flow_id: &FlowId,
666    e030_message: &str,
667    wait_iters: &mut u32,
668) -> Option<(Option<Rendered>, i32)> {
669    let recorded_holder = journal
670        .get_flow(flow_id)
671        .ok()
672        .flatten()
673        .and_then(|f| f.lease_holder);
674    if let Some(holder) = &recorded_holder
675        && let Some(parsed) = parse_holder(holder.as_str())
676        && parsed.host == hostname()
677        && pid_is_dead(parsed.pid)
678    {
679        eprintln!(
680            "keel \u{25b8} exec: abandoning flow {flow_id} held by dead pid {} on this host.",
681            parsed.pid
682        );
683        // complete_flow(Failed) clears the lease; the re-entry consumes an
684        // attempt (cap -> Dead/KEEL-E032, unchanged).
685        if let Err(err) = journal.complete_flow(flow_id, FlowStatus::Failed) {
686            eprintln!("keel \u{25b8} exec: could not abandon dead-held flow {flow_id}: {err}");
687        }
688        return None;
689    }
690
691    match flows_on_busy(project) {
692        OnBusy::Skip => {
693            let report = ExecReport {
694                exit_code: EXIT_OK,
695                flow_id: flow_id.to_string(),
696                entrypoint: String::new(),
697                replayed: false,
698                skipped: true,
699                forced: false,
700                journal_files: Vec::new(),
701            };
702            let human = format!(
703                "keel \u{25b8} exec: flow {flow_id} is busy (held by a live process); skipping \
704                 (flows.on_busy = skip).\n"
705            );
706            Some((Some(report.render(human, false)), EXIT_OK))
707        }
708        OnBusy::Wait => {
709            *wait_iters += 1;
710            if (*wait_iters).is_multiple_of(WAIT_NOTICE_EVERY) {
711                let holder = recorded_holder
712                    .as_ref()
713                    .map_or("unknown", ProcessId::as_str);
714                eprintln!(
715                    "keel \u{25b8} exec: still waiting on flow {flow_id} held by {holder} \
716                     ({}s elapsed; flows.on_busy = wait; ^C to give up).",
717                    (*wait_iters / 2) // 500ms per iteration -> iters/2 = seconds
718                );
719            }
720            std::thread::sleep(Duration::from_millis(500));
721            None
722        }
723        OnBusy::Fail => Some(soft_pair(&format!(
724            "{e030_message} (flows.on_busy = fail); see `keel explain KEEL-E030`."
725        ))),
726    }
727}
728
729/// The effective `[flows].on_busy` from the project's `keel.toml` (default
730/// `skip`), via the CLI's one shared `keel.toml`\u{2192}[`Policy`] loader
731/// ([`evidence::load_policy`] — the same pipeline `resolved_journal` reads).
732/// Lenient: any read/parse failure applies the default.
733fn flows_on_busy(project: &Path) -> OnBusy {
734    evidence::load_policy(project)
735        .and_then(|p| p.flows)
736        .map_or_else(OnBusy::default, |f| f.on_busy)
737}
738
739/// The recorded exit code for a completed flow's single command step: 0 for an
740/// `ok` record, else the recorded payload's `exit_code`.
741fn recorded_exit(journal: &dyn Journal, flow_id: &FlowId) -> i32 {
742    match journal.step_at(flow_id, STEP_SEQ) {
743        Ok(Some((_, outcome))) if outcome.status == StepStatus::Ok => EXIT_OK,
744        Ok(Some((_, outcome))) => outcome
745            .payload
746            .as_deref()
747            .and_then(decode_payload)
748            .and_then(|v| v.get("exit_code").and_then(Value::as_i64))
749            .and_then(|c| i32::try_from(c).ok())
750            .unwrap_or(EXIT_FAILURE),
751        _ => EXIT_OK,
752    }
753}
754
755/// The live path: record the before snapshot marker, the `running` step, spawn
756/// and tee the child, record the terminal step and the after snapshot marker.
757/// All journal-write failures degrade to a stderr warning (resilience first).
758fn live_run(
759    journal: &dyn Journal,
760    flow_id: &FlowId,
761    step_key: &StepKey,
762    options: &ExecOptions,
763) -> SpawnResult {
764    let program = resolve_program(&options.command[0]);
765    let before = json!({
766        "files": snapshot_files(&options.journal_files),
767        "argv": options.command,
768        "program": program,
769    });
770    record_marker(
771        journal,
772        flow_id,
773        SNAP_BEFORE_SEQ,
774        "cmd:snapshot:before",
775        &before,
776    );
777
778    let start = SystemClock.now_ms();
779    record_step(
780        journal,
781        flow_id,
782        step_key,
783        &StepOutcome {
784            kind: StepKind::Subprocess,
785            attempt: 0,
786            status: StepStatus::Running,
787            payload: None,
788            error_class: None,
789            started_at: start,
790            ended_at: None,
791        },
792    );
793
794    let result = run_child(&options.command);
795
796    let end = SystemClock.now_ms();
797    let (status, error_class) = if result.exit_code == 0 && !result.spawn_failed {
798        (StepStatus::Ok, None)
799    } else {
800        (StepStatus::Error, Some(ErrorClass::Other))
801    };
802    let terminal_payload = json!({
803        "exit_code": result.exit_code,
804        "stdout_tail": result.stdout_tail,
805        "stderr_tail": result.stderr_tail,
806    });
807    record_step(
808        journal,
809        flow_id,
810        step_key,
811        &StepOutcome {
812            kind: StepKind::Subprocess,
813            attempt: 1,
814            status,
815            payload: encode_payload(&terminal_payload),
816            error_class,
817            started_at: start,
818            ended_at: Some(end),
819        },
820    );
821
822    let after = json!({
823        "files": snapshot_files(&options.journal_files),
824        "argv": options.command,
825        "program": program,
826    });
827    record_marker(
828        journal,
829        flow_id,
830        SNAP_AFTER_SEQ,
831        "cmd:snapshot:after",
832        &after,
833    );
834
835    result
836}
837
838/// Record a reserved-lane marker, degrading a journal failure to a warning.
839fn record_marker(journal: &dyn Journal, flow_id: &FlowId, seq: u64, key: &str, payload: &Value) {
840    let now = SystemClock.now_ms();
841    let outcome = StepOutcome {
842        kind: StepKind::Marker,
843        attempt: 0,
844        status: StepStatus::Ok,
845        payload: encode_payload(payload),
846        error_class: None,
847        started_at: now,
848        ended_at: Some(now),
849    };
850    if let Err(e) = journal.record_step(flow_id, seq, &StepKey::new(key), &outcome) {
851        eprintln!("keel \u{25b8} exec: {key} marker not journaled: {e}");
852    }
853}
854
855/// Record the command step, degrading a journal failure to a warning (a lost
856/// record costs replay dedup, never correctness — the `running` marker's
857/// absence just makes a resume re-run the command).
858fn record_step(journal: &dyn Journal, flow_id: &FlowId, key: &StepKey, outcome: &StepOutcome) {
859    if let Err(e) = journal.record_step(flow_id, STEP_SEQ, key, outcome) {
860        eprintln!("keel \u{25b8} exec: step {STEP_SEQ} not journaled: {e}");
861    }
862}
863
864// ---- test support ----------------------------------------------------
865//
866// `tests/exec.rs` seeds journal rows directly (the same technique
867// `resume`-style tests use to simulate a foreign holder) to exercise
868// on_busy/dead-PID paths without a second real process. A seeded row that
869// does not share the EXACT `flow_id` `run` derives collides with nothing and
870// the test passes vacuously, so the derivation (entrypoint/args_hash
871// composition, the holder-string format) is exposed here rather than
872// re-implemented (and silently drifted) in the test file.
873
874/// The `flow_id` [`run`] derives for `(flow, command, flow_id_key)` — for
875/// integration tests seeding a colliding row.
876#[doc(hidden)]
877#[must_use]
878pub fn identity_flow_id(flow: &str, command: &[String], flow_id_key: Option<&str>) -> String {
879    FlowDescriptor {
880        entrypoint: format!("cmd:{flow}"),
881        args_hash: args_hash(command),
882        explicit_key: flow_id_key.map(str::to_owned),
883        code_hash: None,
884    }
885    .flow_id()
886    .to_string()
887}
888
889/// This host's name, as [`run`] records it in a lease holder — for
890/// integration tests constructing a same-host (or foreign-host) holder.
891#[doc(hidden)]
892#[must_use]
893pub fn identity_hostname() -> String {
894    hostname()
895}
896
897/// The `"{host}:{pid}:{started_ms}"` lease holder format [`run`] writes — for
898/// integration tests seeding a foreign holder.
899#[doc(hidden)]
900#[must_use]
901pub fn identity_holder_string(host: &str, pid: u32, started_ms: i64) -> String {
902    holder_string(host, pid, started_ms)
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    #[test]
910    fn flow_name_grammar_is_enforced() {
911        assert!(valid_flow_name("autonomous-run"));
912        assert!(valid_flow_name("a1"));
913        assert!(!valid_flow_name("Autonomous"));
914        assert!(!valid_flow_name("-x"));
915        assert!(!valid_flow_name(""));
916        assert!(!valid_flow_name("a_b"));
917    }
918
919    #[test]
920    fn identity_is_deterministic_and_argv_sensitive() {
921        let a = args_hash(&["uvx".into(), "server".into()]);
922        let b = args_hash(&["uvx".into(), "server".into()]);
923        let c = args_hash(&["uvx".into(), "other".into()]);
924        assert_eq!(a, b);
925        assert_ne!(a, c);
926        assert_eq!(a.len(), 16);
927    }
928
929    #[test]
930    fn holder_round_trips_and_detects_shape() {
931        let h = holder_string("myhost", 4242, 1_783_728_000_000);
932        let parsed = parse_holder(&h).unwrap();
933        assert_eq!(parsed.host, "myhost");
934        assert_eq!(parsed.pid, 4242);
935        assert!(
936            parse_holder("host-a:pid-1").is_none(),
937            "legacy holders don't parse"
938        );
939    }
940
941    #[test]
942    fn snapshot_compare_flags_growth_change_and_missing() {
943        let dir = tempfile::TempDir::new().unwrap();
944        let f = dir.path().join("trades.jsonl");
945        std::fs::write(&f, "a\nb\n").unwrap();
946        let one = std::slice::from_ref(&f);
947        let before = snapshot_files(one);
948        assert!(changed_files(&before, &snapshot_files(one)).is_empty());
949        std::fs::write(&f, "a\nb\nc\n").unwrap();
950        assert_eq!(
951            changed_files(&before, &snapshot_files(one)),
952            vec![f.display().to_string()]
953        );
954        std::fs::remove_file(&f).unwrap();
955        assert_eq!(changed_files(&before, &snapshot_files(one)).len(), 1);
956    }
957
958    #[test]
959    fn payload_codec_round_trips_and_reads_bare() {
960        let value = json!({ "exit_code": 3, "stdout_tail": "hi" });
961        let bytes = encode_payload(&value).expect("encodes");
962        assert_eq!(decode_payload(&bytes), Some(value));
963    }
964}