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