use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use keel_core::{Engine, FlowDescriptor, FlowHandle, FlowManager};
use keel_core_api::policy::OnBusy;
use keel_core_api::{ErrorClass, ErrorCode};
use keel_journal::{
Clock, FlowId, FlowStatus, Journal, ProcessId, SqliteJournal, StepKey, StepKind, StepOutcome,
StepStatus, SystemClock,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::render::to_json;
use crate::{EXIT_FAILURE, EXIT_OK, EXIT_USAGE, Rendered, evidence, flows};
const SNAP_BEFORE_SEQ: u64 = 500_000;
const SNAP_AFTER_SEQ: u64 = 500_001;
const FORCE_SEQ: u64 = 500_002;
const FORCE_STEP_KEY: &str = "cmd:force";
const FORCE_STATE_REQUESTED: &str = "requested";
const FORCE_STATE_CONSUMED: &str = "consumed";
const STEP_SEQ: u64 = 1;
const TAIL_CAP: usize = 4096;
const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
#[derive(Debug, Clone)]
pub struct ExecOptions {
pub flow: String,
pub flow_id: Option<String>,
pub journal_files: Vec<PathBuf>,
pub force: bool,
pub command: Vec<String>,
}
fn valid_flow_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn sha16(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())[..16].to_owned()
}
fn args_hash(command: &[String]) -> String {
sha16(command.join("\u{0}").as_bytes())
}
fn code_hash(command: &[String]) -> String {
let program = resolve_program(&command[0]);
sha16(format!("{program}\u{0}{}", command.join("\u{0}")).as_bytes())
}
fn resolve_program(argv0: &str) -> String {
if argv0.contains('/') {
return argv0.to_owned();
}
let Some(path) = std::env::var_os("PATH") else {
return argv0.to_owned();
};
for dir in std::env::split_paths(&path) {
let candidate = dir.join(argv0);
if candidate.is_file() {
return candidate.display().to_string();
}
}
argv0.to_owned()
}
#[derive(Debug)]
struct Holder<'a> {
host: &'a str,
pid: u32,
}
fn holder_string(host: &str, pid: u32, started_ms: i64) -> String {
format!("{host}:{pid}:{started_ms}")
}
fn parse_holder(s: &str) -> Option<Holder<'_>> {
let mut parts = s.rsplitn(3, ':');
let _started: i64 = parts.next()?.parse().ok()?;
let pid: u32 = parts.next()?.parse().ok()?;
let host = parts.next()?;
Some(Holder { host, pid })
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct FileSnap {
path: String,
exists: bool,
lines: u64,
sha256: String,
}
#[allow(clippy::naive_bytecount)]
fn snapshot_files(files: &[PathBuf]) -> Vec<FileSnap> {
files
.iter()
.map(|p| match std::fs::read(p) {
Ok(bytes) => FileSnap {
path: p.display().to_string(),
exists: true,
lines: bytes.iter().filter(|&&b| b == b'\n').count() as u64,
sha256: sha16(&bytes),
},
Err(_) => FileSnap {
path: p.display().to_string(),
exists: false,
lines: 0,
sha256: String::new(),
},
})
.collect()
}
fn changed_files(recorded: &[FileSnap], current: &[FileSnap]) -> Vec<String> {
current
.iter()
.filter(|now| {
recorded
.iter()
.find(|r| r.path == now.path)
.is_some_and(|r| r != *now)
})
.map(|s| s.path.clone())
.collect()
}
#[derive(Serialize)]
struct StepPayloadRef<'a> {
schema: &'a str,
payload: &'a Value,
}
#[derive(Deserialize)]
struct StepPayloadOwned {
schema: String,
payload: Value,
}
fn encode_payload(value: &Value) -> Option<Vec<u8>> {
rmp_serde::to_vec_named(&StepPayloadRef {
schema: STEP_PAYLOAD_SCHEMA,
payload: value,
})
.ok()
}
fn decode_payload(bytes: &[u8]) -> Option<Value> {
if let Ok(envelope) = rmp_serde::from_slice::<StepPayloadOwned>(bytes)
&& envelope.schema == STEP_PAYLOAD_SCHEMA
{
return Some(envelope.payload);
}
rmp_serde::from_slice(bytes).ok()
}
#[cfg(unix)]
fn hostname() -> String {
let mut buf = [0u8; 256];
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if rc == 0 {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
if let Ok(s) = std::str::from_utf8(&buf[..end])
&& !s.is_empty()
{
return s.to_owned();
}
}
env_hostname()
}
#[cfg(not(unix))]
fn hostname() -> String {
env_hostname()
}
fn env_hostname() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "localhost".to_owned())
}
#[cfg(unix)]
fn pid_is_dead(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
let rc = unsafe { libc::kill(pid, 0) };
rc != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
}
#[cfg(not(unix))]
fn pid_is_dead(_pid: u32) -> bool {
false
}
struct SpawnResult {
exit_code: i32,
stdout_tail: String,
stderr_tail: String,
spawn_failed: bool,
}
fn tee<R: Read>(mut reader: R, to_stderr: bool) -> String {
let mut ring: Vec<u8> = Vec::new();
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let chunk = &buf[..n];
if to_stderr {
let _ = std::io::stderr().write_all(chunk);
} else {
let _ = std::io::stdout().write_all(chunk);
}
ring.extend_from_slice(chunk);
if ring.len() > TAIL_CAP {
let drop = ring.len() - TAIL_CAP;
ring.drain(..drop);
}
}
}
}
String::from_utf8_lossy(&ring).into_owned()
}
fn exit_code_of(status: std::process::ExitStatus) -> i32 {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
status
.code()
.unwrap_or_else(|| status.signal().map_or(1, |s| 128 + s))
}
#[cfg(not(unix))]
{
status.code().unwrap_or(1)
}
}
fn run_child(command: &[String]) -> SpawnResult {
use std::process::{Command, Stdio};
let mut cmd = Command::new(&command[0]);
cmd.args(&command[1..])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
eprintln!("keel \u{25b8} exec: could not spawn `{}`: {e}", command[0]);
return SpawnResult {
exit_code: 127,
stdout_tail: String::new(),
stderr_tail: String::new(),
spawn_failed: true,
};
}
};
let out = child.stdout.take().expect("stdout piped");
let err = child.stderr.take().expect("stderr piped");
let out_h = std::thread::spawn(move || tee(out, false));
let err_h = std::thread::spawn(move || tee(err, true));
let status = child.wait();
let stdout_tail = out_h.join().unwrap_or_default();
let stderr_tail = err_h.join().unwrap_or_default();
let exit_code = status.map_or(127, exit_code_of);
SpawnResult {
exit_code,
stdout_tail,
stderr_tail,
spawn_failed: false,
}
}
#[derive(Debug, Serialize)]
struct ExecReport {
exit_code: i32,
flow_id: String,
entrypoint: String,
replayed: bool,
skipped: bool,
forced: bool,
journal_files: Vec<FileSnap>,
}
impl ExecReport {
fn render(self, human: String, to_stderr: bool) -> Rendered {
let exit = self.exit_code;
Rendered {
human,
json: to_json(&self),
exit,
to_stderr,
}
}
}
fn usage_pair(message: &str) -> (Option<Rendered>, i32) {
#[derive(Serialize)]
struct UsageReport<'a> {
error: &'static str,
what: &'a str,
}
let r = Rendered {
human: format!("keel \u{25b8} {message}"),
json: to_json(&UsageReport {
error: "bad-usage",
what: message,
}),
exit: EXIT_USAGE,
to_stderr: true,
};
(Some(r), EXIT_USAGE)
}
fn soft_pair(message: &str) -> (Option<Rendered>, i32) {
let r = flows::soft_error(message);
let code = r.exit;
(Some(r), code)
}
pub fn run(project: &Path, options: &ExecOptions) -> (Option<Rendered>, i32) {
if !valid_flow_name(&options.flow) {
return usage_pair(&format!(
"--flow must match [a-z0-9][a-z0-9-]* (the CCR flow-name grammar); got {:?}.",
options.flow
));
}
if options.command.is_empty() {
return usage_pair(
"the command after `--` must be non-empty: `keel exec --flow <name> -- <program> \
[args…]`.",
);
}
let journal_path = evidence::resolved_journal(project).path;
if let Some(parent) = journal_path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return soft_pair(&format!("could not create {}: {e}", parent.display()));
}
let journal: Arc<dyn Journal> = match SqliteJournal::open(&journal_path, SystemClock) {
Ok(j) => Arc::new(j),
Err(e) => {
return soft_pair(&format!(
"could not open the journal at {}: {e}",
journal_path.display()
));
}
};
let entrypoint = format!("cmd:{}", options.flow);
let args_h = args_hash(&options.command);
let step_key = StepKey::new(format!("{entrypoint}#{args_h}"));
let desc = FlowDescriptor {
entrypoint: entrypoint.clone(),
args_hash: args_h,
explicit_key: options.flow_id.clone(),
code_hash: Some(code_hash(&options.command)),
};
let flow_id = desc.flow_id();
let started_ms = SystemClock.now_ms();
let holder = holder_string(&hostname(), std::process::id(), started_ms);
let engine = Arc::new(Engine::new());
let clock: Arc<dyn Clock> = Arc::new(SystemClock);
let manager = FlowManager::new(engine, Arc::clone(&journal), clock, ProcessId::new(holder));
if let Some(refusal) = side_effect_gate(
journal.as_ref(),
&flow_id,
&entrypoint,
&options.journal_files,
options.force,
) {
return refusal;
}
let mut handle = match enter_loop(&manager, journal.as_ref(), project, &desc, &flow_id) {
Ok(handle) => handle,
Err(terminal) => return terminal,
};
if handle.is_replay_only() {
let code = recorded_exit(journal.as_ref(), &flow_id);
let report = ExecReport {
exit_code: code,
flow_id: flow_id.to_string(),
entrypoint,
replayed: true,
skipped: false,
forced: options.force,
journal_files: snapshot_files(&options.journal_files),
};
let human = format!(
"keel \u{25b8} exec: flow {flow_id} already completed \u{2014} replaying recorded \
outcome (exit {code}); the command is NOT re-run.\n"
);
return (Some(report.render(human, false)), code);
}
let result = live_run(journal.as_ref(), &flow_id, &step_key, options);
let completion = if result.exit_code == 0 && !result.spawn_failed {
handle.complete_success()
} else {
handle.complete_failed()
};
if let Err(e) = completion {
eprintln!("keel \u{25b8} exec: flow {flow_id} terminal status not journaled: {e}");
}
let code = result.exit_code;
let report = ExecReport {
exit_code: code,
flow_id: flow_id.to_string(),
entrypoint,
replayed: false,
skipped: false,
forced: options.force,
journal_files: snapshot_files(&options.journal_files),
};
let human = format!("keel \u{25b8} exec: flow {flow_id} exited {code}.\n");
(Some(report.render(human, code != EXIT_OK)), code)
}
fn side_effect_gate(
journal: &dyn Journal,
flow_id: &FlowId,
entrypoint: &str,
journal_files: &[PathBuf],
force: bool,
) -> Option<(Option<Rendered>, i32)> {
let flow = journal.get_flow(flow_id).ok().flatten()?;
if !matches!(flow.status, FlowStatus::Running | FlowStatus::Failed) {
return None;
}
let (_, marker) = journal.step_at(flow_id, SNAP_BEFORE_SEQ).ok().flatten()?;
let recorded: Vec<FileSnap> = marker
.payload
.as_deref()
.and_then(decode_payload)
.and_then(|v| v.get("files").cloned())
.and_then(|f| serde_json::from_value(f).ok())
.unwrap_or_default();
let current = snapshot_files(journal_files);
let changed = changed_files(&recorded, ¤t);
if changed.is_empty() {
return None;
}
if take_force_override(journal, flow_id) {
eprintln!(
"keel \u{25b8} exec: {} declared side-effect file(s) changed since the last attempt \
({}); re-dispatching anyway (KEEL-E033 overridden by a persistent `keel flows force` \
request \u{2014} one-shot, now cleared).",
changed.len(),
changed.join(", ")
);
return None;
}
if force {
eprintln!(
"keel \u{25b8} exec --force: {} declared side-effect file(s) changed since the last \
attempt ({}); re-dispatching anyway (KEEL-E033 overridden).",
changed.len(),
changed.join(", ")
);
return None;
}
let message = format!(
"refusing to re-dispatch flow {flow_id} ({entrypoint}): {} declared side-effect file(s) \
changed since the last attempt ({}). The previous run left partial effects; re-running \
could duplicate them. Re-run with --force to override (KEEL-E033); see `keel explain \
KEEL-E033`.",
changed.len(),
changed.join(", ")
);
Some(soft_pair(&message))
}
pub fn request_force_override(journal: &dyn Journal, flow_id: &FlowId) -> keel_journal::Result<()> {
let now = SystemClock.now_ms();
let payload = json!({ "state": FORCE_STATE_REQUESTED, "requested_at": now });
let outcome = StepOutcome {
kind: StepKind::Marker,
attempt: 0,
status: StepStatus::Ok,
payload: encode_payload(&payload),
error_class: None,
started_at: now,
ended_at: Some(now),
};
journal.record_step(flow_id, FORCE_SEQ, &StepKey::new(FORCE_STEP_KEY), &outcome)
}
fn take_force_override(journal: &dyn Journal, flow_id: &FlowId) -> bool {
let Ok(Some((_, marker))) = journal.step_at(flow_id, FORCE_SEQ) else {
return false;
};
let armed = marker
.payload
.as_deref()
.and_then(decode_payload)
.and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_owned))
.is_some_and(|state| state == FORCE_STATE_REQUESTED);
if !armed {
return false;
}
let now = SystemClock.now_ms();
let payload = json!({ "state": FORCE_STATE_CONSUMED, "consumed_at": now });
let outcome = StepOutcome {
kind: StepKind::Marker,
attempt: 0,
status: StepStatus::Ok,
payload: encode_payload(&payload),
error_class: None,
started_at: now,
ended_at: Some(now),
};
if let Err(e) = journal.record_step(flow_id, FORCE_SEQ, &StepKey::new(FORCE_STEP_KEY), &outcome)
{
eprintln!(
"keel \u{25b8} exec: force override consumed but its one-shot clear was not journaled \
({e}); a later attempt may be forced again \u{2014} inspect `keel trace {flow_id}`."
);
}
true
}
fn enter_loop(
manager: &FlowManager,
journal: &dyn Journal,
project: &Path,
desc: &FlowDescriptor,
flow_id: &FlowId,
) -> Result<FlowHandle, (Option<Rendered>, i32)> {
let mut wait_iters: u32 = 0;
loop {
match manager.enter_flow(desc) {
Ok(handle) => return Ok(handle),
Err(e) => match e.code {
ErrorCode::FlowLeaseHeld => {
if let Some(terminal) =
handle_busy(journal, project, flow_id, &e.message, &mut wait_iters)
{
return Err(terminal);
}
}
ErrorCode::FlowDead => {
return Err(soft_pair(&format!(
"{} Inspect with `keel trace {flow_id}`; see `keel explain KEEL-E032`.",
e.message
)));
}
_ => {
return Err(soft_pair(&format!(
"could not enter flow {flow_id}: {}",
e.message
)));
}
},
}
}
}
const WAIT_NOTICE_EVERY: u32 = 60;
fn handle_busy(
journal: &dyn Journal,
project: &Path,
flow_id: &FlowId,
e030_message: &str,
wait_iters: &mut u32,
) -> Option<(Option<Rendered>, i32)> {
let recorded_holder = journal
.get_flow(flow_id)
.ok()
.flatten()
.and_then(|f| f.lease_holder);
if let Some(holder) = &recorded_holder
&& let Some(parsed) = parse_holder(holder.as_str())
&& parsed.host == hostname()
&& pid_is_dead(parsed.pid)
{
eprintln!(
"keel \u{25b8} exec: abandoning flow {flow_id} held by dead pid {} on this host.",
parsed.pid
);
if let Err(err) = journal.complete_flow(flow_id, FlowStatus::Failed) {
eprintln!("keel \u{25b8} exec: could not abandon dead-held flow {flow_id}: {err}");
}
return None;
}
match flows_on_busy(project) {
OnBusy::Skip => {
let report = ExecReport {
exit_code: EXIT_OK,
flow_id: flow_id.to_string(),
entrypoint: String::new(),
replayed: false,
skipped: true,
forced: false,
journal_files: Vec::new(),
};
let human = format!(
"keel \u{25b8} exec: flow {flow_id} is busy (held by a live process); skipping \
(flows.on_busy = skip).\n"
);
Some((Some(report.render(human, false)), EXIT_OK))
}
OnBusy::Wait => {
*wait_iters += 1;
if (*wait_iters).is_multiple_of(WAIT_NOTICE_EVERY) {
let holder = recorded_holder
.as_ref()
.map_or("unknown", ProcessId::as_str);
eprintln!(
"keel \u{25b8} exec: still waiting on flow {flow_id} held by {holder} \
({}s elapsed; flows.on_busy = wait; ^C to give up).",
(*wait_iters / 2) );
}
std::thread::sleep(Duration::from_millis(500));
None
}
OnBusy::Fail => Some(soft_pair(&format!(
"{e030_message} (flows.on_busy = fail); see `keel explain KEEL-E030`."
))),
}
}
fn flows_on_busy(project: &Path) -> OnBusy {
evidence::load_policy(project)
.and_then(|p| p.flows)
.map_or_else(OnBusy::default, |f| f.on_busy)
}
fn recorded_exit(journal: &dyn Journal, flow_id: &FlowId) -> i32 {
match journal.step_at(flow_id, STEP_SEQ) {
Ok(Some((_, outcome))) if outcome.status == StepStatus::Ok => EXIT_OK,
Ok(Some((_, outcome))) => outcome
.payload
.as_deref()
.and_then(decode_payload)
.and_then(|v| v.get("exit_code").and_then(Value::as_i64))
.and_then(|c| i32::try_from(c).ok())
.unwrap_or(EXIT_FAILURE),
_ => EXIT_OK,
}
}
fn live_run(
journal: &dyn Journal,
flow_id: &FlowId,
step_key: &StepKey,
options: &ExecOptions,
) -> SpawnResult {
let program = resolve_program(&options.command[0]);
let before = json!({
"files": snapshot_files(&options.journal_files),
"argv": options.command,
"program": program,
});
record_marker(
journal,
flow_id,
SNAP_BEFORE_SEQ,
"cmd:snapshot:before",
&before,
);
let start = SystemClock.now_ms();
record_step(
journal,
flow_id,
step_key,
&StepOutcome {
kind: StepKind::Subprocess,
attempt: 0,
status: StepStatus::Running,
payload: None,
error_class: None,
started_at: start,
ended_at: None,
},
);
let result = run_child(&options.command);
let end = SystemClock.now_ms();
let (status, error_class) = if result.exit_code == 0 && !result.spawn_failed {
(StepStatus::Ok, None)
} else {
(StepStatus::Error, Some(ErrorClass::Other))
};
let terminal_payload = json!({
"exit_code": result.exit_code,
"stdout_tail": result.stdout_tail,
"stderr_tail": result.stderr_tail,
});
record_step(
journal,
flow_id,
step_key,
&StepOutcome {
kind: StepKind::Subprocess,
attempt: 1,
status,
payload: encode_payload(&terminal_payload),
error_class,
started_at: start,
ended_at: Some(end),
},
);
let after = json!({
"files": snapshot_files(&options.journal_files),
"argv": options.command,
"program": program,
});
record_marker(
journal,
flow_id,
SNAP_AFTER_SEQ,
"cmd:snapshot:after",
&after,
);
result
}
fn record_marker(journal: &dyn Journal, flow_id: &FlowId, seq: u64, key: &str, payload: &Value) {
let now = SystemClock.now_ms();
let outcome = StepOutcome {
kind: StepKind::Marker,
attempt: 0,
status: StepStatus::Ok,
payload: encode_payload(payload),
error_class: None,
started_at: now,
ended_at: Some(now),
};
if let Err(e) = journal.record_step(flow_id, seq, &StepKey::new(key), &outcome) {
eprintln!("keel \u{25b8} exec: {key} marker not journaled: {e}");
}
}
fn record_step(journal: &dyn Journal, flow_id: &FlowId, key: &StepKey, outcome: &StepOutcome) {
if let Err(e) = journal.record_step(flow_id, STEP_SEQ, key, outcome) {
eprintln!("keel \u{25b8} exec: step {STEP_SEQ} not journaled: {e}");
}
}
#[doc(hidden)]
#[must_use]
pub fn identity_flow_id(flow: &str, command: &[String], flow_id_key: Option<&str>) -> String {
FlowDescriptor {
entrypoint: format!("cmd:{flow}"),
args_hash: args_hash(command),
explicit_key: flow_id_key.map(str::to_owned),
code_hash: None,
}
.flow_id()
.to_string()
}
#[doc(hidden)]
#[must_use]
pub fn identity_hostname() -> String {
hostname()
}
#[doc(hidden)]
#[must_use]
pub fn identity_holder_string(host: &str, pid: u32, started_ms: i64) -> String {
holder_string(host, pid, started_ms)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flow_name_grammar_is_enforced() {
assert!(valid_flow_name("autonomous-run"));
assert!(valid_flow_name("a1"));
assert!(!valid_flow_name("Autonomous"));
assert!(!valid_flow_name("-x"));
assert!(!valid_flow_name(""));
assert!(!valid_flow_name("a_b"));
}
#[test]
fn identity_is_deterministic_and_argv_sensitive() {
let a = args_hash(&["uvx".into(), "server".into()]);
let b = args_hash(&["uvx".into(), "server".into()]);
let c = args_hash(&["uvx".into(), "other".into()]);
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 16);
}
#[test]
fn holder_round_trips_and_detects_shape() {
let h = holder_string("myhost", 4242, 1_783_728_000_000);
let parsed = parse_holder(&h).unwrap();
assert_eq!(parsed.host, "myhost");
assert_eq!(parsed.pid, 4242);
assert!(
parse_holder("host-a:pid-1").is_none(),
"legacy holders don't parse"
);
}
#[test]
fn snapshot_compare_flags_growth_change_and_missing() {
let dir = tempfile::TempDir::new().unwrap();
let f = dir.path().join("trades.jsonl");
std::fs::write(&f, "a\nb\n").unwrap();
let one = std::slice::from_ref(&f);
let before = snapshot_files(one);
assert!(changed_files(&before, &snapshot_files(one)).is_empty());
std::fs::write(&f, "a\nb\nc\n").unwrap();
assert_eq!(
changed_files(&before, &snapshot_files(one)),
vec![f.display().to_string()]
);
std::fs::remove_file(&f).unwrap();
assert_eq!(changed_files(&before, &snapshot_files(one)).len(), 1);
}
#[test]
fn payload_codec_round_trips_and_reads_bare() {
let value = json!({ "exit_code": 3, "stdout_tail": "hi" });
let bytes = encode_payload(&value).expect("encodes");
assert_eq!(decode_payload(&bytes), Some(value));
}
#[test]
fn persistent_force_override_bypasses_the_gate_exactly_once() {
use keel_journal::NewFlow;
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("trades.jsonl");
std::fs::write(&file, "a\nb\n").unwrap();
let journal = SqliteJournal::open(dir.path().join("journal.db"), SystemClock).unwrap();
let flow_id = FlowId::new("01FORCEFLOW");
journal
.begin_flow(&NewFlow {
flow_id: flow_id.clone(),
entrypoint: "cmd:trade".to_owned(),
args_hash: "ah".to_owned(),
code_hash: None,
})
.unwrap();
let files = std::slice::from_ref(&file);
record_marker(
&journal,
&flow_id,
SNAP_BEFORE_SEQ,
"cmd:snapshot:before",
&json!({ "files": snapshot_files(files) }),
);
std::fs::write(&file, "a\nb\nc\n").unwrap();
let gate = || side_effect_gate(&journal, &flow_id, "cmd:trade", files, false);
assert!(
gate().is_some(),
"a changed-files re-dispatch must be gated when no force is armed"
);
request_force_override(&journal, &flow_id).unwrap();
assert!(
gate().is_none(),
"an armed persistent force must let the gate proceed"
);
assert!(
gate().is_some(),
"the force must be cleared after one bypass, not persist across attempts"
);
request_force_override(&journal, &flow_id).unwrap();
assert!(gate().is_none(), "re-arming grants exactly one more bypass");
assert!(gate().is_some(), "…and then it is spent again");
}
}