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 reserved marker seq holding the one-shot KEEL-E033 force override
65/// (CCR-6): a durable "the next gated re-dispatch of this flow may proceed
66/// even though its declared side-effect files changed", armed by
67/// [`request_force_override`] (the `keel flows force <flow-id>` verb) and
68/// consumed+cleared by [`take_force_override`] inside [`side_effect_gate`].
69/// Sits in the same reserved lane as the two snapshot markers, below
70/// [`keel_core`]'s branch lane (`BRANCH_SEQ_BASE = 1_000_000`), so it never
71/// collides with a real step (numbered from 1) or a branch marker. Like the
72/// snapshot markers it is `kind = 'marker'`, so `keel flows`/`keel trace`
73/// (which filter `kind != 'marker'`) never surface it as real work.
74const FORCE_SEQ: u64 = 500_002;
75
76/// The `step_key` of the [`FORCE_SEQ`] marker.
77const FORCE_STEP_KEY: &str = "cmd:force";
78
79/// The [`FORCE_SEQ`] marker payload's `state` when the one-shot force is armed
80/// (set by `keel flows force`, not yet consumed by a gate check).
81const FORCE_STATE_REQUESTED: &str = "requested";
82/// The [`FORCE_SEQ`] marker payload's `state` after a gate check has consumed
83/// the one-shot force. The row is overwritten (not deleted — the [`Journal`]
84/// trait exposes no delete) so it stays visible for `keel trace` forensics
85/// while no longer arming any future attempt.
86const FORCE_STATE_CONSUMED: &str = "consumed";
87
88/// The single command step's seq. A `cmd` flow has exactly one step in v1.
89const STEP_SEQ: u64 = 1;
90
91/// How many trailing bytes of the child's stdout/stderr are captured into the
92/// step's trace tail (the live view is still forwarded byte-for-byte).
93const TAIL_CAP: usize = 4096;
94
95/// The schema tag stamped into every step payload — identical to
96/// `keel-core/src/flow.rs::STEP_PAYLOAD_SCHEMA` and `replay.rs`, so `keel
97/// replay --step` decodes an exec step's payload the same way it decodes any
98/// other step's.
99const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
100
101/// `keel exec` options (the parsed clap surface).
102#[derive(Debug, Clone)]
103pub struct ExecOptions {
104    /// Flow name; becomes the `cmd:<name>` entrypoint. `[a-z0-9][a-z0-9-]*`.
105    pub flow: String,
106    /// Explicit flow identity key (default: derived from name + argv).
107    pub flow_id: Option<String>,
108    /// Declared side-effect files: line count + content hash recorded
109    /// before/after; a change across a failed run gates re-dispatch (E033).
110    pub journal_files: Vec<PathBuf>,
111    /// Override the KEEL-E033 side-effect gate and re-dispatch anyway.
112    pub force: bool,
113    /// The command to run, after `--` (program then args).
114    pub command: Vec<String>,
115}
116
117// ---- pure, unit-testable pieces ------------------------------------------
118
119/// Whether `name` matches the CCR flow-name grammar `[a-z0-9][a-z0-9-]*`.
120fn valid_flow_name(name: &str) -> bool {
121    let mut chars = name.chars();
122    match chars.next() {
123        Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
124        _ => return false,
125    }
126    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
127}
128
129/// The first 16 hex chars of `sha256(bytes)` — the identity digest width.
130fn sha16(bytes: &[u8]) -> String {
131    use sha2::{Digest, Sha256};
132    let mut hasher = Sha256::new();
133    hasher.update(bytes);
134    format!("{:x}", hasher.finalize())[..16].to_owned()
135}
136
137/// `args_hash`: the identity digest over the full argv (NUL-joined so no argv
138/// value can forge a boundary).
139fn args_hash(command: &[String]) -> String {
140    sha16(command.join("\u{0}").as_bytes())
141}
142
143/// `code_hash`: fences replay across a changed program binary — the resolved
144/// program path plus the argv.
145fn code_hash(command: &[String]) -> String {
146    let program = resolve_program(&command[0]);
147    sha16(format!("{program}\u{0}{}", command.join("\u{0}")).as_bytes())
148}
149
150/// argv[0] through a PATH lookup (`which`-style); unresolvable -> verbatim (the
151/// hash still fences argv changes).
152fn resolve_program(argv0: &str) -> String {
153    if argv0.contains('/') {
154        return argv0.to_owned();
155    }
156    let Some(path) = std::env::var_os("PATH") else {
157        return argv0.to_owned();
158    };
159    for dir in std::env::split_paths(&path) {
160        let candidate = dir.join(argv0);
161        if candidate.is_file() {
162            return candidate.display().to_string();
163        }
164    }
165    argv0.to_owned()
166}
167
168/// A parsed `keel exec` lease holder.
169#[derive(Debug)]
170struct Holder<'a> {
171    host: &'a str,
172    pid: u32,
173}
174
175/// Format a lease holder: `"{hostname}:{pid}:{started_ms}"`.
176fn holder_string(host: &str, pid: u32, started_ms: i64) -> String {
177    format!("{host}:{pid}:{started_ms}")
178}
179
180/// Parse a `keel exec` holder (`"host:pid:started_ms"`). Legacy/foreign holder
181/// formats yield `None` — then only the lease TTL arbitrates.
182fn parse_holder(s: &str) -> Option<Holder<'_>> {
183    let mut parts = s.rsplitn(3, ':');
184    let _started: i64 = parts.next()?.parse().ok()?;
185    let pid: u32 = parts.next()?.parse().ok()?;
186    let host = parts.next()?;
187    Some(Holder { host, pid })
188}
189
190/// A declared side-effect file's recorded shape (line count + content hash).
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192struct FileSnap {
193    path: String,
194    exists: bool,
195    lines: u64,
196    sha256: String,
197}
198
199/// Snapshot each declared file's current `(exists, line_count, sha256)`.
200// `naive_bytecount`: adding the `bytecount` crate for a snapshot taken once per
201// exec (not a hot path) is not worth a new dependency.
202#[allow(clippy::naive_bytecount)]
203fn snapshot_files(files: &[PathBuf]) -> Vec<FileSnap> {
204    files
205        .iter()
206        .map(|p| match std::fs::read(p) {
207            Ok(bytes) => FileSnap {
208                path: p.display().to_string(),
209                exists: true,
210                lines: bytes.iter().filter(|&&b| b == b'\n').count() as u64,
211                sha256: sha16(&bytes),
212            },
213            Err(_) => FileSnap {
214                path: p.display().to_string(),
215                exists: false,
216                lines: 0,
217                sha256: String::new(),
218            },
219        })
220        .collect()
221}
222
223/// Paths whose recorded snapshot differs from the current one. A file with no
224/// recorded entry is NOT flagged (newly declared — nothing to compare).
225fn changed_files(recorded: &[FileSnap], current: &[FileSnap]) -> Vec<String> {
226    current
227        .iter()
228        .filter(|now| {
229            recorded
230                .iter()
231                .find(|r| r.path == now.path)
232                .is_some_and(|r| r != *now)
233        })
234        .map(|s| s.path.clone())
235        .collect()
236}
237
238// ---- payload codec (mirrors keel-core's schema-tagged envelope) -----------
239
240/// The schema-tagged step-payload envelope, written by reference (no clone).
241#[derive(Serialize)]
242struct StepPayloadRef<'a> {
243    schema: &'a str,
244    payload: &'a Value,
245}
246
247/// The owned form read back before its tag is verified.
248#[derive(Deserialize)]
249struct StepPayloadOwned {
250    schema: String,
251    payload: Value,
252}
253
254/// MessagePack-encode a step payload with its schema tag (journal.sql:
255/// `steps.payload` is "MessagePack, schema-tagged") — the exact convention the
256/// core writes and `replay.rs` reads.
257fn encode_payload(value: &Value) -> Option<Vec<u8>> {
258    rmp_serde::to_vec_named(&StepPayloadRef {
259        schema: STEP_PAYLOAD_SCHEMA,
260        payload: value,
261    })
262    .ok()
263}
264
265/// Decode a step payload: the schema-tagged envelope, else a bare value.
266fn decode_payload(bytes: &[u8]) -> Option<Value> {
267    if let Ok(envelope) = rmp_serde::from_slice::<StepPayloadOwned>(bytes)
268        && envelope.schema == STEP_PAYLOAD_SCHEMA
269    {
270        return Some(envelope.payload);
271    }
272    rmp_serde::from_slice(bytes).ok()
273}
274
275// ---- host/process probes (the only unsafe in this crate) ------------------
276
277/// This host's name, for fencing the dead-PID probe to the machine that
278/// recorded the holder. Uses `gethostname` on unix; elsewhere (and on any
279/// failure) an environment fallback. v1 simplification: the value only needs to
280/// be *stable per host* across two exec invocations — a shared journal spanning
281/// hosts that both fall back to `"localhost"` would be a false host match, but
282/// the v1 `file:` journal is machine-local.
283#[cfg(unix)]
284fn hostname() -> String {
285    let mut buf = [0u8; 256];
286    // SAFETY: `gethostname` writes at most `buf.len()` bytes into `buf` and
287    // NUL-terminates within it; we only read the returned prefix up to the NUL.
288    let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
289    if rc == 0 {
290        let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
291        if let Ok(s) = std::str::from_utf8(&buf[..end])
292            && !s.is_empty()
293        {
294            return s.to_owned();
295        }
296    }
297    env_hostname()
298}
299
300#[cfg(not(unix))]
301fn hostname() -> String {
302    env_hostname()
303}
304
305/// Best-effort hostname from the environment, defaulting to `"localhost"`.
306fn env_hostname() -> String {
307    std::env::var("HOSTNAME")
308        .or_else(|_| std::env::var("COMPUTERNAME"))
309        .unwrap_or_else(|_| "localhost".to_owned())
310}
311
312/// Whether `pid` is definitively gone on this host: `kill(pid, 0)` failing with
313/// `ESRCH`. A live process (or `EPERM`) reads as alive. Non-unix cannot probe
314/// portably, so it treats the holder as alive (the lease TTL is the backstop).
315#[cfg(unix)]
316fn pid_is_dead(pid: u32) -> bool {
317    let Ok(pid) = libc::pid_t::try_from(pid) else {
318        return false;
319    };
320    // SAFETY: `kill` with signal 0 performs the existence/permission check
321    // without delivering a signal; it has no memory effects.
322    let rc = unsafe { libc::kill(pid, 0) };
323    rc != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
324}
325
326#[cfg(not(unix))]
327fn pid_is_dead(_pid: u32) -> bool {
328    false
329}
330
331// ---- child process (tee-and-tail) ----------------------------------------
332
333/// The outcome of running the child: its exit code, the trailing tails, and
334/// whether the spawn itself failed (127).
335struct SpawnResult {
336    exit_code: i32,
337    stdout_tail: String,
338    stderr_tail: String,
339    spawn_failed: bool,
340}
341
342/// Forward `reader` to the parent's `stdout`/`stderr` byte-for-byte while
343/// keeping the last [`TAIL_CAP`] bytes as a decoded (lossy) tail.
344fn tee<R: Read>(mut reader: R, to_stderr: bool) -> String {
345    let mut ring: Vec<u8> = Vec::new();
346    let mut buf = [0u8; 8192];
347    loop {
348        match reader.read(&mut buf) {
349            Ok(0) | Err(_) => break,
350            Ok(n) => {
351                let chunk = &buf[..n];
352                if to_stderr {
353                    let _ = std::io::stderr().write_all(chunk);
354                } else {
355                    let _ = std::io::stdout().write_all(chunk);
356                }
357                ring.extend_from_slice(chunk);
358                if ring.len() > TAIL_CAP {
359                    let drop = ring.len() - TAIL_CAP;
360                    ring.drain(..drop);
361                }
362            }
363        }
364    }
365    String::from_utf8_lossy(&ring).into_owned()
366}
367
368/// The child's exit code, mapping a signal death to `128 + signal` on unix.
369fn exit_code_of(status: std::process::ExitStatus) -> i32 {
370    #[cfg(unix)]
371    {
372        use std::os::unix::process::ExitStatusExt;
373        status
374            .code()
375            .unwrap_or_else(|| status.signal().map_or(1, |s| 128 + s))
376    }
377    #[cfg(not(unix))]
378    {
379        status.code().unwrap_or(1)
380    }
381}
382
383/// Spawn `command`, teeing stdout/stderr to the parent while capturing tails.
384fn run_child(command: &[String]) -> SpawnResult {
385    use std::process::{Command, Stdio};
386    let mut cmd = Command::new(&command[0]);
387    cmd.args(&command[1..])
388        .stdout(Stdio::piped())
389        .stderr(Stdio::piped());
390    let mut child = match cmd.spawn() {
391        Ok(c) => c,
392        Err(e) => {
393            eprintln!("keel \u{25b8} exec: could not spawn `{}`: {e}", command[0]);
394            return SpawnResult {
395                exit_code: 127,
396                stdout_tail: String::new(),
397                stderr_tail: String::new(),
398                spawn_failed: true,
399            };
400        }
401    };
402    let out = child.stdout.take().expect("stdout piped");
403    let err = child.stderr.take().expect("stderr piped");
404    let out_h = std::thread::spawn(move || tee(out, false));
405    let err_h = std::thread::spawn(move || tee(err, true));
406    let status = child.wait();
407    let stdout_tail = out_h.join().unwrap_or_default();
408    let stderr_tail = err_h.join().unwrap_or_default();
409    let exit_code = status.map_or(127, exit_code_of);
410    SpawnResult {
411        exit_code,
412        stdout_tail,
413        stderr_tail,
414        spawn_failed: false,
415    }
416}
417
418// ---- the report ----------------------------------------------------------
419
420/// The `keel exec` `--json` twin.
421#[derive(Debug, Serialize)]
422struct ExecReport {
423    exit_code: i32,
424    flow_id: String,
425    entrypoint: String,
426    replayed: bool,
427    skipped: bool,
428    forced: bool,
429    journal_files: Vec<FileSnap>,
430}
431
432impl ExecReport {
433    fn render(self, human: String, to_stderr: bool) -> Rendered {
434        let exit = self.exit_code;
435        Rendered {
436            human,
437            json: to_json(&self),
438            exit,
439            to_stderr,
440        }
441    }
442}
443
444/// A usage error (exit 2, on stderr) — mirrors `resume.rs`'s `usage_pair`.
445fn usage_pair(message: &str) -> (Option<Rendered>, i32) {
446    #[derive(Serialize)]
447    struct UsageReport<'a> {
448        error: &'static str,
449        what: &'a str,
450    }
451    let r = Rendered {
452        human: format!("keel \u{25b8} {message}"),
453        json: to_json(&UsageReport {
454            error: "bad-usage",
455            what: message,
456        }),
457        exit: EXIT_USAGE,
458        to_stderr: true,
459    };
460    (Some(r), EXIT_USAGE)
461}
462
463/// A soft error (exit 1, on stderr) — mirrors `keel status`/`resume`.
464fn soft_pair(message: &str) -> (Option<Rendered>, i32) {
465    let r = flows::soft_error(message);
466    let code = r.exit;
467    (Some(r), code)
468}
469
470// ---- orchestration -------------------------------------------------------
471
472/// `keel exec` for `project`: wrap one command as a `cmd:` durable flow.
473///
474/// Returns the rendered report (if any) and the process exit code: the child's
475/// own exit code on a live/replay run, 127 on a spawn failure, 0 on a
476/// busy-skip, 1 on a refusal (E032/E033/E030-fail), 2 on a usage error.
477pub fn run(project: &Path, options: &ExecOptions) -> (Option<Rendered>, i32) {
478    // 1. Validate the name and the command.
479    if !valid_flow_name(&options.flow) {
480        return usage_pair(&format!(
481            "--flow must match [a-z0-9][a-z0-9-]* (the CCR flow-name grammar); got {:?}.",
482            options.flow
483        ));
484    }
485    if options.command.is_empty() {
486        return usage_pair(
487            "the command after `--` must be non-empty: `keel exec --flow <name> -- <program> \
488             [args…]`.",
489        );
490    }
491
492    // 2. Open the journal READ-WRITE, the way keel-core's journal_backend does
493    //    for a `file:` location. Every exec-time read AND write goes through
494    //    this ONE instance (issue #14: no second in-process reader).
495    let journal_path = evidence::resolved_journal(project).path;
496    if let Some(parent) = journal_path.parent()
497        && !parent.as_os_str().is_empty()
498        && let Err(e) = std::fs::create_dir_all(parent)
499    {
500        return soft_pair(&format!("could not create {}: {e}", parent.display()));
501    }
502    let journal: Arc<dyn Journal> = match SqliteJournal::open(&journal_path, SystemClock) {
503        Ok(j) => Arc::new(j),
504        Err(e) => {
505            return soft_pair(&format!(
506                "could not open the journal at {}: {e}",
507                journal_path.display()
508            ));
509        }
510    };
511
512    // 3. Derive identity and construct the manager over the SAME journal.
513    let entrypoint = format!("cmd:{}", options.flow);
514    let args_h = args_hash(&options.command);
515    let step_key = StepKey::new(format!("{entrypoint}#{args_h}"));
516    let desc = FlowDescriptor {
517        entrypoint: entrypoint.clone(),
518        args_hash: args_h,
519        explicit_key: options.flow_id.clone(),
520        code_hash: Some(code_hash(&options.command)),
521    };
522    let flow_id = desc.flow_id();
523
524    let started_ms = SystemClock.now_ms();
525    let holder = holder_string(&hostname(), std::process::id(), started_ms);
526    let engine = Arc::new(Engine::new());
527    let clock: Arc<dyn Clock> = Arc::new(SystemClock);
528    let manager = FlowManager::new(engine, Arc::clone(&journal), clock, ProcessId::new(holder));
529
530    // 4. Pre-entry side-effect gate (KEEL-E033).
531    if let Some(refusal) = side_effect_gate(
532        journal.as_ref(),
533        &flow_id,
534        &entrypoint,
535        &options.journal_files,
536        options.force,
537    ) {
538        return refusal;
539    }
540
541    // 5. Enter (with dead-PID abandonment / on_busy retry loop).
542    let mut handle = match enter_loop(&manager, journal.as_ref(), project, &desc, &flow_id) {
543        Ok(handle) => handle,
544        Err(terminal) => return terminal,
545    };
546
547    // 6. Completed flow -> pure replay-skip: do NOT spawn.
548    if handle.is_replay_only() {
549        let code = recorded_exit(journal.as_ref(), &flow_id);
550        let report = ExecReport {
551            exit_code: code,
552            flow_id: flow_id.to_string(),
553            entrypoint,
554            replayed: true,
555            skipped: false,
556            forced: options.force,
557            journal_files: snapshot_files(&options.journal_files),
558        };
559        let human = format!(
560            "keel \u{25b8} exec: flow {flow_id} already completed \u{2014} replaying recorded \
561             outcome (exit {code}); the command is NOT re-run.\n"
562        );
563        return (Some(report.render(human, false)), code);
564    }
565
566    // 7. Live run.
567    let result = live_run(journal.as_ref(), &flow_id, &step_key, options);
568    let completion = if result.exit_code == 0 && !result.spawn_failed {
569        handle.complete_success()
570    } else {
571        handle.complete_failed()
572    };
573    if let Err(e) = completion {
574        eprintln!("keel \u{25b8} exec: flow {flow_id} terminal status not journaled: {e}");
575    }
576    let code = result.exit_code;
577    let report = ExecReport {
578        exit_code: code,
579        flow_id: flow_id.to_string(),
580        entrypoint,
581        replayed: false,
582        skipped: false,
583        forced: options.force,
584        journal_files: snapshot_files(&options.journal_files),
585    };
586    let human = format!("keel \u{25b8} exec: flow {flow_id} exited {code}.\n");
587    (Some(report.render(human, code != EXIT_OK)), code)
588}
589
590/// The pre-entry side-effect gate (KEEL-E033). `Some(terminal)` refuses the
591/// re-dispatch; `None` proceeds. Only a `running`/`failed` flow with a recorded
592/// pre-run snapshot is gated; `--force` prints a loud note and proceeds.
593fn side_effect_gate(
594    journal: &dyn Journal,
595    flow_id: &FlowId,
596    entrypoint: &str,
597    journal_files: &[PathBuf],
598    force: bool,
599) -> Option<(Option<Rendered>, i32)> {
600    let flow = journal.get_flow(flow_id).ok().flatten()?;
601    if !matches!(flow.status, FlowStatus::Running | FlowStatus::Failed) {
602        return None;
603    }
604    let (_, marker) = journal.step_at(flow_id, SNAP_BEFORE_SEQ).ok().flatten()?;
605    let recorded: Vec<FileSnap> = marker
606        .payload
607        .as_deref()
608        .and_then(decode_payload)
609        .and_then(|v| v.get("files").cloned())
610        .and_then(|f| serde_json::from_value(f).ok())
611        .unwrap_or_default();
612    let current = snapshot_files(journal_files);
613    let changed = changed_files(&recorded, &current);
614    if changed.is_empty() {
615        return None;
616    }
617    // A durable one-shot force (CCR-6), armed out-of-band by `keel flows force
618    // <flow-id>`, bypasses the gate for exactly THIS attempt. Read-then-clear:
619    // consuming it here (and only here, where files actually changed and the
620    // gate would otherwise refuse) is what makes it one-shot — a later attempt
621    // with no fresh `keel flows force` is gated again. Consumed before the
622    // in-memory `--force` branch so it does real work; left untouched by the
623    // earlier no-change/no-snapshot bypasses so it is never spent needlessly.
624    if take_force_override(journal, flow_id) {
625        eprintln!(
626            "keel \u{25b8} exec: {} declared side-effect file(s) changed since the last attempt \
627             ({}); re-dispatching anyway (KEEL-E033 overridden by a persistent `keel flows force` \
628             request \u{2014} one-shot, now cleared).",
629            changed.len(),
630            changed.join(", ")
631        );
632        return None;
633    }
634    if force {
635        eprintln!(
636            "keel \u{25b8} exec --force: {} declared side-effect file(s) changed since the last \
637             attempt ({}); re-dispatching anyway (KEEL-E033 overridden).",
638            changed.len(),
639            changed.join(", ")
640        );
641        return None;
642    }
643    let message = format!(
644        "refusing to re-dispatch flow {flow_id} ({entrypoint}): {} declared side-effect file(s) \
645         changed since the last attempt ({}). The previous run left partial effects; re-running \
646         could duplicate them. Re-run with --force to override (KEEL-E033); see `keel explain \
647         KEEL-E033`.",
648        changed.len(),
649        changed.join(", ")
650    );
651    Some(soft_pair(&message))
652}
653
654/// Arm the one-shot KEEL-E033 force override for `flow_id` (CCR-6): the next
655/// [`side_effect_gate`] check that would otherwise refuse this flow's
656/// re-dispatch (its declared side-effect files changed) proceeds instead, and
657/// clears the flag as it does so.
658///
659/// This is the durable primitive behind `keel flows force <flow-id>` — the
660/// out-of-process, config-free equivalent of `keel exec --force` (CCR-5
661/// decision 2). Unlike `--force` (a purely in-memory per-invocation flag),
662/// this persists a marker step so the override survives across processes and
663/// applies even when no process is currently running against the flow. It
664/// writes through the **same** [`Journal`] handle every exec-time read/write
665/// uses — no second in-process SQLite reader (issue #14).
666///
667/// The caller (the `keel flows force` verb) is responsible for the UX
668/// preconditions: the flow row must already exist (`steps.flow_id` is a
669/// foreign key into `flows`, so arming a non-existent flow errors here) and
670/// should be `running`/`failed` — the only statuses [`side_effect_gate`] gates
671/// — for the arming to have any effect.
672///
673/// # Errors
674/// Propagates any [`Journal::record_step`] failure (e.g. the flow does not
675/// exist, or the store is unwritable).
676pub fn request_force_override(journal: &dyn Journal, flow_id: &FlowId) -> keel_journal::Result<()> {
677    let now = SystemClock.now_ms();
678    let payload = json!({ "state": FORCE_STATE_REQUESTED, "requested_at": now });
679    let outcome = StepOutcome {
680        kind: StepKind::Marker,
681        attempt: 0,
682        status: StepStatus::Ok,
683        payload: encode_payload(&payload),
684        error_class: None,
685        started_at: now,
686        ended_at: Some(now),
687    };
688    journal.record_step(flow_id, FORCE_SEQ, &StepKey::new(FORCE_STEP_KEY), &outcome)
689}
690
691/// Read-and-clear the one-shot force override (CCR-6): `true` iff a `requested`
692/// [`FORCE_SEQ`] marker was present, in which case it is overwritten to
693/// `consumed` before returning so it arms no further attempt. Best-effort and
694/// lenient like the rest of [`side_effect_gate`] — any journal read/write error
695/// reads as "not forced" (`false`), never a panic or a refusal-turned-crash.
696///
697/// The read (`step_at`) and the clear (`record_step` overwrite) are two
698/// autocommit statements, not one transaction: the [`Journal`] trait exposes
699/// no cross-statement transaction, and none is needed here. A hypothetical
700/// double-consume (two concurrent execs both reading `requested` before either
701/// clears) cannot double-run the command — flow entry still serializes on the
702/// single lease ([`FlowManager::enter_flow`]), so at most one re-dispatch
703/// actually proceeds. `keel flows force` is a rare, deliberate operator action,
704/// so the benign race is accepted rather than paid for with a new trait method.
705fn take_force_override(journal: &dyn Journal, flow_id: &FlowId) -> bool {
706    let Ok(Some((_, marker))) = journal.step_at(flow_id, FORCE_SEQ) else {
707        return false;
708    };
709    let armed = marker
710        .payload
711        .as_deref()
712        .and_then(decode_payload)
713        .and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_owned))
714        .is_some_and(|state| state == FORCE_STATE_REQUESTED);
715    if !armed {
716        return false;
717    }
718    let now = SystemClock.now_ms();
719    let payload = json!({ "state": FORCE_STATE_CONSUMED, "consumed_at": now });
720    let outcome = StepOutcome {
721        kind: StepKind::Marker,
722        attempt: 0,
723        status: StepStatus::Ok,
724        payload: encode_payload(&payload),
725        error_class: None,
726        started_at: now,
727        ended_at: Some(now),
728    };
729    if let Err(e) = journal.record_step(flow_id, FORCE_SEQ, &StepKey::new(FORCE_STEP_KEY), &outcome)
730    {
731        // The override fired (we proceed), but clearing it failed: warn loudly
732        // so a stuck-armed flag is visible rather than silently forcing forever.
733        eprintln!(
734            "keel \u{25b8} exec: force override consumed but its one-shot clear was not journaled \
735             ({e}); a later attempt may be forced again \u{2014} inspect `keel trace {flow_id}`."
736        );
737    }
738    true
739}
740
741/// Enter the flow, handling KEEL-E030 (dead-PID abandonment or `on_busy`) and
742/// KEEL-E032 (dead flow). `Ok(handle)` is a live-or-replay handle; `Err` is a
743/// terminal `(rendered, code)` to return from [`run`].
744fn enter_loop(
745    manager: &FlowManager,
746    journal: &dyn Journal,
747    project: &Path,
748    desc: &FlowDescriptor,
749    flow_id: &FlowId,
750) -> Result<FlowHandle, (Option<Rendered>, i32)> {
751    let mut wait_iters: u32 = 0;
752    loop {
753        match manager.enter_flow(desc) {
754            Ok(handle) => return Ok(handle),
755            Err(e) => match e.code {
756                ErrorCode::FlowLeaseHeld => {
757                    if let Some(terminal) =
758                        handle_busy(journal, project, flow_id, &e.message, &mut wait_iters)
759                    {
760                        return Err(terminal);
761                    }
762                    // Retry: an abandoned dead holder, or an `on_busy = wait`
763                    // sleep, has cleared the way.
764                }
765                ErrorCode::FlowDead => {
766                    return Err(soft_pair(&format!(
767                        "{} Inspect with `keel trace {flow_id}`; see `keel explain KEEL-E032`.",
768                        e.message
769                    )));
770                }
771                _ => {
772                    return Err(soft_pair(&format!(
773                        "could not enter flow {flow_id}: {}",
774                        e.message
775                    )));
776                }
777            },
778        }
779    }
780}
781
782/// How many `wait`-mode iterations ([`Duration::from_millis`]`(500)` apart)
783/// between "still waiting" safety prints — `60 * 500ms = 30s`. No timeout in
784/// v1 (the operator can ^C); this print is the only feedback a long wait
785/// gives, so it must actually recur, not fire once and go silent.
786const WAIT_NOTICE_EVERY: u32 = 60;
787
788/// Handle a held lease: abandon a dead same-host holder (then retry), else
789/// apply `[flows].on_busy`. `Some(terminal)` ends the run; `None` retries.
790/// `wait_iters` threads the `wait`-mode iteration count across calls (one
791/// call per [`enter_loop`] retry) so the safety print fires on a cadence
792/// instead of every 500ms or never.
793fn handle_busy(
794    journal: &dyn Journal,
795    project: &Path,
796    flow_id: &FlowId,
797    e030_message: &str,
798    wait_iters: &mut u32,
799) -> Option<(Option<Rendered>, i32)> {
800    let recorded_holder = journal
801        .get_flow(flow_id)
802        .ok()
803        .flatten()
804        .and_then(|f| f.lease_holder);
805    if let Some(holder) = &recorded_holder
806        && let Some(parsed) = parse_holder(holder.as_str())
807        && parsed.host == hostname()
808        && pid_is_dead(parsed.pid)
809    {
810        eprintln!(
811            "keel \u{25b8} exec: abandoning flow {flow_id} held by dead pid {} on this host.",
812            parsed.pid
813        );
814        // complete_flow(Failed) clears the lease; the re-entry consumes an
815        // attempt (cap -> Dead/KEEL-E032, unchanged).
816        if let Err(err) = journal.complete_flow(flow_id, FlowStatus::Failed) {
817            eprintln!("keel \u{25b8} exec: could not abandon dead-held flow {flow_id}: {err}");
818        }
819        return None;
820    }
821
822    match flows_on_busy(project) {
823        OnBusy::Skip => {
824            let report = ExecReport {
825                exit_code: EXIT_OK,
826                flow_id: flow_id.to_string(),
827                entrypoint: String::new(),
828                replayed: false,
829                skipped: true,
830                forced: false,
831                journal_files: Vec::new(),
832            };
833            let human = format!(
834                "keel \u{25b8} exec: flow {flow_id} is busy (held by a live process); skipping \
835                 (flows.on_busy = skip).\n"
836            );
837            Some((Some(report.render(human, false)), EXIT_OK))
838        }
839        OnBusy::Wait => {
840            *wait_iters += 1;
841            if (*wait_iters).is_multiple_of(WAIT_NOTICE_EVERY) {
842                let holder = recorded_holder
843                    .as_ref()
844                    .map_or("unknown", ProcessId::as_str);
845                eprintln!(
846                    "keel \u{25b8} exec: still waiting on flow {flow_id} held by {holder} \
847                     ({}s elapsed; flows.on_busy = wait; ^C to give up).",
848                    (*wait_iters / 2) // 500ms per iteration -> iters/2 = seconds
849                );
850            }
851            std::thread::sleep(Duration::from_millis(500));
852            None
853        }
854        OnBusy::Fail => Some(soft_pair(&format!(
855            "{e030_message} (flows.on_busy = fail); see `keel explain KEEL-E030`."
856        ))),
857    }
858}
859
860/// The effective `[flows].on_busy` from the project's `keel.toml` (default
861/// `skip`), via the CLI's one shared `keel.toml`\u{2192}[`Policy`] loader
862/// ([`evidence::load_policy`] — the same pipeline `resolved_journal` reads).
863/// Lenient: any read/parse failure applies the default.
864fn flows_on_busy(project: &Path) -> OnBusy {
865    evidence::load_policy(project)
866        .and_then(|p| p.flows)
867        .map_or_else(OnBusy::default, |f| f.on_busy)
868}
869
870/// The recorded exit code for a completed flow's single command step: 0 for an
871/// `ok` record, else the recorded payload's `exit_code`.
872fn recorded_exit(journal: &dyn Journal, flow_id: &FlowId) -> i32 {
873    match journal.step_at(flow_id, STEP_SEQ) {
874        Ok(Some((_, outcome))) if outcome.status == StepStatus::Ok => EXIT_OK,
875        Ok(Some((_, outcome))) => outcome
876            .payload
877            .as_deref()
878            .and_then(decode_payload)
879            .and_then(|v| v.get("exit_code").and_then(Value::as_i64))
880            .and_then(|c| i32::try_from(c).ok())
881            .unwrap_or(EXIT_FAILURE),
882        _ => EXIT_OK,
883    }
884}
885
886/// The live path: record the before snapshot marker, the `running` step, spawn
887/// and tee the child, record the terminal step and the after snapshot marker.
888/// All journal-write failures degrade to a stderr warning (resilience first).
889fn live_run(
890    journal: &dyn Journal,
891    flow_id: &FlowId,
892    step_key: &StepKey,
893    options: &ExecOptions,
894) -> SpawnResult {
895    let program = resolve_program(&options.command[0]);
896    let before = json!({
897        "files": snapshot_files(&options.journal_files),
898        "argv": options.command,
899        "program": program,
900    });
901    record_marker(
902        journal,
903        flow_id,
904        SNAP_BEFORE_SEQ,
905        "cmd:snapshot:before",
906        &before,
907    );
908
909    let start = SystemClock.now_ms();
910    record_step(
911        journal,
912        flow_id,
913        step_key,
914        &StepOutcome {
915            kind: StepKind::Subprocess,
916            attempt: 0,
917            status: StepStatus::Running,
918            payload: None,
919            error_class: None,
920            started_at: start,
921            ended_at: None,
922        },
923    );
924
925    let result = run_child(&options.command);
926
927    let end = SystemClock.now_ms();
928    let (status, error_class) = if result.exit_code == 0 && !result.spawn_failed {
929        (StepStatus::Ok, None)
930    } else {
931        (StepStatus::Error, Some(ErrorClass::Other))
932    };
933    let terminal_payload = json!({
934        "exit_code": result.exit_code,
935        "stdout_tail": result.stdout_tail,
936        "stderr_tail": result.stderr_tail,
937    });
938    record_step(
939        journal,
940        flow_id,
941        step_key,
942        &StepOutcome {
943            kind: StepKind::Subprocess,
944            attempt: 1,
945            status,
946            payload: encode_payload(&terminal_payload),
947            error_class,
948            started_at: start,
949            ended_at: Some(end),
950        },
951    );
952
953    let after = json!({
954        "files": snapshot_files(&options.journal_files),
955        "argv": options.command,
956        "program": program,
957    });
958    record_marker(
959        journal,
960        flow_id,
961        SNAP_AFTER_SEQ,
962        "cmd:snapshot:after",
963        &after,
964    );
965
966    result
967}
968
969/// Record a reserved-lane marker, degrading a journal failure to a warning.
970fn record_marker(journal: &dyn Journal, flow_id: &FlowId, seq: u64, key: &str, payload: &Value) {
971    let now = SystemClock.now_ms();
972    let outcome = StepOutcome {
973        kind: StepKind::Marker,
974        attempt: 0,
975        status: StepStatus::Ok,
976        payload: encode_payload(payload),
977        error_class: None,
978        started_at: now,
979        ended_at: Some(now),
980    };
981    if let Err(e) = journal.record_step(flow_id, seq, &StepKey::new(key), &outcome) {
982        eprintln!("keel \u{25b8} exec: {key} marker not journaled: {e}");
983    }
984}
985
986/// Record the command step, degrading a journal failure to a warning (a lost
987/// record costs replay dedup, never correctness — the `running` marker's
988/// absence just makes a resume re-run the command).
989fn record_step(journal: &dyn Journal, flow_id: &FlowId, key: &StepKey, outcome: &StepOutcome) {
990    if let Err(e) = journal.record_step(flow_id, STEP_SEQ, key, outcome) {
991        eprintln!("keel \u{25b8} exec: step {STEP_SEQ} not journaled: {e}");
992    }
993}
994
995// ---- test support ----------------------------------------------------
996//
997// `tests/exec.rs` seeds journal rows directly (the same technique
998// `resume`-style tests use to simulate a foreign holder) to exercise
999// on_busy/dead-PID paths without a second real process. A seeded row that
1000// does not share the EXACT `flow_id` `run` derives collides with nothing and
1001// the test passes vacuously, so the derivation (entrypoint/args_hash
1002// composition, the holder-string format) is exposed here rather than
1003// re-implemented (and silently drifted) in the test file.
1004
1005/// The `flow_id` [`run`] derives for `(flow, command, flow_id_key)` — for
1006/// integration tests seeding a colliding row.
1007#[doc(hidden)]
1008#[must_use]
1009pub fn identity_flow_id(flow: &str, command: &[String], flow_id_key: Option<&str>) -> String {
1010    FlowDescriptor {
1011        entrypoint: format!("cmd:{flow}"),
1012        args_hash: args_hash(command),
1013        explicit_key: flow_id_key.map(str::to_owned),
1014        code_hash: None,
1015    }
1016    .flow_id()
1017    .to_string()
1018}
1019
1020/// This host's name, as [`run`] records it in a lease holder — for
1021/// integration tests constructing a same-host (or foreign-host) holder.
1022#[doc(hidden)]
1023#[must_use]
1024pub fn identity_hostname() -> String {
1025    hostname()
1026}
1027
1028/// The `"{host}:{pid}:{started_ms}"` lease holder format [`run`] writes — for
1029/// integration tests seeding a foreign holder.
1030#[doc(hidden)]
1031#[must_use]
1032pub fn identity_holder_string(host: &str, pid: u32, started_ms: i64) -> String {
1033    holder_string(host, pid, started_ms)
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039
1040    #[test]
1041    fn flow_name_grammar_is_enforced() {
1042        assert!(valid_flow_name("autonomous-run"));
1043        assert!(valid_flow_name("a1"));
1044        assert!(!valid_flow_name("Autonomous"));
1045        assert!(!valid_flow_name("-x"));
1046        assert!(!valid_flow_name(""));
1047        assert!(!valid_flow_name("a_b"));
1048    }
1049
1050    #[test]
1051    fn identity_is_deterministic_and_argv_sensitive() {
1052        let a = args_hash(&["uvx".into(), "server".into()]);
1053        let b = args_hash(&["uvx".into(), "server".into()]);
1054        let c = args_hash(&["uvx".into(), "other".into()]);
1055        assert_eq!(a, b);
1056        assert_ne!(a, c);
1057        assert_eq!(a.len(), 16);
1058    }
1059
1060    #[test]
1061    fn holder_round_trips_and_detects_shape() {
1062        let h = holder_string("myhost", 4242, 1_783_728_000_000);
1063        let parsed = parse_holder(&h).unwrap();
1064        assert_eq!(parsed.host, "myhost");
1065        assert_eq!(parsed.pid, 4242);
1066        assert!(
1067            parse_holder("host-a:pid-1").is_none(),
1068            "legacy holders don't parse"
1069        );
1070    }
1071
1072    #[test]
1073    fn snapshot_compare_flags_growth_change_and_missing() {
1074        let dir = tempfile::TempDir::new().unwrap();
1075        let f = dir.path().join("trades.jsonl");
1076        std::fs::write(&f, "a\nb\n").unwrap();
1077        let one = std::slice::from_ref(&f);
1078        let before = snapshot_files(one);
1079        assert!(changed_files(&before, &snapshot_files(one)).is_empty());
1080        std::fs::write(&f, "a\nb\nc\n").unwrap();
1081        assert_eq!(
1082            changed_files(&before, &snapshot_files(one)),
1083            vec![f.display().to_string()]
1084        );
1085        std::fs::remove_file(&f).unwrap();
1086        assert_eq!(changed_files(&before, &snapshot_files(one)).len(), 1);
1087    }
1088
1089    #[test]
1090    fn payload_codec_round_trips_and_reads_bare() {
1091        let value = json!({ "exit_code": 3, "stdout_tail": "hi" });
1092        let bytes = encode_payload(&value).expect("encodes");
1093        assert_eq!(decode_payload(&bytes), Some(value));
1094    }
1095
1096    /// CCR-6: `keel flows force` arms a durable one-shot KEEL-E033 override.
1097    /// The gate refuses a changed-files re-dispatch; an armed force lets exactly
1098    /// ONE attempt through and clears itself; the next attempt (no re-arm) is
1099    /// refused again — the whole point of "one-shot".
1100    #[test]
1101    fn persistent_force_override_bypasses_the_gate_exactly_once() {
1102        use keel_journal::NewFlow;
1103
1104        let dir = tempfile::TempDir::new().unwrap();
1105        let file = dir.path().join("trades.jsonl");
1106        std::fs::write(&file, "a\nb\n").unwrap();
1107
1108        let journal = SqliteJournal::open(dir.path().join("journal.db"), SystemClock).unwrap();
1109        let flow_id = FlowId::new("01FORCEFLOW");
1110        journal
1111            .begin_flow(&NewFlow {
1112                flow_id: flow_id.clone(),
1113                entrypoint: "cmd:trade".to_owned(),
1114                args_hash: "ah".to_owned(),
1115                code_hash: None,
1116            })
1117            .unwrap();
1118
1119        // The pre-run snapshot the gate compares against — the file as it was.
1120        let files = std::slice::from_ref(&file);
1121        record_marker(
1122            &journal,
1123            &flow_id,
1124            SNAP_BEFORE_SEQ,
1125            "cmd:snapshot:before",
1126            &json!({ "files": snapshot_files(files) }),
1127        );
1128
1129        // Change the declared file so the gate sees a real side effect.
1130        std::fs::write(&file, "a\nb\nc\n").unwrap();
1131
1132        let gate = || side_effect_gate(&journal, &flow_id, "cmd:trade", files, false);
1133
1134        // 1. No force armed: refused (KEEL-E033).
1135        assert!(
1136            gate().is_some(),
1137            "a changed-files re-dispatch must be gated when no force is armed"
1138        );
1139
1140        // 2. Arm the durable one-shot force → the gate proceeds this once.
1141        request_force_override(&journal, &flow_id).unwrap();
1142        assert!(
1143            gate().is_none(),
1144            "an armed persistent force must let the gate proceed"
1145        );
1146
1147        // 3. One-shot: the force was consumed by (2); a second attempt without
1148        //    a fresh `keel flows force` is refused again.
1149        assert!(
1150            gate().is_some(),
1151            "the force must be cleared after one bypass, not persist across attempts"
1152        );
1153
1154        // 4. Re-arming works — it is a fresh one-shot each time.
1155        request_force_override(&journal, &flow_id).unwrap();
1156        assert!(gate().is_none(), "re-arming grants exactly one more bypass");
1157        assert!(gate().is_some(), "…and then it is spent again");
1158    }
1159}