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)`.
138fn 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/// Directories a project-tree walk never descends into: version control,
144/// dependency trees, caches, and Keel's own state.
145const SKIP_DIRS: &[&str] = &[
146    ".git",
147    ".keel",
148    ".venv",
149    "venv",
150    "__pycache__",
151    "node_modules",
152    ".hg",
153    ".svn",
154];
155
156/// Every file under `dir` (skipping [`SKIP_DIRS`], never following symlinks —
157/// `DirEntry::file_type` reports the symlink itself, not its target) whose
158/// file name is exactly `name`. Sorted for deterministic ambiguity reports.
159fn 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
181/// The exact path a dotted module names: `"jobs.nightly"` → `jobs/nightly.py`
182/// under `project` (the module-path suffix `_flow.py::match_flow` requires,
183/// applied in reverse).
184fn 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
193/// Where a module's script lives, or why that cannot be determined — see the
194/// module docs for the single-component-vs-dotted matching rule.
195enum 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
228/// Whether `row` can be resumed right now, and if so, the script to re-invoke.
229/// Resumability here matches `keel replay`'s notion (`running` **or**
230/// `failed` — both resume through the core's `enter()`; only `completed` is
231/// pure replay and only `dead` is refused), which is broader than
232/// `SqliteJournal::incomplete_flows`'s narrower recovery-scan set
233/// (`running` only, per `crates/keel-journal/src/sqlite.rs`).
234fn 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
261/// Read one flow's resume-relevant columns by exact id.
262fn 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
281/// Every `running`/`failed` flow, sorted by id — the candidate set `--all`
282/// filters through [`eligibility`].
283fn 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
305/// `keel flows resume` for `project`: dispatch to the single-flow or `--all`
306/// path, judging leases and the retention-adjacent "already gone" case
307/// against `now_ms`. `Rendered` is only ever produced for a precondition
308/// failure or the `--all` summary — a single successful resume is invisible
309/// on the way in (like `keel run`), because the child IS the resume.
310pub 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/// One `--all` attempt's outcome.
392#[derive(Debug, Serialize)]
393struct AttemptEntry {
394    exit_code: i32,
395    flow_id: String,
396    /// Whether the flow's journal record changed after the child ran — a
397    /// signal (not a guarantee) that the intended flow, not a fresh one with
398    /// mismatched arguments, actually progressed.
399    progressed: bool,
400    script: String,
401}
402
403/// One flow `--all` could not attempt, and why.
404#[derive(Debug, Serialize)]
405struct SkipEntry {
406    flow_id: String,
407    reason: String,
408}
409
410/// The `keel flows resume --all` report.
411#[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    // A flow this pass could not even attempt (a live lease, an unresolvable
460    // script) is exactly as much an outstanding finding as a failed attempt —
461    // `ok` requires an empty `skipped` too, not just successful attempts (the
462    // vacuous "all attempts ok" over zero attempts must not read as success).
463    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
513/// Print the resume notice (and, for a no-args single resume, the honest
514/// caveat about `args_hash`) to stderr before spawning.
515fn 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
537/// Whether `row`'s flow record changed since it was read (a signal, not a
538/// guarantee, that the flow this command meant to resume is the one that
539/// ran). Best-effort: a read failure counts as "did not progress" rather than
540/// aborting a resume that has already happened.
541fn 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    /// A project dir with `.keel/journal.db` built from the frozen schema plus
580    /// the named golden fixtures, ready for a real script tree to be added.
581    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        // The fixture's lease expires at T0+30s; ask before that.
698        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        // No pipeline/ingest.py written: past the lease, script is still missing.
716        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        // dead-flow.sql uses py:jobs.nightly:run — a dotted module. Even with a
735        // decoy jobs/nightly.py, a dead flow is refused before script lookup.
736        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        // Two flows sharing a substring: resolve_flow's ambiguity fires first.
805        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        // A failed flow (no lease, per complete_flow's always-clear rule) with
836        // its script present is eligible: the resume machinery hands off to
837        // `run::plan`, which we can assert on without spawning python3.
838        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"]); // only the running one
873    }
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        // Running flow, lease still live at this `now`: skipped, not attempted.
896        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, // before the fixture's lease expiry
904        );
905        assert_eq!(code, EXIT_FAILURE); // nothing attempted successfully
906        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}