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 created_at: i64,
33 entrypoint: String,
34 flow_id: String,
35 status: String,
36 steps_done: i64,
37 steps_total: i64,
38 updated_at: i64,
39}
40
41#[derive(Debug, Serialize)]
44struct FlowsReport {
45 count: usize,
46 dead_only: bool,
47 flows: Vec<FlowRow>,
48 journal_present: bool,
49}
50
51pub fn flows(project: &Path, dead_only: bool, now_ms: i64) -> Rendered {
53 let path = evidence::resolved_journal(project).path;
55 if !path.exists() {
56 return Rendered::ok(
57 "keel \u{25b8} no flows yet.\n Run a flow with `keel run <script>` (a `[flows]` entrypoint) to record one."
58 .to_owned(),
59 to_json(&FlowsReport {
60 count: 0,
61 dead_only,
62 flows: Vec::new(),
63 journal_present: false,
64 }),
65 );
66 }
67 let rows = match read_flows(&path, dead_only) {
68 Ok(r) => r,
69 Err(e) => return soft_error(&e),
70 };
71 let report = FlowsReport {
72 count: rows.len(),
73 dead_only,
74 flows: rows,
75 journal_present: true,
76 };
77 let human = flows_human(&report, now_ms);
78 Rendered::ok(human, to_json(&report))
79}
80
81fn read_flows(path: &Path, dead_only: bool) -> Result<Vec<FlowRow>, String> {
83 let conn = open_ro(path)?;
84 let sql = if dead_only {
85 "SELECT flow_id, entrypoint, status, created_at, updated_at FROM flows \
86 WHERE status = 'dead' ORDER BY updated_at DESC, flow_id"
87 } else {
88 "SELECT flow_id, entrypoint, status, created_at, updated_at FROM flows \
89 ORDER BY updated_at DESC, flow_id"
90 };
91 let mut stmt = conn.prepare(sql).map_err(|e| q(&e))?;
92 let raw = stmt
93 .query_map([], |row| {
94 Ok((
95 row.get::<_, String>(0)?,
96 row.get::<_, String>(1)?,
97 row.get::<_, String>(2)?,
98 row.get::<_, i64>(3)?,
99 row.get::<_, i64>(4)?,
100 ))
101 })
102 .map_err(|e| q(&e))?
103 .collect::<rusqlite::Result<Vec<_>>>()
104 .map_err(|e| q(&e))?;
105
106 let mut out = Vec::with_capacity(raw.len());
107 for (flow_id, entrypoint, status, created_at, updated_at) in raw {
108 let (steps_total, steps_done) = step_counts(&conn, &flow_id)?;
109 out.push(FlowRow {
110 created_at,
111 entrypoint,
112 flow_id,
113 status,
114 steps_done,
115 steps_total,
116 updated_at,
117 });
118 }
119 Ok(out)
120}
121
122fn step_counts(conn: &Connection, flow_id: &str) -> Result<(i64, i64), String> {
126 conn.query_row(
127 "SELECT COUNT(*), COALESCE(SUM(CASE WHEN outcome IN ('ok','error') THEN 1 ELSE 0 END), 0) \
128 FROM steps WHERE flow_id = ?1 AND kind != 'marker'",
129 [flow_id],
130 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
131 )
132 .map_err(|e| q(&e))
133}
134
135fn flows_human(report: &FlowsReport, now_ms: i64) -> String {
137 if report.flows.is_empty() {
138 let what = if report.dead_only {
139 "no dead flows \u{2014} nothing has exhausted its resume cap."
140 } else {
141 "no flows recorded yet."
142 };
143 return format!("keel \u{25b8} {what}");
144 }
145 let scope = if report.dead_only { " (dead)" } else { "" };
146 let mut lines = vec![format!(
147 "keel \u{25b8} flows{scope}: {} total\n",
148 report.count
149 )];
150 for f in &report.flows {
151 lines.push(format!(
152 " {} {} {} steps {}/{} {}\n",
153 f.flow_id,
154 f.entrypoint,
155 f.status,
156 f.steps_done,
157 f.steps_total,
158 fmt_age(now_ms, f.updated_at),
159 ));
160 }
161 lines.concat()
162}
163
164#[derive(Debug, Serialize)]
166struct TraceStep {
167 attempt: i64,
168 duration_ms: Option<i64>,
169 ended_at: Option<i64>,
170 kind: String,
171 outcome: String,
172 seq: i64,
173 started_at: i64,
174 step_key: String,
175}
176
177#[derive(Debug, Serialize)]
179struct TraceReport {
180 created_at: i64,
181 entrypoint: String,
182 flow_id: String,
183 status: String,
184 steps: Vec<TraceStep>,
185 updated_at: i64,
186}
187
188pub fn trace(project: &Path, flow: &str) -> Rendered {
191 let path = evidence::resolved_journal(project).path;
192 if !path.exists() {
193 return soft_error("no journal yet (.keel/journal.db). Run a flow first with `keel run`.");
194 }
195 let conn = match open_ro(&path) {
196 Ok(c) => c,
197 Err(e) => return soft_error(&e),
198 };
199 let resolved = match resolve_flow(&conn, flow) {
200 Ok(r) => r,
201 Err(e) => return soft_error(&e),
202 };
203 let steps = match read_steps(&conn, &resolved.flow_id) {
204 Ok(s) => s,
205 Err(e) => return soft_error(&e),
206 };
207 let report = TraceReport {
208 created_at: resolved.created_at,
209 entrypoint: resolved.entrypoint,
210 flow_id: resolved.flow_id,
211 status: resolved.status,
212 steps,
213 updated_at: resolved.updated_at,
214 };
215 let human = trace_human(&report);
216 Rendered::ok(human, to_json(&report))
217}
218
219pub(crate) struct ResolvedFlow {
222 pub(crate) flow_id: String,
223 pub(crate) entrypoint: String,
224 pub(crate) status: String,
225 pub(crate) created_at: i64,
226 pub(crate) updated_at: i64,
227}
228
229pub(crate) fn resolve_flow(conn: &Connection, flow: &str) -> Result<ResolvedFlow, String> {
233 let like = format!("%{flow}%");
234 let mut stmt = conn
235 .prepare(
236 "SELECT flow_id, entrypoint, status, created_at, updated_at FROM flows \
237 WHERE flow_id = ?1 OR flow_id LIKE ?2 OR entrypoint LIKE ?2 \
238 ORDER BY (flow_id = ?1) DESC, updated_at DESC, flow_id",
239 )
240 .map_err(|e| q(&e))?;
241 let matches = stmt
242 .query_map([flow, &like], |row| {
243 Ok(ResolvedFlow {
244 flow_id: row.get(0)?,
245 entrypoint: row.get(1)?,
246 status: row.get(2)?,
247 created_at: row.get(3)?,
248 updated_at: row.get(4)?,
249 })
250 })
251 .map_err(|e| q(&e))?
252 .collect::<rusqlite::Result<Vec<_>>>()
253 .map_err(|e| q(&e))?;
254
255 if let Some(first) = matches.first()
257 && first.flow_id == flow
258 {
259 return Ok(matches.into_iter().next().expect("first exists"));
260 }
261 match matches.len() {
262 0 => Err(format!(
263 "no flow matches {flow:?} in the journal. Run `keel flows` to list recorded flows."
264 )),
265 1 => Ok(matches.into_iter().next().expect("one match")),
266 n => {
267 let ids: Vec<&str> = matches.iter().take(5).map(|m| m.flow_id.as_str()).collect();
268 Err(format!(
269 "{flow:?} matches {n} flows ({}); use a full flow_id.",
270 ids.join(", ")
271 ))
272 }
273 }
274}
275
276fn read_steps(conn: &Connection, flow_id: &str) -> Result<Vec<TraceStep>, String> {
278 let mut stmt = conn
279 .prepare(
280 "SELECT seq, step_key, kind, attempt, outcome, started_at, ended_at FROM steps \
281 WHERE flow_id = ?1 AND kind != 'marker' ORDER BY seq",
282 )
283 .map_err(|e| q(&e))?;
284 let steps = stmt
285 .query_map([flow_id], |row| {
286 let seq: i64 = row.get(0)?;
287 let step_key: String = row.get(1)?;
288 let kind: String = row.get(2)?;
289 let attempt: i64 = row.get(3)?;
290 let outcome: String = row.get(4)?;
291 let started_at: i64 = row.get(5)?;
292 let ended_at: Option<i64> = row.get(6)?;
293 Ok(TraceStep {
294 attempt,
295 duration_ms: ended_at.map(|e| e - started_at),
296 ended_at,
297 kind,
298 outcome,
299 seq,
300 started_at,
301 step_key,
302 })
303 })
304 .map_err(|e| q(&e))?
305 .collect::<rusqlite::Result<Vec<_>>>()
306 .map_err(|e| q(&e))?;
307 Ok(steps)
308}
309
310fn trace_human(report: &TraceReport) -> String {
312 let mut lines = vec![
313 format!(
314 "keel \u{25b8} trace {}\n entrypoint: {}\n status: {}\n",
315 report.flow_id, report.entrypoint, report.status
316 ),
317 format!(" steps: {}\n", report.steps.len()),
318 ];
319 for s in &report.steps {
320 let dur = match s.duration_ms {
321 Some(ms) => format!("{ms}ms"),
322 None => "\u{2014}".to_owned(), };
324 let attempts = if s.attempt > 1 {
325 format!(" ({} attempts)", s.attempt)
326 } else {
327 String::new()
328 };
329 lines.push(format!(
330 " {:>3}. {:<7} {:<28} {:<8} {}{}\n",
331 s.seq, s.kind, s.step_key, s.outcome, dur, attempts,
332 ));
333 }
334 lines.concat()
335}
336
337fn fmt_age(now_ms: i64, then_ms: i64) -> String {
339 let secs = (now_ms - then_ms).max(0) / 1000;
340 if secs < 60 {
341 format!("{secs}s ago")
342 } else if secs < 3600 {
343 format!("{}m ago", secs / 60)
344 } else if secs < 86_400 {
345 format!("{}h ago", secs / 3600)
346 } else {
347 format!("{}d ago", secs / 86_400)
348 }
349}
350
351pub(crate) fn open_ro(path: &Path) -> Result<Connection, String> {
352 Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
353 .map_err(|e| format!("could not open {}: {e}", path.display()))
354}
355
356pub(crate) fn q(e: &rusqlite::Error) -> String {
357 format!("journal query failed: {e}")
358}
359
360pub(crate) fn soft_error(message: &str) -> Rendered {
364 #[derive(Serialize)]
365 struct ErrReport<'a> {
366 error: &'a str,
367 }
368 Rendered {
369 human: format!("keel \u{25b8} {message}"),
370 json: to_json(&ErrReport { error: message }),
371 exit: EXIT_FAILURE,
372 to_stderr: true,
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use std::path::PathBuf;
380
381 const T0: i64 = 1_783_728_000_000;
382
383 fn project_with_fixture(fixture: &str) -> (tempfile::TempDir, PathBuf) {
387 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
388 let schema = std::fs::read_to_string(root.join("contracts/journal.sql")).unwrap();
389 let sql = std::fs::read_to_string(root.join("conformance/fixtures/journal").join(fixture))
390 .unwrap();
391 let dir = tempfile::TempDir::new().unwrap();
392 let keel = dir.path().join(".keel");
393 std::fs::create_dir_all(&keel).unwrap();
394 let db = keel.join("journal.db");
395 let conn = Connection::open(&db).unwrap();
396 conn.execute_batch(&schema).unwrap();
397 conn.execute_batch(&sql).unwrap();
398 let project = dir.path().to_path_buf();
399 (dir, project)
400 }
401
402 #[test]
403 fn flows_lists_completed_with_step_counts() {
404 let (_d, project) = project_with_fixture("completed-flow.sql");
405 let r = flows(&project, false, T0 + 10_000);
406 assert_eq!(r.exit, crate::EXIT_OK);
407 assert_eq!(r.json["count"], 1);
408 let f = &r.json["flows"][0];
409 assert_eq!(f["entrypoint"], "py:pipeline.ingest:main");
410 assert_eq!(f["status"], "completed");
411 assert_eq!(f["steps_done"], 5);
412 assert_eq!(f["steps_total"], 5);
413 assert!(r.human.contains("steps 5/5"));
415 assert!(r.human.contains("ago"));
416 }
417
418 #[test]
419 fn flows_interrupted_shows_incomplete_step_count() {
420 let (_d, project) = project_with_fixture("interrupted-flow.sql");
421 let r = flows(&project, false, T0 + 60_000);
422 let f = &r.json["flows"][0];
423 assert_eq!(f["status"], "running");
424 assert_eq!(f["steps_total"], 4);
426 assert_eq!(f["steps_done"], 3);
427 }
428
429 #[test]
430 fn flows_dead_filter_selects_only_dead() {
431 let (_d, project) = project_with_fixture("dead-flow.sql");
432 let all = flows(&project, false, T0);
433 assert_eq!(all.json["count"], 1);
434 let dead = flows(&project, true, T0);
435 assert_eq!(dead.json["count"], 1);
436 assert_eq!(dead.json["dead_only"], true);
437 assert_eq!(dead.json["flows"][0]["status"], "dead");
438 let (_d2, p2) = project_with_fixture("completed-flow.sql");
440 assert_eq!(flows(&p2, true, T0).json["count"], 0);
441 }
442
443 #[test]
444 fn flows_absent_journal_nudges() {
445 let dir = tempfile::TempDir::new().unwrap();
446 let r = flows(dir.path(), false, T0);
447 assert_eq!(r.exit, crate::EXIT_OK);
448 assert_eq!(r.json["journal_present"], false);
449 assert_eq!(r.json["count"], 0);
450 }
451
452 #[test]
453 fn trace_walks_steps_with_outcomes_and_durations() {
454 let (_d, project) = project_with_fixture("completed-flow.sql");
455 let r = trace(&project, "py:pipeline.ingest:main");
456 assert_eq!(r.exit, crate::EXIT_OK);
457 assert_eq!(r.json["status"], "completed");
458 let steps = r.json["steps"].as_array().unwrap();
459 assert_eq!(steps.len(), 5);
460 assert_eq!(steps[0]["seq"], 1);
462 assert_eq!(steps[0]["step_key"], "api.source.internal#q1");
463 assert_eq!(steps[0]["outcome"], "ok");
464 assert_eq!(steps[0]["duration_ms"], 240);
465 assert_eq!(steps[1]["kind"], "time");
467 assert_eq!(steps[1]["step_key"], "py:time.time#-");
468 assert_eq!(steps[2]["attempt"], 2);
470 assert!(r.human.contains("2 attempts"));
471 }
472
473 #[test]
474 fn trace_running_step_has_no_duration() {
475 let (_d, project) = project_with_fixture("interrupted-flow.sql");
476 let r = trace(&project, "01JZWY0A0000000000000002");
477 let steps = r.json["steps"].as_array().unwrap();
478 assert_eq!(steps.len(), 4);
479 assert_eq!(steps[3]["outcome"], "running");
480 assert!(steps[3]["duration_ms"].is_null());
481 assert!(steps[3]["ended_at"].is_null());
482 }
483
484 #[test]
485 fn trace_unknown_flow_is_a_soft_error() {
486 let (_d, project) = project_with_fixture("completed-flow.sql");
487 let r = trace(&project, "does-not-exist");
488 assert_eq!(r.exit, EXIT_FAILURE);
489 assert!(r.to_stderr);
490 assert!(r.human.contains("no flow matches"));
491 }
492}