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 STEP_SEQ: u64 = 1;
66
67const TAIL_CAP: usize = 4096;
70
71const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
76
77#[derive(Debug, Clone)]
79pub struct ExecOptions {
80 pub flow: String,
82 pub flow_id: Option<String>,
84 pub journal_files: Vec<PathBuf>,
87 pub force: bool,
89 pub command: Vec<String>,
91}
92
93fn valid_flow_name(name: &str) -> bool {
97 let mut chars = name.chars();
98 match chars.next() {
99 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
100 _ => return false,
101 }
102 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
103}
104
105fn sha16(bytes: &[u8]) -> String {
107 use sha2::{Digest, Sha256};
108 let mut hasher = Sha256::new();
109 hasher.update(bytes);
110 format!("{:x}", hasher.finalize())[..16].to_owned()
111}
112
113fn args_hash(command: &[String]) -> String {
116 sha16(command.join("\u{0}").as_bytes())
117}
118
119fn code_hash(command: &[String]) -> String {
122 let program = resolve_program(&command[0]);
123 sha16(format!("{program}\u{0}{}", command.join("\u{0}")).as_bytes())
124}
125
126fn resolve_program(argv0: &str) -> String {
129 if argv0.contains('/') {
130 return argv0.to_owned();
131 }
132 let Some(path) = std::env::var_os("PATH") else {
133 return argv0.to_owned();
134 };
135 for dir in std::env::split_paths(&path) {
136 let candidate = dir.join(argv0);
137 if candidate.is_file() {
138 return candidate.display().to_string();
139 }
140 }
141 argv0.to_owned()
142}
143
144#[derive(Debug)]
146struct Holder<'a> {
147 host: &'a str,
148 pid: u32,
149}
150
151fn holder_string(host: &str, pid: u32, started_ms: i64) -> String {
153 format!("{host}:{pid}:{started_ms}")
154}
155
156fn parse_holder(s: &str) -> Option<Holder<'_>> {
159 let mut parts = s.rsplitn(3, ':');
160 let _started: i64 = parts.next()?.parse().ok()?;
161 let pid: u32 = parts.next()?.parse().ok()?;
162 let host = parts.next()?;
163 Some(Holder { host, pid })
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168struct FileSnap {
169 path: String,
170 exists: bool,
171 lines: u64,
172 sha256: String,
173}
174
175#[allow(clippy::naive_bytecount)]
179fn snapshot_files(files: &[PathBuf]) -> Vec<FileSnap> {
180 files
181 .iter()
182 .map(|p| match std::fs::read(p) {
183 Ok(bytes) => FileSnap {
184 path: p.display().to_string(),
185 exists: true,
186 lines: bytes.iter().filter(|&&b| b == b'\n').count() as u64,
187 sha256: sha16(&bytes),
188 },
189 Err(_) => FileSnap {
190 path: p.display().to_string(),
191 exists: false,
192 lines: 0,
193 sha256: String::new(),
194 },
195 })
196 .collect()
197}
198
199fn changed_files(recorded: &[FileSnap], current: &[FileSnap]) -> Vec<String> {
202 current
203 .iter()
204 .filter(|now| {
205 recorded
206 .iter()
207 .find(|r| r.path == now.path)
208 .is_some_and(|r| r != *now)
209 })
210 .map(|s| s.path.clone())
211 .collect()
212}
213
214#[derive(Serialize)]
218struct StepPayloadRef<'a> {
219 schema: &'a str,
220 payload: &'a Value,
221}
222
223#[derive(Deserialize)]
225struct StepPayloadOwned {
226 schema: String,
227 payload: Value,
228}
229
230fn encode_payload(value: &Value) -> Option<Vec<u8>> {
234 rmp_serde::to_vec_named(&StepPayloadRef {
235 schema: STEP_PAYLOAD_SCHEMA,
236 payload: value,
237 })
238 .ok()
239}
240
241fn decode_payload(bytes: &[u8]) -> Option<Value> {
243 if let Ok(envelope) = rmp_serde::from_slice::<StepPayloadOwned>(bytes)
244 && envelope.schema == STEP_PAYLOAD_SCHEMA
245 {
246 return Some(envelope.payload);
247 }
248 rmp_serde::from_slice(bytes).ok()
249}
250
251#[cfg(unix)]
260fn hostname() -> String {
261 let mut buf = [0u8; 256];
262 let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
265 if rc == 0 {
266 let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
267 if let Ok(s) = std::str::from_utf8(&buf[..end])
268 && !s.is_empty()
269 {
270 return s.to_owned();
271 }
272 }
273 env_hostname()
274}
275
276#[cfg(not(unix))]
277fn hostname() -> String {
278 env_hostname()
279}
280
281fn env_hostname() -> String {
283 std::env::var("HOSTNAME")
284 .or_else(|_| std::env::var("COMPUTERNAME"))
285 .unwrap_or_else(|_| "localhost".to_owned())
286}
287
288#[cfg(unix)]
292fn pid_is_dead(pid: u32) -> bool {
293 let Ok(pid) = libc::pid_t::try_from(pid) else {
294 return false;
295 };
296 let rc = unsafe { libc::kill(pid, 0) };
299 rc != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
300}
301
302#[cfg(not(unix))]
303fn pid_is_dead(_pid: u32) -> bool {
304 false
305}
306
307struct SpawnResult {
312 exit_code: i32,
313 stdout_tail: String,
314 stderr_tail: String,
315 spawn_failed: bool,
316}
317
318fn tee<R: Read>(mut reader: R, to_stderr: bool) -> String {
321 let mut ring: Vec<u8> = Vec::new();
322 let mut buf = [0u8; 8192];
323 loop {
324 match reader.read(&mut buf) {
325 Ok(0) | Err(_) => break,
326 Ok(n) => {
327 let chunk = &buf[..n];
328 if to_stderr {
329 let _ = std::io::stderr().write_all(chunk);
330 } else {
331 let _ = std::io::stdout().write_all(chunk);
332 }
333 ring.extend_from_slice(chunk);
334 if ring.len() > TAIL_CAP {
335 let drop = ring.len() - TAIL_CAP;
336 ring.drain(..drop);
337 }
338 }
339 }
340 }
341 String::from_utf8_lossy(&ring).into_owned()
342}
343
344fn exit_code_of(status: std::process::ExitStatus) -> i32 {
346 #[cfg(unix)]
347 {
348 use std::os::unix::process::ExitStatusExt;
349 status
350 .code()
351 .unwrap_or_else(|| status.signal().map_or(1, |s| 128 + s))
352 }
353 #[cfg(not(unix))]
354 {
355 status.code().unwrap_or(1)
356 }
357}
358
359fn run_child(command: &[String]) -> SpawnResult {
361 use std::process::{Command, Stdio};
362 let mut cmd = Command::new(&command[0]);
363 cmd.args(&command[1..])
364 .stdout(Stdio::piped())
365 .stderr(Stdio::piped());
366 let mut child = match cmd.spawn() {
367 Ok(c) => c,
368 Err(e) => {
369 eprintln!("keel \u{25b8} exec: could not spawn `{}`: {e}", command[0]);
370 return SpawnResult {
371 exit_code: 127,
372 stdout_tail: String::new(),
373 stderr_tail: String::new(),
374 spawn_failed: true,
375 };
376 }
377 };
378 let out = child.stdout.take().expect("stdout piped");
379 let err = child.stderr.take().expect("stderr piped");
380 let out_h = std::thread::spawn(move || tee(out, false));
381 let err_h = std::thread::spawn(move || tee(err, true));
382 let status = child.wait();
383 let stdout_tail = out_h.join().unwrap_or_default();
384 let stderr_tail = err_h.join().unwrap_or_default();
385 let exit_code = status.map_or(127, exit_code_of);
386 SpawnResult {
387 exit_code,
388 stdout_tail,
389 stderr_tail,
390 spawn_failed: false,
391 }
392}
393
394#[derive(Debug, Serialize)]
398struct ExecReport {
399 exit_code: i32,
400 flow_id: String,
401 entrypoint: String,
402 replayed: bool,
403 skipped: bool,
404 forced: bool,
405 journal_files: Vec<FileSnap>,
406}
407
408impl ExecReport {
409 fn render(self, human: String, to_stderr: bool) -> Rendered {
410 let exit = self.exit_code;
411 Rendered {
412 human,
413 json: to_json(&self),
414 exit,
415 to_stderr,
416 }
417 }
418}
419
420fn usage_pair(message: &str) -> (Option<Rendered>, i32) {
422 #[derive(Serialize)]
423 struct UsageReport<'a> {
424 error: &'static str,
425 what: &'a str,
426 }
427 let r = Rendered {
428 human: format!("keel \u{25b8} {message}"),
429 json: to_json(&UsageReport {
430 error: "bad-usage",
431 what: message,
432 }),
433 exit: EXIT_USAGE,
434 to_stderr: true,
435 };
436 (Some(r), EXIT_USAGE)
437}
438
439fn soft_pair(message: &str) -> (Option<Rendered>, i32) {
441 let r = flows::soft_error(message);
442 let code = r.exit;
443 (Some(r), code)
444}
445
446pub fn run(project: &Path, options: &ExecOptions) -> (Option<Rendered>, i32) {
454 if !valid_flow_name(&options.flow) {
456 return usage_pair(&format!(
457 "--flow must match [a-z0-9][a-z0-9-]* (the CCR flow-name grammar); got {:?}.",
458 options.flow
459 ));
460 }
461 if options.command.is_empty() {
462 return usage_pair(
463 "the command after `--` must be non-empty: `keel exec --flow <name> -- <program> \
464 [args…]`.",
465 );
466 }
467
468 let journal_path = evidence::resolved_journal(project).path;
472 if let Some(parent) = journal_path.parent()
473 && !parent.as_os_str().is_empty()
474 && let Err(e) = std::fs::create_dir_all(parent)
475 {
476 return soft_pair(&format!("could not create {}: {e}", parent.display()));
477 }
478 let journal: Arc<dyn Journal> = match SqliteJournal::open(&journal_path, SystemClock) {
479 Ok(j) => Arc::new(j),
480 Err(e) => {
481 return soft_pair(&format!(
482 "could not open the journal at {}: {e}",
483 journal_path.display()
484 ));
485 }
486 };
487
488 let entrypoint = format!("cmd:{}", options.flow);
490 let args_h = args_hash(&options.command);
491 let step_key = StepKey::new(format!("{entrypoint}#{args_h}"));
492 let desc = FlowDescriptor {
493 entrypoint: entrypoint.clone(),
494 args_hash: args_h,
495 explicit_key: options.flow_id.clone(),
496 code_hash: Some(code_hash(&options.command)),
497 };
498 let flow_id = desc.flow_id();
499
500 let started_ms = SystemClock.now_ms();
501 let holder = holder_string(&hostname(), std::process::id(), started_ms);
502 let engine = Arc::new(Engine::new());
503 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
504 let manager = FlowManager::new(engine, Arc::clone(&journal), clock, ProcessId::new(holder));
505
506 if let Some(refusal) = side_effect_gate(
508 journal.as_ref(),
509 &flow_id,
510 &entrypoint,
511 &options.journal_files,
512 options.force,
513 ) {
514 return refusal;
515 }
516
517 let mut handle = match enter_loop(&manager, journal.as_ref(), project, &desc, &flow_id) {
519 Ok(handle) => handle,
520 Err(terminal) => return terminal,
521 };
522
523 if handle.is_replay_only() {
525 let code = recorded_exit(journal.as_ref(), &flow_id);
526 let report = ExecReport {
527 exit_code: code,
528 flow_id: flow_id.to_string(),
529 entrypoint,
530 replayed: true,
531 skipped: false,
532 forced: options.force,
533 journal_files: snapshot_files(&options.journal_files),
534 };
535 let human = format!(
536 "keel \u{25b8} exec: flow {flow_id} already completed \u{2014} replaying recorded \
537 outcome (exit {code}); the command is NOT re-run.\n"
538 );
539 return (Some(report.render(human, false)), code);
540 }
541
542 let result = live_run(journal.as_ref(), &flow_id, &step_key, options);
544 if result.exit_code == 0 && !result.spawn_failed {
545 handle.complete_success();
546 } else {
547 handle.complete_failed();
548 }
549 let code = result.exit_code;
550 let report = ExecReport {
551 exit_code: code,
552 flow_id: flow_id.to_string(),
553 entrypoint,
554 replayed: false,
555 skipped: false,
556 forced: options.force,
557 journal_files: snapshot_files(&options.journal_files),
558 };
559 let human = format!("keel \u{25b8} exec: flow {flow_id} exited {code}.\n");
560 (Some(report.render(human, code != EXIT_OK)), code)
561}
562
563fn side_effect_gate(
567 journal: &dyn Journal,
568 flow_id: &FlowId,
569 entrypoint: &str,
570 journal_files: &[PathBuf],
571 force: bool,
572) -> Option<(Option<Rendered>, i32)> {
573 let flow = journal.get_flow(flow_id).ok().flatten()?;
574 if !matches!(flow.status, FlowStatus::Running | FlowStatus::Failed) {
575 return None;
576 }
577 let (_, marker) = journal.step_at(flow_id, SNAP_BEFORE_SEQ).ok().flatten()?;
578 let recorded: Vec<FileSnap> = marker
579 .payload
580 .as_deref()
581 .and_then(decode_payload)
582 .and_then(|v| v.get("files").cloned())
583 .and_then(|f| serde_json::from_value(f).ok())
584 .unwrap_or_default();
585 let current = snapshot_files(journal_files);
586 let changed = changed_files(&recorded, ¤t);
587 if changed.is_empty() {
588 return None;
589 }
590 if force {
591 eprintln!(
592 "keel \u{25b8} exec --force: {} declared side-effect file(s) changed since the last \
593 attempt ({}); re-dispatching anyway (KEEL-E033 overridden).",
594 changed.len(),
595 changed.join(", ")
596 );
597 return None;
598 }
599 let message = format!(
600 "refusing to re-dispatch flow {flow_id} ({entrypoint}): {} declared side-effect file(s) \
601 changed since the last attempt ({}). The previous run left partial effects; re-running \
602 could duplicate them. Re-run with --force to override (KEEL-E033); see `keel explain \
603 KEEL-E033`.",
604 changed.len(),
605 changed.join(", ")
606 );
607 Some(soft_pair(&message))
608}
609
610fn enter_loop(
614 manager: &FlowManager,
615 journal: &dyn Journal,
616 project: &Path,
617 desc: &FlowDescriptor,
618 flow_id: &FlowId,
619) -> Result<FlowHandle, (Option<Rendered>, i32)> {
620 let mut wait_iters: u32 = 0;
621 loop {
622 match manager.enter_flow(desc) {
623 Ok(handle) => return Ok(handle),
624 Err(e) => match e.code {
625 ErrorCode::FlowLeaseHeld => {
626 if let Some(terminal) =
627 handle_busy(journal, project, flow_id, &e.message, &mut wait_iters)
628 {
629 return Err(terminal);
630 }
631 }
634 ErrorCode::FlowDead => {
635 return Err(soft_pair(&format!(
636 "{} Inspect with `keel trace {flow_id}`; see `keel explain KEEL-E032`.",
637 e.message
638 )));
639 }
640 _ => {
641 return Err(soft_pair(&format!(
642 "could not enter flow {flow_id}: {}",
643 e.message
644 )));
645 }
646 },
647 }
648 }
649}
650
651const WAIT_NOTICE_EVERY: u32 = 60;
656
657fn handle_busy(
663 journal: &dyn Journal,
664 project: &Path,
665 flow_id: &FlowId,
666 e030_message: &str,
667 wait_iters: &mut u32,
668) -> Option<(Option<Rendered>, i32)> {
669 let recorded_holder = journal
670 .get_flow(flow_id)
671 .ok()
672 .flatten()
673 .and_then(|f| f.lease_holder);
674 if let Some(holder) = &recorded_holder
675 && let Some(parsed) = parse_holder(holder.as_str())
676 && parsed.host == hostname()
677 && pid_is_dead(parsed.pid)
678 {
679 eprintln!(
680 "keel \u{25b8} exec: abandoning flow {flow_id} held by dead pid {} on this host.",
681 parsed.pid
682 );
683 if let Err(err) = journal.complete_flow(flow_id, FlowStatus::Failed) {
686 eprintln!("keel \u{25b8} exec: could not abandon dead-held flow {flow_id}: {err}");
687 }
688 return None;
689 }
690
691 match flows_on_busy(project) {
692 OnBusy::Skip => {
693 let report = ExecReport {
694 exit_code: EXIT_OK,
695 flow_id: flow_id.to_string(),
696 entrypoint: String::new(),
697 replayed: false,
698 skipped: true,
699 forced: false,
700 journal_files: Vec::new(),
701 };
702 let human = format!(
703 "keel \u{25b8} exec: flow {flow_id} is busy (held by a live process); skipping \
704 (flows.on_busy = skip).\n"
705 );
706 Some((Some(report.render(human, false)), EXIT_OK))
707 }
708 OnBusy::Wait => {
709 *wait_iters += 1;
710 if (*wait_iters).is_multiple_of(WAIT_NOTICE_EVERY) {
711 let holder = recorded_holder
712 .as_ref()
713 .map_or("unknown", ProcessId::as_str);
714 eprintln!(
715 "keel \u{25b8} exec: still waiting on flow {flow_id} held by {holder} \
716 ({}s elapsed; flows.on_busy = wait; ^C to give up).",
717 (*wait_iters / 2) );
719 }
720 std::thread::sleep(Duration::from_millis(500));
721 None
722 }
723 OnBusy::Fail => Some(soft_pair(&format!(
724 "{e030_message} (flows.on_busy = fail); see `keel explain KEEL-E030`."
725 ))),
726 }
727}
728
729fn flows_on_busy(project: &Path) -> OnBusy {
734 evidence::load_policy(project)
735 .and_then(|p| p.flows)
736 .map_or_else(OnBusy::default, |f| f.on_busy)
737}
738
739fn recorded_exit(journal: &dyn Journal, flow_id: &FlowId) -> i32 {
742 match journal.step_at(flow_id, STEP_SEQ) {
743 Ok(Some((_, outcome))) if outcome.status == StepStatus::Ok => EXIT_OK,
744 Ok(Some((_, outcome))) => outcome
745 .payload
746 .as_deref()
747 .and_then(decode_payload)
748 .and_then(|v| v.get("exit_code").and_then(Value::as_i64))
749 .and_then(|c| i32::try_from(c).ok())
750 .unwrap_or(EXIT_FAILURE),
751 _ => EXIT_OK,
752 }
753}
754
755fn live_run(
759 journal: &dyn Journal,
760 flow_id: &FlowId,
761 step_key: &StepKey,
762 options: &ExecOptions,
763) -> SpawnResult {
764 let program = resolve_program(&options.command[0]);
765 let before = json!({
766 "files": snapshot_files(&options.journal_files),
767 "argv": options.command,
768 "program": program,
769 });
770 record_marker(
771 journal,
772 flow_id,
773 SNAP_BEFORE_SEQ,
774 "cmd:snapshot:before",
775 &before,
776 );
777
778 let start = SystemClock.now_ms();
779 record_step(
780 journal,
781 flow_id,
782 step_key,
783 &StepOutcome {
784 kind: StepKind::Subprocess,
785 attempt: 0,
786 status: StepStatus::Running,
787 payload: None,
788 error_class: None,
789 started_at: start,
790 ended_at: None,
791 },
792 );
793
794 let result = run_child(&options.command);
795
796 let end = SystemClock.now_ms();
797 let (status, error_class) = if result.exit_code == 0 && !result.spawn_failed {
798 (StepStatus::Ok, None)
799 } else {
800 (StepStatus::Error, Some(ErrorClass::Other))
801 };
802 let terminal_payload = json!({
803 "exit_code": result.exit_code,
804 "stdout_tail": result.stdout_tail,
805 "stderr_tail": result.stderr_tail,
806 });
807 record_step(
808 journal,
809 flow_id,
810 step_key,
811 &StepOutcome {
812 kind: StepKind::Subprocess,
813 attempt: 1,
814 status,
815 payload: encode_payload(&terminal_payload),
816 error_class,
817 started_at: start,
818 ended_at: Some(end),
819 },
820 );
821
822 let after = json!({
823 "files": snapshot_files(&options.journal_files),
824 "argv": options.command,
825 "program": program,
826 });
827 record_marker(
828 journal,
829 flow_id,
830 SNAP_AFTER_SEQ,
831 "cmd:snapshot:after",
832 &after,
833 );
834
835 result
836}
837
838fn record_marker(journal: &dyn Journal, flow_id: &FlowId, seq: u64, key: &str, payload: &Value) {
840 let now = SystemClock.now_ms();
841 let outcome = StepOutcome {
842 kind: StepKind::Marker,
843 attempt: 0,
844 status: StepStatus::Ok,
845 payload: encode_payload(payload),
846 error_class: None,
847 started_at: now,
848 ended_at: Some(now),
849 };
850 if let Err(e) = journal.record_step(flow_id, seq, &StepKey::new(key), &outcome) {
851 eprintln!("keel \u{25b8} exec: {key} marker not journaled: {e}");
852 }
853}
854
855fn record_step(journal: &dyn Journal, flow_id: &FlowId, key: &StepKey, outcome: &StepOutcome) {
859 if let Err(e) = journal.record_step(flow_id, STEP_SEQ, key, outcome) {
860 eprintln!("keel \u{25b8} exec: step {STEP_SEQ} not journaled: {e}");
861 }
862}
863
864#[doc(hidden)]
877#[must_use]
878pub fn identity_flow_id(flow: &str, command: &[String], flow_id_key: Option<&str>) -> String {
879 FlowDescriptor {
880 entrypoint: format!("cmd:{flow}"),
881 args_hash: args_hash(command),
882 explicit_key: flow_id_key.map(str::to_owned),
883 code_hash: None,
884 }
885 .flow_id()
886 .to_string()
887}
888
889#[doc(hidden)]
892#[must_use]
893pub fn identity_hostname() -> String {
894 hostname()
895}
896
897#[doc(hidden)]
900#[must_use]
901pub fn identity_holder_string(host: &str, pid: u32, started_ms: i64) -> String {
902 holder_string(host, pid, started_ms)
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908
909 #[test]
910 fn flow_name_grammar_is_enforced() {
911 assert!(valid_flow_name("autonomous-run"));
912 assert!(valid_flow_name("a1"));
913 assert!(!valid_flow_name("Autonomous"));
914 assert!(!valid_flow_name("-x"));
915 assert!(!valid_flow_name(""));
916 assert!(!valid_flow_name("a_b"));
917 }
918
919 #[test]
920 fn identity_is_deterministic_and_argv_sensitive() {
921 let a = args_hash(&["uvx".into(), "server".into()]);
922 let b = args_hash(&["uvx".into(), "server".into()]);
923 let c = args_hash(&["uvx".into(), "other".into()]);
924 assert_eq!(a, b);
925 assert_ne!(a, c);
926 assert_eq!(a.len(), 16);
927 }
928
929 #[test]
930 fn holder_round_trips_and_detects_shape() {
931 let h = holder_string("myhost", 4242, 1_783_728_000_000);
932 let parsed = parse_holder(&h).unwrap();
933 assert_eq!(parsed.host, "myhost");
934 assert_eq!(parsed.pid, 4242);
935 assert!(
936 parse_holder("host-a:pid-1").is_none(),
937 "legacy holders don't parse"
938 );
939 }
940
941 #[test]
942 fn snapshot_compare_flags_growth_change_and_missing() {
943 let dir = tempfile::TempDir::new().unwrap();
944 let f = dir.path().join("trades.jsonl");
945 std::fs::write(&f, "a\nb\n").unwrap();
946 let one = std::slice::from_ref(&f);
947 let before = snapshot_files(one);
948 assert!(changed_files(&before, &snapshot_files(one)).is_empty());
949 std::fs::write(&f, "a\nb\nc\n").unwrap();
950 assert_eq!(
951 changed_files(&before, &snapshot_files(one)),
952 vec![f.display().to_string()]
953 );
954 std::fs::remove_file(&f).unwrap();
955 assert_eq!(changed_files(&before, &snapshot_files(one)).len(), 1);
956 }
957
958 #[test]
959 fn payload_codec_round_trips_and_reads_bare() {
960 let value = json!({ "exit_code": 3, "stdout_tail": "hi" });
961 let bytes = encode_payload(&value).expect("encodes");
962 assert_eq!(decode_payload(&bytes), Some(value));
963 }
964}