1use std::path::{Path, PathBuf};
40
41use rusqlite::Connection;
42use serde::Serialize;
43
44use crate::render::to_json;
45use crate::{EXIT_FAILURE, EXIT_OK, EXIT_USAGE, Rendered, evidence, flows, run};
46
47#[derive(Debug, Clone, Default)]
50pub struct ResumeOptions {
51 pub flow: Option<String>,
53 pub all: bool,
55 pub args: Vec<String>,
57}
58
59struct FlowRow {
62 flow_id: String,
63 entrypoint: String,
64 status: String,
65 lease_holder: Option<String>,
66 lease_expires: Option<i64>,
67 updated_at: i64,
68}
69
70#[derive(Debug)]
73enum Ineligible {
74 Completed,
76 Dead,
78 LeaseLive { holder: String, expires: i64 },
80 UnsupportedEntrypoint,
82 ScriptNotFound { candidate: String },
84 AmbiguousScript { candidates: Vec<String> },
87}
88
89impl Ineligible {
90 fn message(&self, row: &FlowRow) -> String {
91 let flow_id = &row.flow_id;
92 match self {
93 Self::Completed => format!(
94 "flow {flow_id} is already completed; nothing to resume. Inspect it with `keel \
95 replay {flow_id}`, or run the script again to start a new flow."
96 ),
97 Self::Dead => format!(
98 "flow {flow_id} is dead \u{2014} dead flows are never auto-resumed (KEEL-E032). \
99 Inspect with `keel trace {flow_id}`, fix the cause, then rerun with a new \
100 identity; see `keel explain KEEL-E032`."
101 ),
102 Self::LeaseLive { holder, expires } => format!(
103 "flow {flow_id}'s lease is held by {holder} until {expires} (KEEL-E030); another \
104 process may still be running it. Wait for the lease to expire (or confirm that \
105 process is gone) and retry; see `keel explain KEEL-E030`."
106 ),
107 Self::UnsupportedEntrypoint => format!(
108 "flow {flow_id} ({}) cannot be resumed from the CLI: only `py:` entrypoints can \
109 be re-invoked today (no other front end designates durable flows yet). \
110 Re-invoke the original script directly with `keel run`.",
111 row.entrypoint
112 ),
113 Self::ScriptNotFound { candidate } => format!(
114 "flow {flow_id} ({}) cannot be resumed from the CLI: expected its script at \
115 `{candidate}` (derived from the entrypoint's module), but no such file exists \
116 under the project. If the module lives elsewhere on PYTHONPATH, run `keel run \
117 <script>` on it directly instead.",
118 row.entrypoint
119 ),
120 Self::AmbiguousScript { candidates } => format!(
121 "flow {flow_id} ({}) cannot be resumed from the CLI: its single-component module \
122 matches {} files under the project ({}) and this command will not guess which \
123 one wrote this journal. Run `keel run <script>` on the right one directly \
124 instead.",
125 row.entrypoint,
126 candidates.len(),
127 candidates.join(", ")
128 ),
129 }
130 }
131}
132
133const PY_SCHEME: &str = "py";
136
137fn parse_entrypoint(entrypoint: &str) -> Option<(&str, &str, &str)> {
139 let mut parts = entrypoint.splitn(3, ':');
140 Some((parts.next()?, parts.next()?, parts.next()?))
141}
142
143const SKIP_DIRS: &[&str] = &[
146 ".git",
147 ".keel",
148 ".venv",
149 "venv",
150 "__pycache__",
151 "node_modules",
152 ".hg",
153 ".svn",
154];
155
156fn find_files_named(dir: &Path, name: &str, out: &mut Vec<PathBuf>) {
160 let Ok(entries) = std::fs::read_dir(dir) else {
161 return;
162 };
163 for entry in entries.flatten() {
164 let Ok(file_type) = entry.file_type() else {
165 continue;
166 };
167 if file_type.is_dir() {
168 let skip = entry
169 .file_name()
170 .to_str()
171 .is_some_and(|n| SKIP_DIRS.contains(&n));
172 if !skip {
173 find_files_named(&entry.path(), name, out);
174 }
175 } else if file_type.is_file() && entry.file_name().to_str() == Some(name) {
176 out.push(entry.path());
177 }
178 }
179}
180
181fn dotted_module_path(project: &Path, module: &str) -> PathBuf {
185 let mut path = project.to_path_buf();
186 for part in module.split('.') {
187 path.push(part);
188 }
189 path.set_extension("py");
190 path
191}
192
193enum ScriptLookup {
196 Found(PathBuf),
197 NotFound { candidate: String },
198 Ambiguous { candidates: Vec<String> },
199}
200
201fn locate_script(project: &Path, module: &str) -> ScriptLookup {
202 if module.contains('.') {
203 let candidate = dotted_module_path(project, module);
204 if candidate.is_file() {
205 ScriptLookup::Found(candidate)
206 } else {
207 ScriptLookup::NotFound {
208 candidate: candidate.display().to_string(),
209 }
210 }
211 } else {
212 let name = format!("{module}.py");
213 let mut found = Vec::new();
214 find_files_named(project, &name, &mut found);
215 found.sort();
216 match found.len() {
217 0 => ScriptLookup::NotFound {
218 candidate: project.join(&name).display().to_string(),
219 },
220 1 => ScriptLookup::Found(found.into_iter().next().expect("exactly one")),
221 _ => ScriptLookup::Ambiguous {
222 candidates: found.iter().map(|p| p.display().to_string()).collect(),
223 },
224 }
225 }
226}
227
228fn eligibility(project: &Path, row: &FlowRow, now_ms: i64) -> Result<PathBuf, Ineligible> {
235 match row.status.as_str() {
236 "completed" => return Err(Ineligible::Completed),
237 "dead" => return Err(Ineligible::Dead),
238 _ => {}
239 }
240 if let (Some(holder), Some(expires)) = (&row.lease_holder, row.lease_expires)
241 && expires > now_ms
242 {
243 return Err(Ineligible::LeaseLive {
244 holder: holder.clone(),
245 expires,
246 });
247 }
248 let Some((lang, module, _function)) = parse_entrypoint(&row.entrypoint) else {
249 return Err(Ineligible::UnsupportedEntrypoint);
250 };
251 if lang != PY_SCHEME {
252 return Err(Ineligible::UnsupportedEntrypoint);
253 }
254 match locate_script(project, module) {
255 ScriptLookup::Found(path) => Ok(path),
256 ScriptLookup::NotFound { candidate } => Err(Ineligible::ScriptNotFound { candidate }),
257 ScriptLookup::Ambiguous { candidates } => Err(Ineligible::AmbiguousScript { candidates }),
258 }
259}
260
261fn read_flow_row(conn: &Connection, flow_id: &str) -> Result<FlowRow, String> {
263 conn.query_row(
264 "SELECT flow_id, entrypoint, status, lease_holder, lease_expires, updated_at \
265 FROM flows WHERE flow_id = ?1",
266 [flow_id],
267 |r| {
268 Ok(FlowRow {
269 flow_id: r.get(0)?,
270 entrypoint: r.get(1)?,
271 status: r.get(2)?,
272 lease_holder: r.get(3)?,
273 lease_expires: r.get(4)?,
274 updated_at: r.get(5)?,
275 })
276 },
277 )
278 .map_err(|e| flows::q(&e))
279}
280
281fn resumable_candidates(conn: &Connection) -> Result<Vec<FlowRow>, String> {
284 let mut stmt = conn
285 .prepare(
286 "SELECT flow_id, entrypoint, status, lease_holder, lease_expires, updated_at \
287 FROM flows WHERE status IN ('running', 'failed') ORDER BY flow_id",
288 )
289 .map_err(|e| flows::q(&e))?;
290 stmt.query_map([], |r| {
291 Ok(FlowRow {
292 flow_id: r.get(0)?,
293 entrypoint: r.get(1)?,
294 status: r.get(2)?,
295 lease_holder: r.get(3)?,
296 lease_expires: r.get(4)?,
297 updated_at: r.get(5)?,
298 })
299 })
300 .map_err(|e| flows::q(&e))?
301 .collect::<rusqlite::Result<Vec<_>>>()
302 .map_err(|e| flows::q(&e))
303}
304
305pub fn run(project: &Path, options: &ResumeOptions, now_ms: i64) -> (Option<Rendered>, i32) {
311 if options.flow.is_some() == options.all {
312 return usage_pair(if options.all {
313 "cannot give both a FLOW_ID and --all; resume one flow by id, or every resumable \
314 flow with --all alone."
315 } else {
316 "`keel flows resume` needs a FLOW_ID, or --all to resume every resumable flow."
317 });
318 }
319 if options.all && !options.args.is_empty() {
320 return usage_pair(
321 "cannot forward arguments with --all: different flows may need different original \
322 arguments. Resume them individually: `keel flows resume <FLOW> -- <args>`.",
323 );
324 }
325
326 let path = evidence::resolved_journal(project).path;
327 if !path.exists() {
328 return soft_pair("no journal yet (.keel/journal.db). Run a flow first with `keel run`.");
329 }
330 let conn = match flows::open_ro(&path) {
331 Ok(c) => c,
332 Err(e) => return soft_pair(&e),
333 };
334
335 if options.all {
336 resume_all(project, &conn, now_ms)
337 } else {
338 let flow = options.flow.as_deref().expect("checked above");
339 resume_one(project, &conn, flow, &options.args, now_ms)
340 }
341}
342
343fn resume_one(
344 project: &Path,
345 conn: &Connection,
346 flow: &str,
347 args: &[String],
348 now_ms: i64,
349) -> (Option<Rendered>, i32) {
350 let resolved = match flows::resolve_flow(conn, flow) {
351 Ok(r) => r,
352 Err(e) => return soft_pair(&e),
353 };
354 let row = match read_flow_row(conn, &resolved.flow_id) {
355 Ok(r) => r,
356 Err(e) => return soft_pair(&e),
357 };
358 let script = match eligibility(project, &row, now_ms) {
359 Ok(s) => s,
360 Err(ineligible) => return soft_pair(&ineligible.message(&row)),
361 };
362 let plan = match run::plan(&script.to_string_lossy(), args, false) {
363 Ok(p) => p,
364 Err(e) => {
365 return soft_pair(&format!(
366 "could not plan a run for {}: {e:?}",
367 script.display()
368 ));
369 }
370 };
371 announce(&row, &script, args);
372 match run::exec(&plan) {
373 Ok(code) => {
374 if code == EXIT_OK && !progressed(conn, &row) {
375 eprintln!(
376 "keel \u{25b8} flow {} does not look resumed \u{2014} its journal record did \
377 not change. Check that the arguments after `--` matched the original \
378 invocation exactly (see `keel explain KEEL-E040` if this is unexpected).",
379 row.flow_id
380 );
381 }
382 (None, code)
383 }
384 Err(r) => {
385 let code = r.exit;
386 (Some(r), code)
387 }
388 }
389}
390
391#[derive(Debug, Serialize)]
393struct AttemptEntry {
394 exit_code: i32,
395 flow_id: String,
396 progressed: bool,
400 script: String,
401}
402
403#[derive(Debug, Serialize)]
405struct SkipEntry {
406 flow_id: String,
407 reason: String,
408}
409
410#[derive(Debug, Serialize)]
412struct AllReport {
413 attempted: Vec<AttemptEntry>,
414 ok: bool,
415 skipped: Vec<SkipEntry>,
416}
417
418fn resume_all(project: &Path, conn: &Connection, now_ms: i64) -> (Option<Rendered>, i32) {
419 let candidates = match resumable_candidates(conn) {
420 Ok(c) => c,
421 Err(e) => return soft_pair(&e),
422 };
423 let mut attempted = Vec::new();
424 let mut skipped = Vec::new();
425 for row in candidates {
426 match eligibility(project, &row, now_ms) {
427 Err(ineligible) => skipped.push(SkipEntry {
428 flow_id: row.flow_id.clone(),
429 reason: ineligible.message(&row),
430 }),
431 Ok(script) => {
432 let plan = match run::plan(&script.to_string_lossy(), &[], false) {
433 Ok(p) => p,
434 Err(e) => {
435 skipped.push(SkipEntry {
436 flow_id: row.flow_id.clone(),
437 reason: format!("could not plan a run for {}: {e:?}", script.display()),
438 });
439 continue;
440 }
441 };
442 announce(&row, &script, &[]);
443 let exit_code = match run::exec(&plan) {
444 Ok(code) => code,
445 Err(rendered) => {
446 eprint!("{}", rendered.human);
447 EXIT_FAILURE
448 }
449 };
450 attempted.push(AttemptEntry {
451 exit_code,
452 progressed: progressed(conn, &row),
453 flow_id: row.flow_id,
454 script: script.display().to_string(),
455 });
456 }
457 }
458 }
459 let ok = skipped.is_empty()
464 && attempted
465 .iter()
466 .all(|a| a.exit_code == EXIT_OK && a.progressed);
467 let report = AllReport {
468 attempted,
469 ok,
470 skipped,
471 };
472 let human = all_human(&report);
473 let exit = if ok { EXIT_OK } else { EXIT_FAILURE };
474 (
475 Some(Rendered {
476 human,
477 json: to_json(&report),
478 exit,
479 to_stderr: false,
480 }),
481 exit,
482 )
483}
484
485fn all_human(report: &AllReport) -> String {
486 if report.attempted.is_empty() && report.skipped.is_empty() {
487 return "keel \u{25b8} no resumable flows (running/failed with no live lease).".to_owned();
488 }
489 let mut lines = vec![format!(
490 "keel \u{25b8} flows resume --all: {} attempted, {} skipped\n",
491 report.attempted.len(),
492 report.skipped.len()
493 )];
494 for a in &report.attempted {
495 let flag = if a.exit_code == EXIT_OK && a.progressed {
496 "ok"
497 } else if a.exit_code != EXIT_OK {
498 "child failed"
499 } else {
500 "did not progress"
501 };
502 lines.push(format!(
503 " {} {} exit {} \u{2014} {flag}\n",
504 a.flow_id, a.script, a.exit_code
505 ));
506 }
507 for s in &report.skipped {
508 lines.push(format!(" {} skipped: {}\n", s.flow_id, s.reason));
509 }
510 lines.concat()
511}
512
513fn announce(row: &FlowRow, script: &Path, args: &[String]) {
516 let extra = if args.is_empty() {
517 String::new()
518 } else {
519 format!(" {}", args.join(" "))
520 };
521 eprintln!(
522 "keel \u{25b8} resuming flow {} ({}) via `keel run {}{extra}`",
523 row.flow_id,
524 row.entrypoint,
525 script.display()
526 );
527 if args.is_empty() {
528 eprintln!(
529 " note: no arguments given \u{2014} this only resumes flow {} if its original \
530 invocation also had none; otherwise a new flow starts. Pass the original arguments \
531 after `--` to match.",
532 row.flow_id
533 );
534 }
535}
536
537fn progressed(conn: &Connection, row: &FlowRow) -> bool {
542 read_flow_row(conn, &row.flow_id)
543 .is_ok_and(|after| after.updated_at != row.updated_at || after.status != row.status)
544}
545
546fn usage_pair(message: &str) -> (Option<Rendered>, i32) {
547 #[derive(Serialize)]
548 struct UsageReport<'a> {
549 error: &'static str,
550 what: &'a str,
551 }
552 let human = format!("keel \u{25b8} {message}");
553 let r = Rendered {
554 human,
555 json: to_json(&UsageReport {
556 error: "bad-usage",
557 what: message,
558 }),
559 exit: EXIT_USAGE,
560 to_stderr: true,
561 };
562 (Some(r), EXIT_USAGE)
563}
564
565fn soft_pair(message: &str) -> (Option<Rendered>, i32) {
566 let r = flows::soft_error(message);
567 let code = r.exit;
568 (Some(r), code)
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use rusqlite::params;
575 use std::path::PathBuf;
576
577 const T0: i64 = 1_783_728_000_000;
578
579 fn project_with_fixtures(fixtures: &[&str]) -> (tempfile::TempDir, PathBuf) {
582 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
583 let dir = tempfile::TempDir::new().unwrap();
584 let keel = dir.path().join(".keel");
585 std::fs::create_dir_all(&keel).unwrap();
586 let conn = Connection::open(keel.join("journal.db")).unwrap();
587 let schema = std::fs::read_to_string(root.join("contracts/journal.sql")).unwrap();
588 conn.execute_batch(&schema).unwrap();
589 for f in fixtures {
590 let sql =
591 std::fs::read_to_string(root.join("conformance/fixtures/journal").join(f)).unwrap();
592 conn.execute_batch(&sql).unwrap();
593 }
594 let project = dir.path().to_path_buf();
595 (dir, project)
596 }
597
598 fn write_script(project: &Path, rel: &str) {
599 let path = project.join(rel);
600 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
601 std::fs::write(&path, "def main():\n pass\n").unwrap();
602 }
603
604 #[test]
605 fn missing_journal_is_a_soft_error() {
606 let dir = tempfile::TempDir::new().unwrap();
607 let (r, code) = run(
608 dir.path(),
609 &ResumeOptions {
610 flow: Some("anything".to_owned()),
611 ..Default::default()
612 },
613 T0,
614 );
615 assert_eq!(code, EXIT_FAILURE);
616 assert!(r.unwrap().human.contains("no journal yet"));
617 }
618
619 #[test]
620 fn neither_flow_nor_all_is_a_usage_error() {
621 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
622 let (r, code) = run(&project, &ResumeOptions::default(), T0);
623 assert_eq!(code, EXIT_USAGE);
624 assert!(r.unwrap().human.contains("needs a FLOW_ID"));
625 }
626
627 #[test]
628 fn both_flow_and_all_is_a_usage_error() {
629 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
630 let (r, code) = run(
631 &project,
632 &ResumeOptions {
633 flow: Some("x".to_owned()),
634 all: true,
635 ..Default::default()
636 },
637 T0,
638 );
639 assert_eq!(code, EXIT_USAGE);
640 assert!(r.unwrap().human.contains("cannot give both"));
641 }
642
643 #[test]
644 fn args_with_all_is_a_usage_error() {
645 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
646 let (r, code) = run(
647 &project,
648 &ResumeOptions {
649 all: true,
650 args: vec!["--x".to_owned()],
651 ..Default::default()
652 },
653 T0,
654 );
655 assert_eq!(code, EXIT_USAGE);
656 assert!(r.unwrap().human.contains("cannot forward arguments"));
657 }
658
659 #[test]
660 fn completed_flow_is_refused_as_nothing_to_resume() {
661 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
662 let (r, code) = run(
663 &project,
664 &ResumeOptions {
665 flow: Some("01JZWY0A0000000000000001".to_owned()),
666 ..Default::default()
667 },
668 T0,
669 );
670 assert_eq!(code, EXIT_FAILURE);
671 let human = r.unwrap().human;
672 assert!(human.contains("already completed"));
673 assert!(human.contains("keel replay"));
674 }
675
676 #[test]
677 fn dead_flow_is_refused_with_e032() {
678 let (_d, project) = project_with_fixtures(&["dead-flow.sql"]);
679 let (r, code) = run(
680 &project,
681 &ResumeOptions {
682 flow: Some("01JZWY0A0000000000000003".to_owned()),
683 ..Default::default()
684 },
685 T0,
686 );
687 assert_eq!(code, EXIT_FAILURE);
688 let human = r.unwrap().human;
689 assert!(human.contains("KEEL-E032"));
690 assert!(human.contains("keel trace"));
691 }
692
693 #[test]
694 fn live_lease_is_refused_with_e030() {
695 let (_d, project) = project_with_fixtures(&["interrupted-flow.sql"]);
696 write_script(&project, "pipeline/ingest.py");
697 let (r, code) = run(
699 &project,
700 &ResumeOptions {
701 flow: Some("01JZWY0A0000000000000002".to_owned()),
702 ..Default::default()
703 },
704 T0 + 1_000,
705 );
706 assert_eq!(code, EXIT_FAILURE);
707 let human = r.unwrap().human;
708 assert!(human.contains("KEEL-E030"));
709 assert!(human.contains("host-a:pid-4242"));
710 }
711
712 #[test]
713 fn expired_lease_with_missing_script_reports_the_expected_path() {
714 let (_d, project) = project_with_fixtures(&["interrupted-flow.sql"]);
715 let (r, code) = run(
717 &project,
718 &ResumeOptions {
719 flow: Some("01JZWY0A0000000000000002".to_owned()),
720 ..Default::default()
721 },
722 T0 + 60_000,
723 );
724 assert_eq!(code, EXIT_FAILURE);
725 let human = r.unwrap().human;
726 assert!(human.contains("cannot be resumed from the CLI"));
727 assert!(human.contains("pipeline"));
728 assert!(human.ends_with("directly instead.\n") || human.contains("directly instead."));
729 }
730
731 #[test]
732 fn dotted_module_resolves_to_the_exact_nested_path() {
733 let (_d, project) = project_with_fixtures(&["dead-flow.sql"]);
734 write_script(&project, "jobs/nightly.py");
737 let script = locate_script(&project, "jobs.nightly");
738 match script {
739 ScriptLookup::Found(p) => assert_eq!(p, project.join("jobs").join("nightly.py")),
740 _ => panic!("expected the dotted module to resolve"),
741 }
742 }
743
744 #[test]
745 fn single_component_module_is_found_anywhere_under_the_project() {
746 let dir = tempfile::TempDir::new().unwrap();
747 write_script(dir.path(), "scripts/pipeline.py");
748 match locate_script(dir.path(), "pipeline") {
749 ScriptLookup::Found(p) => {
750 assert_eq!(p, dir.path().join("scripts").join("pipeline.py"));
751 }
752 _ => panic!("expected a single-component module match"),
753 }
754 }
755
756 #[test]
757 fn single_component_module_ambiguity_refuses_to_guess() {
758 let dir = tempfile::TempDir::new().unwrap();
759 write_script(dir.path(), "a/pipeline.py");
760 write_script(dir.path(), "b/pipeline.py");
761 match locate_script(dir.path(), "pipeline") {
762 ScriptLookup::Ambiguous { candidates } => assert_eq!(candidates.len(), 2),
763 _ => panic!("expected ambiguity"),
764 }
765 }
766
767 #[test]
768 fn tree_walk_skips_dependency_and_vcs_directories() {
769 let dir = tempfile::TempDir::new().unwrap();
770 write_script(dir.path(), "src/pipeline.py");
771 write_script(dir.path(), "node_modules/pkg/pipeline.py");
772 write_script(dir.path(), ".venv/lib/pipeline.py");
773 match locate_script(dir.path(), "pipeline") {
774 ScriptLookup::Found(p) => assert_eq!(p, dir.path().join("src").join("pipeline.py")),
775 _ => panic!("expected exactly one match outside skipped directories"),
776 }
777 }
778
779 #[test]
780 fn unsupported_entrypoint_scheme_is_refused() {
781 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
782 let conn = Connection::open(project.join(".keel/journal.db")).unwrap();
783 conn.execute(
784 "INSERT INTO flows (flow_id, entrypoint, args_hash, status, created_at, updated_at) \
785 VALUES ('01NODEFLOW', 'js:server.mjs:handler', 'ah-1', 'running', ?1, ?1)",
786 params![T0],
787 )
788 .unwrap();
789 let (r, code) = run(
790 &project,
791 &ResumeOptions {
792 flow: Some("01NODEFLOW".to_owned()),
793 ..Default::default()
794 },
795 T0,
796 );
797 assert_eq!(code, EXIT_FAILURE);
798 let human = r.unwrap().human;
799 assert!(human.contains("only `py:` entrypoints"));
800 }
801
802 #[test]
803 fn ambiguous_flow_lookup_is_a_soft_error_before_eligibility() {
804 let (_d, project) = project_with_fixtures(&["completed-flow.sql", "dead-flow.sql"]);
806 let (r, code) = run(
807 &project,
808 &ResumeOptions {
809 flow: Some("01JZWY0A".to_owned()),
810 ..Default::default()
811 },
812 T0,
813 );
814 assert_eq!(code, EXIT_FAILURE);
815 assert!(r.unwrap().human.contains("matches"));
816 }
817
818 #[test]
819 fn unknown_flow_is_a_soft_error() {
820 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
821 let (r, code) = run(
822 &project,
823 &ResumeOptions {
824 flow: Some("does-not-exist".to_owned()),
825 ..Default::default()
826 },
827 T0,
828 );
829 assert_eq!(code, EXIT_FAILURE);
830 assert!(r.unwrap().human.contains("no flow matches"));
831 }
832
833 #[test]
834 fn eligible_flow_builds_the_expected_run_plan() {
835 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
839 let conn = Connection::open(project.join(".keel/journal.db")).unwrap();
840 conn.execute(
841 "INSERT INTO flows (flow_id, entrypoint, args_hash, status, created_at, updated_at) \
842 VALUES ('01FAILEDFLOW', 'py:pipeline.retry:main', 'ah-2', 'failed', ?1, ?1)",
843 params![T0],
844 )
845 .unwrap();
846 write_script(&project, "pipeline/retry.py");
847 let row = read_flow_row(&conn, "01FAILEDFLOW").unwrap();
848 let script = eligibility(&project, &row, T0).expect("eligible");
849 assert_eq!(script, project.join("pipeline").join("retry.py"));
850 let plan = run::plan(&script.to_string_lossy(), &["--x".to_owned()], false).unwrap();
851 assert_eq!(plan.program, "python3");
852 assert_eq!(plan.argv[0], "-m");
853 assert_eq!(plan.argv[1], "keel");
854 assert_eq!(plan.argv[2], "run");
855 assert!(plan.argv[3].ends_with("retry.py"));
856 assert_eq!(plan.argv[4], "--x");
857 }
858
859 #[test]
860 fn resumable_candidates_excludes_completed_and_dead() {
861 let (_d, project) = project_with_fixtures(&[
862 "completed-flow.sql",
863 "interrupted-flow.sql",
864 "dead-flow.sql",
865 ]);
866 let conn = Connection::open(project.join(".keel/journal.db")).unwrap();
867 let ids: Vec<String> = resumable_candidates(&conn)
868 .unwrap()
869 .into_iter()
870 .map(|r| r.flow_id)
871 .collect();
872 assert_eq!(ids, vec!["01JZWY0A0000000000000002"]); }
874
875 #[test]
876 fn resume_all_with_nothing_resumable_is_a_clean_no_op() {
877 let (_d, project) = project_with_fixtures(&["completed-flow.sql", "dead-flow.sql"]);
878 let (r, code) = run(
879 &project,
880 &ResumeOptions {
881 all: true,
882 ..Default::default()
883 },
884 T0,
885 );
886 assert_eq!(code, EXIT_OK);
887 let rendered = r.unwrap();
888 assert!(rendered.human.contains("no resumable flows"));
889 assert_eq!(rendered.json["attempted"].as_array().unwrap().len(), 0);
890 assert_eq!(rendered.json["skipped"].as_array().unwrap().len(), 0);
891 }
892
893 #[test]
894 fn resume_all_skips_ineligible_candidates_with_reasons() {
895 let (_d, project) = project_with_fixtures(&["interrupted-flow.sql"]);
897 let (r, code) = run(
898 &project,
899 &ResumeOptions {
900 all: true,
901 ..Default::default()
902 },
903 T0 + 1_000, );
905 assert_eq!(code, EXIT_FAILURE); let rendered = r.unwrap();
907 assert_eq!(rendered.json["attempted"].as_array().unwrap().len(), 0);
908 let skipped = rendered.json["skipped"].as_array().unwrap();
909 assert_eq!(skipped.len(), 1);
910 assert!(skipped[0]["reason"].as_str().unwrap().contains("KEEL-E030"));
911 }
912}