Skip to main content

keel_cli/
flows.rs

1//! `keel flows` and `keel trace <flow>` — the Tier 2 durable-flow inspectors
2//! (dx-spec §6; architecture spec §4.3–4.4).
3//!
4//! Both read only `.keel/journal.db` (the frozen `contracts/journal.sql`
5//! schema), so they are pure queries any SQLite tool could reproduce:
6//!
7//! - `keel flows` lists each flow — id, entrypoint, status, steps done/total,
8//!   age — with `--dead` narrowing to the poison flows a resume gave up on.
9//! - `keel trace <flow>` walks one flow's steps in order with their outcome,
10//!   attempts, and duration.
11//!
12//! Internal control rows never surface: the reserved seq-0 attempt counter and
13//! any `flow:*` replay-branch markers (`kind = 'marker'`) are excluded from both
14//! the step counts and the trace, so the user sees only real work.
15//!
16//! Determinism (dx-spec §5): the `--json` twin carries only values read from the
17//! DB (ids, statuses, `created_at`/`updated_at` in ms) — never a wall-clock
18//! "age", which lives in the human view alone. `age` is computed against an
19//! injected `now`, so the human output is reproducible under test.
20
21use std::path::Path;
22
23use rusqlite::{Connection, OpenFlags};
24use serde::Serialize;
25
26use crate::render::to_json;
27use crate::{EXIT_FAILURE, Rendered, evidence};
28
29/// One flow row for `keel flows`.
30#[derive(Debug, Serialize)]
31struct FlowRow {
32    /// The code hash recorded at first entry (fences replay across deploys).
33    code_hash: Option<String>,
34    /// WS6 staleness surfacing: `Some(true)` = this resumable flow's current
35    /// script hashes differently (resume would replay against changed code;
36    /// the core downgrades nondeterminism fail->warn); `Some(false)` = hash
37    /// verified current; `None` = not derivable (completed/dead flows,
38    /// non-`py:` entrypoints, NULL recorded hash, unresolvable script).
39    code_hash_stale: Option<bool>,
40    created_at: i64,
41    entrypoint: String,
42    flow_id: String,
43    status: String,
44    steps_done: i64,
45    steps_total: i64,
46    updated_at: i64,
47}
48
49/// The `keel flows` report — one struct so the human table and `--json` cannot
50/// drift.
51#[derive(Debug, Serialize)]
52struct FlowsReport {
53    count: usize,
54    dead_only: bool,
55    flows: Vec<FlowRow>,
56    journal_present: bool,
57}
58
59/// `keel flows [--dead]` for `project`, dating ages against `now_ms`.
60pub fn flows(project: &Path, dead_only: bool, now_ms: i64) -> Rendered {
61    // Honor the policy's `journal` key (file: locations), like the engine does.
62    let path = evidence::resolved_journal(project).path;
63    if !path.exists() {
64        return Rendered::ok(
65            "keel \u{25b8} no flows yet.\n  Run a flow with `keel run <script>` (a `[flows]` entrypoint) to record one."
66                .to_owned(),
67            to_json(&FlowsReport {
68                count: 0,
69                dead_only,
70                flows: Vec::new(),
71                journal_present: false,
72            }),
73        );
74    }
75    let rows = match read_flows(project, &path, dead_only) {
76        Ok(r) => r,
77        Err(e) => return soft_error(&e),
78    };
79    let report = FlowsReport {
80        count: rows.len(),
81        dead_only,
82        flows: rows,
83        journal_present: true,
84    };
85    let human = flows_human(&report, now_ms);
86    Rendered::ok(human, to_json(&report))
87}
88
89/// Read the flows table (and per-flow step counts), newest-updated first.
90/// `project` is needed only to compute WS6 code-hash staleness (a resumable
91/// flow's current script vs. its recorded `code_hash`).
92fn read_flows(project: &Path, path: &Path, dead_only: bool) -> Result<Vec<FlowRow>, String> {
93    let conn = open_ro(path)?;
94    let sql = if dead_only {
95        "SELECT flow_id, entrypoint, status, created_at, updated_at, code_hash FROM flows \
96         WHERE status = 'dead' ORDER BY updated_at DESC, flow_id"
97    } else {
98        "SELECT flow_id, entrypoint, status, created_at, updated_at, code_hash FROM flows \
99         ORDER BY updated_at DESC, flow_id"
100    };
101    let mut stmt = conn.prepare(sql).map_err(|e| q(&e))?;
102    let raw = stmt
103        .query_map([], |row| {
104            Ok((
105                row.get::<_, String>(0)?,
106                row.get::<_, String>(1)?,
107                row.get::<_, String>(2)?,
108                row.get::<_, i64>(3)?,
109                row.get::<_, i64>(4)?,
110                row.get::<_, Option<String>>(5)?,
111            ))
112        })
113        .map_err(|e| q(&e))?
114        .collect::<rusqlite::Result<Vec<_>>>()
115        .map_err(|e| q(&e))?;
116
117    let mut out = Vec::with_capacity(raw.len());
118    for (flow_id, entrypoint, status, created_at, updated_at, code_hash) in raw {
119        let (steps_total, steps_done) = step_counts(&conn, &flow_id)?;
120        let code_hash_stale = staleness(project, &status, &entrypoint, code_hash.as_deref());
121        out.push(FlowRow {
122            code_hash,
123            code_hash_stale,
124            created_at,
125            entrypoint,
126            flow_id,
127            status,
128            steps_done,
129            steps_total,
130            updated_at,
131        });
132    }
133    Ok(out)
134}
135
136/// The Python front end's exact flow-hash convention
137/// (`python/keel/src/keel/_flow.py::_code_hash`): sha256 of the script
138/// bytes, first 16 hex chars. Byte-for-byte the same derivation or the
139/// staleness verdict is noise.
140fn current_code_hash(script: &Path) -> Option<String> {
141    use sha2::{Digest, Sha256};
142    let bytes = std::fs::read(script).ok()?;
143    let mut hasher = Sha256::new();
144    hasher.update(&bytes);
145    Some(format!("{:x}", hasher.finalize())[..16].to_owned())
146}
147
148/// Apply the WS6 staleness rule (module docs): resumable + recorded hash +
149/// resolvable `py:` script, else `None`.
150fn staleness(
151    project: &Path,
152    status: &str,
153    entrypoint: &str,
154    recorded: Option<&str>,
155) -> Option<bool> {
156    if !matches!(status, "running" | "failed") {
157        return None;
158    }
159    let recorded = recorded?;
160    let (lang, module, _function) = crate::resume::parse_entrypoint(entrypoint)?;
161    if lang != "py" {
162        return None;
163    }
164    match crate::resume::locate_script(project, module) {
165        crate::resume::ScriptLookup::Found(path) => Some(current_code_hash(&path)? != recorded),
166        _ => None,
167    }
168}
169
170/// A resumable flow whose recorded code hash no longer matches its script —
171/// doctor's `code-hash-stale` follow-up input (WS6).
172pub(crate) struct StaleFlow {
173    pub(crate) flow_id: String,
174    pub(crate) entrypoint: String,
175}
176
177/// Stale resumable flows for `project`, sorted by flow_id; empty on any read
178/// problem (doctor never fails on journal trouble — it reports what it can).
179pub(crate) fn stale_code_hash_flows(project: &Path) -> Vec<StaleFlow> {
180    let path = evidence::resolved_journal(project).path;
181    if !path.exists() {
182        return Vec::new();
183    }
184    let Ok(rows) = read_flows(project, &path, false) else {
185        return Vec::new();
186    };
187    let mut out: Vec<StaleFlow> = rows
188        .into_iter()
189        .filter(|r| r.code_hash_stale == Some(true))
190        .map(|r| StaleFlow {
191            flow_id: r.flow_id,
192            entrypoint: r.entrypoint,
193        })
194        .collect();
195    out.sort_by(|a, b| a.flow_id.cmp(&b.flow_id));
196    out
197}
198
199/// `(total, done)` real steps for a flow — excluding internal `marker` rows (the
200/// seq-0 attempt counter and replay-branch markers). `done` counts terminal
201/// (`ok`/`error`) outcomes; a still-`running` step is counted only in `total`.
202fn step_counts(conn: &Connection, flow_id: &str) -> Result<(i64, i64), String> {
203    conn.query_row(
204        "SELECT COUNT(*), COALESCE(SUM(CASE WHEN outcome IN ('ok','error') THEN 1 ELSE 0 END), 0) \
205         FROM steps WHERE flow_id = ?1 AND kind != 'marker'",
206        [flow_id],
207        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
208    )
209    .map_err(|e| q(&e))
210}
211
212/// The human flows table, derived entirely from [`FlowsReport`].
213fn flows_human(report: &FlowsReport, now_ms: i64) -> String {
214    if report.flows.is_empty() {
215        let what = if report.dead_only {
216            "no dead flows \u{2014} nothing has exhausted its resume cap."
217        } else {
218            "no flows recorded yet."
219        };
220        return format!("keel \u{25b8} {what}");
221    }
222    let scope = if report.dead_only { " (dead)" } else { "" };
223    let mut lines = vec![format!(
224        "keel \u{25b8} flows{scope}: {} total\n",
225        report.count
226    )];
227    for f in &report.flows {
228        let stale_marker = if f.code_hash_stale == Some(true) {
229            "  code changed"
230        } else {
231            ""
232        };
233        lines.push(format!(
234            "  {}  {}  {}  steps {}/{}  {}{stale_marker}\n",
235            f.flow_id,
236            f.entrypoint,
237            f.status,
238            f.steps_done,
239            f.steps_total,
240            fmt_age(now_ms, f.updated_at),
241        ));
242    }
243    lines.concat()
244}
245
246/// One step line for `keel trace`.
247#[derive(Debug, Serialize)]
248struct TraceStep {
249    attempt: i64,
250    duration_ms: Option<i64>,
251    ended_at: Option<i64>,
252    kind: String,
253    outcome: String,
254    seq: i64,
255    started_at: i64,
256    step_key: String,
257}
258
259/// The `keel trace <flow>` report.
260#[derive(Debug, Serialize)]
261struct TraceReport {
262    created_at: i64,
263    entrypoint: String,
264    flow_id: String,
265    status: String,
266    steps: Vec<TraceStep>,
267    updated_at: i64,
268}
269
270/// `keel trace <flow>` for `project`. `flow` is an exact `flow_id`, or a
271/// substring of an id/entrypoint that resolves to exactly one flow.
272pub fn trace(project: &Path, flow: &str) -> Rendered {
273    let path = evidence::resolved_journal(project).path;
274    if !path.exists() {
275        return soft_error("no journal yet (.keel/journal.db). Run a flow first with `keel run`.");
276    }
277    let conn = match open_ro(&path) {
278        Ok(c) => c,
279        Err(e) => return soft_error(&e),
280    };
281    let resolved = match resolve_flow(&conn, flow) {
282        Ok(r) => r,
283        Err(e) => return soft_error(&e),
284    };
285    let steps = match read_steps(&conn, &resolved.flow_id) {
286        Ok(s) => s,
287        Err(e) => return soft_error(&e),
288    };
289    let report = TraceReport {
290        created_at: resolved.created_at,
291        entrypoint: resolved.entrypoint,
292        flow_id: resolved.flow_id,
293        status: resolved.status,
294        steps,
295        updated_at: resolved.updated_at,
296    };
297    let human = trace_human(&report);
298    Rendered::ok(human, to_json(&report))
299}
300
301/// A resolved flow header (before its steps are read). Shared with
302/// [`replay`](crate::replay), which resolves flows the same way.
303pub(crate) struct ResolvedFlow {
304    pub(crate) flow_id: String,
305    pub(crate) entrypoint: String,
306    pub(crate) status: String,
307    pub(crate) created_at: i64,
308    pub(crate) updated_at: i64,
309}
310
311/// Resolve `flow` to exactly one flow: an exact `flow_id`, else a unique
312/// substring match on id or entrypoint. Ambiguity and absence are precise
313/// errors, never a silent pick.
314pub(crate) fn resolve_flow(conn: &Connection, flow: &str) -> Result<ResolvedFlow, String> {
315    let like = format!("%{flow}%");
316    let mut stmt = conn
317        .prepare(
318            "SELECT flow_id, entrypoint, status, created_at, updated_at FROM flows \
319             WHERE flow_id = ?1 OR flow_id LIKE ?2 OR entrypoint LIKE ?2 \
320             ORDER BY (flow_id = ?1) DESC, updated_at DESC, flow_id",
321        )
322        .map_err(|e| q(&e))?;
323    let matches = stmt
324        .query_map([flow, &like], |row| {
325            Ok(ResolvedFlow {
326                flow_id: row.get(0)?,
327                entrypoint: row.get(1)?,
328                status: row.get(2)?,
329                created_at: row.get(3)?,
330                updated_at: row.get(4)?,
331            })
332        })
333        .map_err(|e| q(&e))?
334        .collect::<rusqlite::Result<Vec<_>>>()
335        .map_err(|e| q(&e))?;
336
337    // An exact id match wins outright even if the substring also hits others.
338    if let Some(first) = matches.first()
339        && first.flow_id == flow
340    {
341        return Ok(matches.into_iter().next().expect("first exists"));
342    }
343    match matches.len() {
344        0 => Err(format!(
345            "no flow matches {flow:?} in the journal. Run `keel flows` to list recorded flows."
346        )),
347        1 => Ok(matches.into_iter().next().expect("one match")),
348        n => {
349            let ids: Vec<&str> = matches.iter().take(5).map(|m| m.flow_id.as_str()).collect();
350            Err(format!(
351                "{flow:?} matches {n} flows ({}); use a full flow_id.",
352                ids.join(", ")
353            ))
354        }
355    }
356}
357
358/// Read a flow's real steps (excluding `marker` rows) in seq order.
359fn read_steps(conn: &Connection, flow_id: &str) -> Result<Vec<TraceStep>, String> {
360    let mut stmt = conn
361        .prepare(
362            "SELECT seq, step_key, kind, attempt, outcome, started_at, ended_at FROM steps \
363             WHERE flow_id = ?1 AND kind != 'marker' ORDER BY seq",
364        )
365        .map_err(|e| q(&e))?;
366    let steps = stmt
367        .query_map([flow_id], |row| {
368            let seq: i64 = row.get(0)?;
369            let step_key: String = row.get(1)?;
370            let kind: String = row.get(2)?;
371            let attempt: i64 = row.get(3)?;
372            let outcome: String = row.get(4)?;
373            let started_at: i64 = row.get(5)?;
374            let ended_at: Option<i64> = row.get(6)?;
375            Ok(TraceStep {
376                attempt,
377                duration_ms: ended_at.map(|e| e - started_at),
378                ended_at,
379                kind,
380                outcome,
381                seq,
382                started_at,
383                step_key,
384            })
385        })
386        .map_err(|e| q(&e))?
387        .collect::<rusqlite::Result<Vec<_>>>()
388        .map_err(|e| q(&e))?;
389    Ok(steps)
390}
391
392/// The human trace, derived entirely from [`TraceReport`].
393fn trace_human(report: &TraceReport) -> String {
394    let mut lines = vec![
395        format!(
396            "keel \u{25b8} trace {}\n  entrypoint: {}\n  status:     {}\n",
397            report.flow_id, report.entrypoint, report.status
398        ),
399        format!("  steps:      {}\n", report.steps.len()),
400    ];
401    for s in &report.steps {
402        let dur = match s.duration_ms {
403            Some(ms) => format!("{ms}ms"),
404            None => "\u{2014}".to_owned(), // still running
405        };
406        let attempts = if s.attempt > 1 {
407            format!(" ({} attempts)", s.attempt)
408        } else {
409            String::new()
410        };
411        lines.push(format!(
412            "    {:>3}. {:<7} {:<28} {:<8} {}{}\n",
413            s.seq, s.kind, s.step_key, s.outcome, dur, attempts,
414        ));
415    }
416    lines.concat()
417}
418
419/// A coarse human age ("5s", "3m", "2h", "1d") from `then_ms` to `now_ms`.
420fn fmt_age(now_ms: i64, then_ms: i64) -> String {
421    let secs = (now_ms - then_ms).max(0) / 1000;
422    if secs < 60 {
423        format!("{secs}s ago")
424    } else if secs < 3600 {
425        format!("{}m ago", secs / 60)
426    } else if secs < 86_400 {
427        format!("{}h ago", secs / 3600)
428    } else {
429        format!("{}d ago", secs / 86_400)
430    }
431}
432
433pub(crate) fn open_ro(path: &Path) -> Result<Connection, String> {
434    Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
435        .map_err(|e| format!("could not open {}: {e}", path.display()))
436}
437
438pub(crate) fn q(e: &rusqlite::Error) -> String {
439    format!("journal query failed: {e}")
440}
441
442/// A read failure (exit 1, on stderr) — mirrors `keel status`. Shared with
443/// [`crate::replay`] (the same journal reads) and `keel flows suggest` (same
444/// failure mode there: a corrupt `.keel/discovery.db`).
445pub(crate) fn soft_error(message: &str) -> Rendered {
446    #[derive(Serialize)]
447    struct ErrReport<'a> {
448        error: &'a str,
449    }
450    Rendered {
451        human: format!("keel \u{25b8} {message}"),
452        json: to_json(&ErrReport { error: message }),
453        exit: EXIT_FAILURE,
454        to_stderr: true,
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::path::PathBuf;
462
463    const T0: i64 = 1_783_728_000_000;
464
465    /// Build a project dir with `.keel/journal.db` from a golden fixture
466    /// (`conformance/fixtures/journal/<fixture>`), applied over the frozen
467    /// schema — self-contained, no reliance on the checked-in `.gen/` build.
468    fn project_with_fixture(fixture: &str) -> (tempfile::TempDir, PathBuf) {
469        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
470        let schema = std::fs::read_to_string(root.join("contracts/journal.sql")).unwrap();
471        let sql = std::fs::read_to_string(root.join("conformance/fixtures/journal").join(fixture))
472            .unwrap();
473        let dir = tempfile::TempDir::new().unwrap();
474        let keel = dir.path().join(".keel");
475        std::fs::create_dir_all(&keel).unwrap();
476        let db = keel.join("journal.db");
477        let conn = Connection::open(&db).unwrap();
478        conn.execute_batch(&schema).unwrap();
479        conn.execute_batch(&sql).unwrap();
480        let project = dir.path().to_path_buf();
481        (dir, project)
482    }
483
484    #[test]
485    fn flows_lists_completed_with_step_counts() {
486        let (_d, project) = project_with_fixture("completed-flow.sql");
487        let r = flows(&project, false, T0 + 10_000);
488        assert_eq!(r.exit, crate::EXIT_OK);
489        assert_eq!(r.json["count"], 1);
490        let f = &r.json["flows"][0];
491        assert_eq!(f["entrypoint"], "py:pipeline.ingest:main");
492        assert_eq!(f["status"], "completed");
493        assert_eq!(f["steps_done"], 5);
494        assert_eq!(f["steps_total"], 5);
495        // Human view is deterministic under a fixed `now`.
496        assert!(r.human.contains("steps 5/5"));
497        assert!(r.human.contains("ago"));
498    }
499
500    #[test]
501    fn flows_interrupted_shows_incomplete_step_count() {
502        let (_d, project) = project_with_fixture("interrupted-flow.sql");
503        let r = flows(&project, false, T0 + 60_000);
504        let f = &r.json["flows"][0];
505        assert_eq!(f["status"], "running");
506        // 4 recorded steps; step 4 is still `running` → 3 done of 4.
507        assert_eq!(f["steps_total"], 4);
508        assert_eq!(f["steps_done"], 3);
509    }
510
511    #[test]
512    fn flows_dead_filter_selects_only_dead() {
513        let (_d, project) = project_with_fixture("dead-flow.sql");
514        let all = flows(&project, false, T0);
515        assert_eq!(all.json["count"], 1);
516        let dead = flows(&project, true, T0);
517        assert_eq!(dead.json["count"], 1);
518        assert_eq!(dead.json["dead_only"], true);
519        assert_eq!(dead.json["flows"][0]["status"], "dead");
520        // A completed fixture has no dead flows.
521        let (_d2, p2) = project_with_fixture("completed-flow.sql");
522        assert_eq!(flows(&p2, true, T0).json["count"], 0);
523    }
524
525    #[test]
526    fn flows_absent_journal_nudges() {
527        let dir = tempfile::TempDir::new().unwrap();
528        let r = flows(dir.path(), false, T0);
529        assert_eq!(r.exit, crate::EXIT_OK);
530        assert_eq!(r.json["journal_present"], false);
531        assert_eq!(r.json["count"], 0);
532    }
533
534    /// WS6: a resumable flow whose current script hashes differently from the
535    /// recorded code_hash is flagged; a completed flow reports null; a flow
536    /// whose script cannot be located reports null (honesty over coverage).
537    #[test]
538    fn flows_reports_code_hash_staleness_tristate() {
539        let (_d, project) = project_with_fixture("interrupted-flow.sql");
540        // interrupted-flow.sql: running flow, entrypoint py:pipeline.ingest:main.
541        // Read its recorded code_hash from the DB rather than assuming the
542        // fixture value:
543        let conn = Connection::open(project.join(".keel/journal.db")).unwrap();
544        let recorded: Option<String> = conn
545            .query_row("SELECT code_hash FROM flows", [], |r| r.get(0))
546            .unwrap();
547        // Case 1: no script on disk -> null.
548        let r = flows(&project, false, T0 + 60_000);
549        assert!(r.json["flows"][0]["code_hash_stale"].is_null());
550        assert_eq!(r.json["flows"][0]["code_hash"], serde_json::json!(recorded));
551        // Case 2: script present, content that CANNOT hash to the recorded
552        // value's 16-hex prefix (recorded fixture hashes are synthetic) -> stale.
553        let script = project.join("pipeline").join("ingest.py");
554        std::fs::create_dir_all(script.parent().unwrap()).unwrap();
555        std::fs::write(&script, "def main():\n    pass\n").unwrap();
556        let r = flows(&project, false, T0 + 60_000);
557        if recorded.is_some() {
558            assert_eq!(r.json["flows"][0]["code_hash_stale"], true);
559            assert!(r.human.contains("code changed"));
560        } else {
561            // Fixture stores NULL code_hash: tri-state stays null; Case 3
562            // below covers the non-null path with a synthetic row.
563            assert!(r.json["flows"][0]["code_hash_stale"].is_null());
564        }
565        // Case 3: synthetic running flow whose recorded hash MATCHES the file.
566        let bytes = std::fs::read(&script).unwrap();
567        let fresh = {
568            use sha2::{Digest, Sha256};
569            let mut h = Sha256::new();
570            h.update(&bytes);
571            format!("{:x}", h.finalize())[..16].to_owned()
572        };
573        conn.execute(
574            "INSERT INTO flows (flow_id, entrypoint, args_hash, code_hash, status, created_at, updated_at) \
575             VALUES ('01FRESHFLOW', 'py:pipeline.ingest:main', 'ah-x', ?1, 'failed', ?2, ?2)",
576            rusqlite::params![fresh, T0],
577        )
578        .unwrap();
579        let r = flows(&project, false, T0 + 60_000);
580        let fresh_row = r.json["flows"]
581            .as_array()
582            .unwrap()
583            .iter()
584            .find(|f| f["flow_id"] == "01FRESHFLOW")
585            .unwrap()
586            .clone();
587        assert_eq!(fresh_row["code_hash_stale"], false);
588    }
589
590    #[test]
591    fn completed_flows_never_report_staleness() {
592        let (_d, project) = project_with_fixture("completed-flow.sql");
593        let r = flows(&project, false, T0);
594        assert!(r.json["flows"][0]["code_hash_stale"].is_null());
595    }
596
597    #[test]
598    fn trace_walks_steps_with_outcomes_and_durations() {
599        let (_d, project) = project_with_fixture("completed-flow.sql");
600        let r = trace(&project, "py:pipeline.ingest:main");
601        assert_eq!(r.exit, crate::EXIT_OK);
602        assert_eq!(r.json["status"], "completed");
603        let steps = r.json["steps"].as_array().unwrap();
604        assert_eq!(steps.len(), 5);
605        // seq 1: the fetch effect, 240ms.
606        assert_eq!(steps[0]["seq"], 1);
607        assert_eq!(steps[0]["step_key"], "api.source.internal#q1");
608        assert_eq!(steps[0]["outcome"], "ok");
609        assert_eq!(steps[0]["duration_ms"], 240);
610        // seq 2: the virtualized time read.
611        assert_eq!(steps[1]["kind"], "time");
612        assert_eq!(steps[1]["step_key"], "py:time.time#-");
613        // seq 3: enrich succeeded on its 2nd attempt.
614        assert_eq!(steps[2]["attempt"], 2);
615        assert!(r.human.contains("2 attempts"));
616    }
617
618    #[test]
619    fn trace_running_step_has_no_duration() {
620        let (_d, project) = project_with_fixture("interrupted-flow.sql");
621        let r = trace(&project, "01JZWY0A0000000000000002");
622        let steps = r.json["steps"].as_array().unwrap();
623        assert_eq!(steps.len(), 4);
624        assert_eq!(steps[3]["outcome"], "running");
625        assert!(steps[3]["duration_ms"].is_null());
626        assert!(steps[3]["ended_at"].is_null());
627    }
628
629    #[test]
630    fn trace_unknown_flow_is_a_soft_error() {
631        let (_d, project) = project_with_fixture("completed-flow.sql");
632        let r = trace(&project, "does-not-exist");
633        assert_eq!(r.exit, EXIT_FAILURE);
634        assert!(r.to_stderr);
635        assert!(r.human.contains("no flow matches"));
636    }
637}