1use 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#[derive(Debug, Serialize)]
31struct FlowRow {
32 code_hash: Option<String>,
34 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#[derive(Debug, Serialize)]
52struct FlowsReport {
53 count: usize,
54 dead_only: bool,
55 flows: Vec<FlowRow>,
56 journal_present: bool,
57}
58
59pub fn flows(project: &Path, dead_only: bool, now_ms: i64) -> Rendered {
61 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
89fn 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
136fn 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
148fn 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
170pub(crate) struct StaleFlow {
173 pub(crate) flow_id: String,
174 pub(crate) entrypoint: String,
175}
176
177pub(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
199fn 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
212fn 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#[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#[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
270pub 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
301pub(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
311pub(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 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
358fn 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
392fn 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(), };
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
419fn 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
442pub(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 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 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 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 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 #[test]
538 fn flows_reports_code_hash_staleness_tristate() {
539 let (_d, project) = project_with_fixture("interrupted-flow.sql");
540 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 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 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 assert!(r.json["flows"][0]["code_hash_stale"].is_null());
564 }
565 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 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 assert_eq!(steps[1]["kind"], "time");
612 assert_eq!(steps[1]["step_key"], "py:time.time#-");
613 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}