1use 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
55const SNAP_BEFORE_SEQ: u64 = 500_000;
60const SNAP_AFTER_SEQ: u64 = 500_001;
63
64const FORCE_SEQ: u64 = 500_002;
75
76const FORCE_STEP_KEY: &str = "cmd:force";
78
79const FORCE_STATE_REQUESTED: &str = "requested";
82const FORCE_STATE_CONSUMED: &str = "consumed";
87
88const STEP_SEQ: u64 = 1;
90
91const TAIL_CAP: usize = 4096;
94
95const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
100
101#[derive(Debug, Clone)]
103pub struct ExecOptions {
104 pub flow: String,
106 pub flow_id: Option<String>,
108 pub journal_files: Vec<PathBuf>,
111 pub force: bool,
113 pub command: Vec<String>,
115}
116
117fn 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
129fn 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
137fn args_hash(command: &[String]) -> String {
140 sha16(command.join("\u{0}").as_bytes())
141}
142
143fn 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
150fn 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#[derive(Debug)]
170struct Holder<'a> {
171 host: &'a str,
172 pid: u32,
173}
174
175fn holder_string(host: &str, pid: u32, started_ms: i64) -> String {
177 format!("{host}:{pid}:{started_ms}")
178}
179
180fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192struct FileSnap {
193 path: String,
194 exists: bool,
195 lines: u64,
196 sha256: String,
197}
198
199#[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
223fn 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#[derive(Serialize)]
242struct StepPayloadRef<'a> {
243 schema: &'a str,
244 payload: &'a Value,
245}
246
247#[derive(Deserialize)]
249struct StepPayloadOwned {
250 schema: String,
251 payload: Value,
252}
253
254fn 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
265fn 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#[cfg(unix)]
284fn hostname() -> String {
285 let mut buf = [0u8; 256];
286 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
305fn 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#[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 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
331struct SpawnResult {
336 exit_code: i32,
337 stdout_tail: String,
338 stderr_tail: String,
339 spawn_failed: bool,
340}
341
342fn 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
368fn 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
383fn 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#[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
444fn 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
463fn 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
470pub fn run(project: &Path, options: &ExecOptions) -> (Option<Rendered>, i32) {
478 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 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 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 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 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 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 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
590fn 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, ¤t);
614 if changed.is_empty() {
615 return None;
616 }
617 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
654pub 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
691fn 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 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
741fn 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 }
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
782const WAIT_NOTICE_EVERY: u32 = 60;
787
788fn 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 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) );
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
860fn 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
870fn 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
886fn 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
969fn 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
986fn 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#[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#[doc(hidden)]
1023#[must_use]
1024pub fn identity_hostname() -> String {
1025 hostname()
1026}
1027
1028#[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 #[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 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 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 assert!(
1136 gate().is_some(),
1137 "a changed-files re-dispatch must be gated when no force is armed"
1138 );
1139
1140 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 assert!(
1150 gate().is_some(),
1151 "the force must be cleared after one bypass, not persist across attempts"
1152 );
1153
1154 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}