Skip to main content

keel_cli/
resume.rs

1//! `keel flows resume <FLOW> [-- ARGS…]` / `keel flows resume --all` —
2//! re-invoke a resumable flow's recorded entrypoint through `keel run`
3//! semantics (architecture spec §4.3 "Recovery … re-execute"; §6 laptop model
4//! "a crashed script resumes when re-invoked").
5//!
6//! ## What the journal actually lets the CLI know
7//!
8//! The frozen schema (`contracts/journal.sql`) records a flow's *identity* —
9//! `entrypoint` (`"py:<module>:<function>"`) and `args_hash` (a one-way
10//! digest) — never the script path the user originally typed at
11//! `keel run <script>`, nor the literal CLI arguments. That is enough to
12//! recognize a flow's provenance but never enough to reconstruct the exact
13//! command that created it. This command is the honest middle ground between
14//! pure re-invocation (dx-spec/§6, which asks the operator to remember the
15//! command) and a fully automatic background scan (§4.3, which would face the
16//! identical gap with nobody present to fill it in) — it does the lookup and
17//! the eligibility checks, and is explicit about what it cannot know:
18//!
19//! - **The script.** A `py:<module>:<function>` entrypoint's module maps to a
20//!   candidate file the same way the Python front end's `match_flow`
21//!   (`python/keel/src/keel/_flow.py`) matches one on the way in: a dotted
22//!   module (`jobs.nightly`) names an exact path (`jobs/nightly.py`); a
23//!   single-component module (`pipeline`) matches any `pipeline.py` under the
24//!   project, so this command searches the tree for one. Zero matches, or more
25//!   than one for a single-component module, and it stops rather than guess.
26//! - **The arguments.** `args_hash` cannot be inverted. Passing none
27//!   reproduces the original flow_id only when the original invocation also
28//!   had none; otherwise the front end derives a *different* deterministic
29//!   flow_id (spec §4.3) and a fresh flow starts next to the one this command
30//!   was asked to resume. Pass the original arguments after `--` when the
31//!   flow took any. After the child exits, this command re-reads the flow's
32//!   `updated_at` and says plainly when it looks like the wrong flow ran.
33//!
34//! Non-`py:` entrypoints (no other front end designates durable flows yet),
35//! `completed` flows (nothing to resume — see `keel replay`), and `dead` flows
36//! (KEEL-E032, never auto-resumed) are refused with a precise what/why/next;
37//! see [`Ineligible`].
38
39use 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/// `keel flows resume` options: either one `flow` (id or substring) or `all`,
48/// never both — `args` only makes sense for a single flow.
49#[derive(Debug, Clone, Default)]
50pub struct ResumeOptions {
51    /// A flow_id, or a substring of an id/entrypoint that names one flow.
52    pub flow: Option<String>,
53    /// Resume every currently-resumable flow instead of one.
54    pub all: bool,
55    /// Arguments forwarded to the resumed script (single-flow only).
56    pub args: Vec<String>,
57}
58
59/// One flow row with the columns eligibility needs (a superset of
60/// [`flows::ResolvedFlow`], which does not carry the lease/age columns).
61struct 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/// Why a flow cannot be resumed from the CLI right now — each renders as a
71/// precise what/why/next.
72#[derive(Debug)]
73enum Ineligible {
74    /// Nothing to resume; `keel replay` inspects a completed flow instead.
75    Completed,
76    /// Dead flows are never auto-resumed (KEEL-E032).
77    Dead,
78    /// A lease not yet expired: another process may still hold it (KEEL-E030).
79    LeaseLive { holder: String, expires: i64 },
80    /// The entrypoint scheme this CLI knows how to re-invoke is `py:` only.
81    UnsupportedEntrypoint,
82    /// The module's file could not be found under the project.
83    ScriptNotFound { candidate: String },
84    /// A single-component module matched more than one file; resuming the
85    /// wrong one would replay a stranger's steps, so this refuses to guess.
86    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
133/// The `py:` entrypoint scheme this CLI knows how to re-invoke (Tier 2 durable
134/// flows are Python-only as of this build).
135const PY_SCHEME: &str = "py";
136
137/// Split `"py:pipeline.ingest:main"` into `(lang, module, function)`.
138pub(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
143/// Every file under `dir` (skipping [`crate::scan::SKIP_DIRS`], never
144/// following symlinks — `DirEntry::file_type` reports the symlink itself,
145/// not its target) whose file name is exactly `name`. Sorted for
146/// deterministic ambiguity reports.
147fn 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
169/// The exact path a dotted module names: `"jobs.nightly"` → `jobs/nightly.py`
170/// under `project` (the module-path suffix `_flow.py::match_flow` requires,
171/// applied in reverse).
172fn 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
181/// Where a module's script lives, or why that cannot be determined — see the
182/// module docs for the single-component-vs-dotted matching rule.
183pub(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
216/// Whether `row` can be resumed right now, and if so, the script to re-invoke.
217/// Resumability here matches `keel replay`'s notion (`running` **or**
218/// `failed` — both resume through the core's `enter()`; only `completed` is
219/// pure replay and only `dead` is refused), which is broader than
220/// `SqliteJournal::incomplete_flows`'s narrower recovery-scan set
221/// (`running` only, per `crates/keel-journal/src/sqlite.rs`).
222fn 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
249/// Read one flow's resume-relevant columns by exact id.
250fn 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
269/// Every `running`/`failed` flow, sorted by id — the candidate set `--all`
270/// filters through [`eligibility`].
271fn 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
293/// `keel flows resume` for `project`: dispatch to the single-flow or `--all`
294/// path, judging leases and the retention-adjacent "already gone" case
295/// against `now_ms`. `Rendered` is only ever produced for a precondition
296/// failure or the `--all` summary — a single successful resume is invisible
297/// on the way in (like `keel run`), because the child IS the resume.
298pub 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/// One `--all` attempt's outcome.
380#[derive(Debug, Serialize)]
381struct AttemptEntry {
382    exit_code: i32,
383    flow_id: String,
384    /// Whether the flow's journal record changed after the child ran — a
385    /// signal (not a guarantee) that the intended flow, not a fresh one with
386    /// mismatched arguments, actually progressed.
387    progressed: bool,
388    script: String,
389}
390
391/// One flow `--all` could not attempt, and why.
392#[derive(Debug, Serialize)]
393struct SkipEntry {
394    flow_id: String,
395    reason: String,
396}
397
398/// The `keel flows resume --all` report.
399#[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    // A flow this pass could not even attempt (a live lease, an unresolvable
448    // script) is exactly as much an outstanding finding as a failed attempt —
449    // `ok` requires an empty `skipped` too, not just successful attempts (the
450    // vacuous "all attempts ok" over zero attempts must not read as success).
451    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
501/// Print the resume notice (and, for a no-args single resume, the honest
502/// caveat about `args_hash`) to stderr before spawning.
503fn 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
525/// Whether `row`'s flow record changed since it was read (a signal, not a
526/// guarantee, that the flow this command meant to resume is the one that
527/// ran). Best-effort: a read failure counts as "did not progress" rather than
528/// aborting a resume that has already happened.
529fn 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    /// A project dir with `.keel/journal.db` built from the frozen schema plus
568    /// the named golden fixtures, ready for a real script tree to be added.
569    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        // The fixture's lease expires at T0+30s; ask before that.
686        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        // No pipeline/ingest.py written: past the lease, script is still missing.
704        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        // dead-flow.sql uses py:jobs.nightly:run — a dotted module. Even with a
723        // decoy jobs/nightly.py, a dead flow is refused before script lookup.
724        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        // Two flows sharing a substring: resolve_flow's ambiguity fires first.
793        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        // A failed flow (no lease, per complete_flow's always-clear rule) with
824        // its script present is eligible: the resume machinery hands off to
825        // `run::plan`, which we can assert on without spawning python3.
826        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"]); // only the running one
861    }
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        // Running flow, lease still live at this `now`: skipped, not attempted.
884        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, // before the fixture's lease expiry
892        );
893        assert_eq!(code, EXIT_FAILURE); // nothing attempted successfully
894        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}