Skip to main content

databricks_tui/fetchers/
runs.rs

1use crate::cli::DatabricksCli;
2use crate::fetchers::jobs::run_status;
3use crate::shape::{fmt_duration_ms, relative_time, DetailData, Status};
4
5/// Recent runs of one job, newest first: (run_id, status, age).
6pub async fn list(
7    cli: &DatabricksCli,
8    job_id: &str,
9) -> Result<Vec<(String, Status, String)>, String> {
10    let args = ["jobs", "list-runs", "--job-id", job_id, "--limit", "20"];
11    let json = cli.run(&args).await.map_err(|e| format!("{e:#}"))?;
12    Ok(json
13        .as_array()
14        .map(|runs| {
15            runs.iter()
16                .filter_map(|r| {
17                    let id = r["run_id"].as_u64()?;
18                    let age = r["start_time"].as_u64().map(relative_time)?;
19                    Some((id.to_string(), run_status(r), age))
20                })
21                .collect()
22        })
23        .unwrap_or_default())
24}
25
26/// Current state of one run, for the watch list: (status, still-live).
27pub async fn state(cli: &DatabricksCli, run_id: &str) -> Result<(Status, bool), String> {
28    let json = cli
29        .run(&["jobs", "get-run", run_id])
30        .await
31        .map_err(|e| format!("{e:#}"))?;
32    let status = run_status(&json);
33    let live = matches!(status, Status::Running | Status::Pending);
34    Ok((status, live))
35}
36
37/// One run column of the history grid.
38pub struct GridRun {
39    pub run_id: String,
40    pub status: Status,
41    pub age: String,
42}
43
44/// One task row of the history grid, index-aligned with `GridData::runs`:
45/// the task's state and execution duration in each run, None where the
46/// task didn't exist yet (or timing wasn't recorded).
47pub struct GridTask {
48    pub key: String,
49    pub cells: Vec<Option<Status>>,
50    pub durations: Vec<Option<u64>>,
51}
52
53impl GridTask {
54    /// Durations of successful cells, oldest first. Failed runs stop
55    /// early, so their durations would fake a speed-up.
56    pub fn success_durations(&self) -> Vec<u64> {
57        self.cells
58            .iter()
59            .zip(&self.durations)
60            .filter(|(c, _)| matches!(c, Some(Status::Success)))
61            .filter_map(|(_, d)| *d)
62            .collect()
63    }
64
65    /// Some((latest, median of the earlier ones)) when the newest
66    /// successful duration is at least 1.5× the median of three or more
67    /// earlier ones — a creeping slowdown worth flagging.
68    pub fn slowdown(&self) -> Option<(u64, u64)> {
69        let ds = self.success_durations();
70        let (latest, prior) = ds.split_last()?;
71        if prior.len() < 3 {
72            return None;
73        }
74        let mut sorted = prior.to_vec();
75        sorted.sort_unstable();
76        let median = sorted[sorted.len() / 2];
77        (median > 0 && *latest * 2 >= median * 3).then_some((*latest, median))
78    }
79}
80
81/// The run-history grid of a job: tasks × recent runs.
82pub struct GridData {
83    /// Columns, oldest → newest.
84    pub runs: Vec<GridRun>,
85    pub tasks: Vec<GridTask>,
86}
87
88/// Task states and durations across a job's recent runs, in one call
89/// via `--expand-tasks`.
90pub async fn grid(cli: &DatabricksCli, job_id: &str) -> Result<GridData, String> {
91    let args = [
92        "jobs",
93        "list-runs",
94        "--job-id",
95        job_id,
96        "--expand-tasks",
97        "--limit",
98        "20",
99    ];
100    let json = cli.run(&args).await.map_err(|e| format!("{e:#}"))?;
101    Ok(grid_from(&json))
102}
103
104fn grid_from(json: &serde_json::Value) -> GridData {
105    let mut runs_json = json
106        .as_array()
107        .cloned()
108        .or_else(|| json["runs"].as_array().cloned())
109        .unwrap_or_default();
110    // The API returns newest first; grid columns read oldest → newest.
111    runs_json.reverse();
112    let runs: Vec<GridRun> = runs_json
113        .iter()
114        .map(|r| GridRun {
115            run_id: r["run_id"]
116                .as_u64()
117                .map(|i| i.to_string())
118                .unwrap_or_default(),
119            status: run_status(r),
120            age: r["start_time"]
121                .as_u64()
122                .map(relative_time)
123                .unwrap_or_default(),
124        })
125        .collect();
126    // Row order follows the newest run, so the job's current shape is on
127    // top and retired tasks sink to the bottom.
128    let mut keys: Vec<String> = Vec::new();
129    for r in runs_json.iter().rev() {
130        for t in r["tasks"].as_array().into_iter().flatten() {
131            if let Some(k) = t["task_key"].as_str() {
132                if !keys.iter().any(|e| e == k) {
133                    keys.push(k.to_string());
134                }
135            }
136        }
137    }
138    let tasks = keys
139        .into_iter()
140        .map(|key| {
141            let mut cells = Vec::with_capacity(runs_json.len());
142            let mut durations = Vec::with_capacity(runs_json.len());
143            for r in &runs_json {
144                let t = r["tasks"]
145                    .as_array()
146                    .and_then(|ts| ts.iter().find(|t| t["task_key"].as_str() == Some(&key)));
147                cells.push(t.map(run_status));
148                durations.push(t.and_then(|t| {
149                    t["execution_duration"]
150                        .as_u64()
151                        .or_else(|| t["run_duration"].as_u64())
152                        .filter(|d| *d > 0)
153                }));
154            }
155            GridTask {
156                key,
157                cells,
158                durations,
159            }
160        })
161        .collect();
162    GridData { runs, tasks }
163}
164
165/// One task's execution window, for the timeline view.
166pub struct TimelineTask {
167    pub key: String,
168    /// Epoch ms; 0 when the task hasn't started.
169    pub start: u64,
170    /// None while the task is still executing.
171    pub end: Option<u64>,
172    pub status: Status,
173}
174
175/// Per-task execution windows parsed from a stored get-run response,
176/// sorted by start time with not-yet-started tasks last.
177pub fn timeline(raw: &str) -> Vec<TimelineTask> {
178    let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
179        return Vec::new();
180    };
181    let mut tasks: Vec<TimelineTask> = json["tasks"]
182        .as_array()
183        .map(|ts| {
184            ts.iter()
185                .map(|t| {
186                    let start = t["start_time"].as_u64().unwrap_or(0);
187                    let end = t["end_time"].as_u64().filter(|e| *e > 0 && start > 0);
188                    TimelineTask {
189                        key: t["task_key"].as_str().unwrap_or("?").to_string(),
190                        start,
191                        end,
192                        status: run_status(t),
193                    }
194                })
195                .collect()
196        })
197        .unwrap_or_default();
198    tasks.sort_by_key(|t| if t.start == 0 { u64::MAX } else { t.start });
199    tasks
200}
201
202/// One row of the task dependency tree, ready to render.
203pub struct DagRow {
204    /// Branch guides + connector, e.g. "│  ├─ ". Empty for roots.
205    pub prefix: String,
206    pub key: String,
207    pub status: Status,
208    /// Execution duration in ms, when recorded.
209    pub duration: Option<u64>,
210    /// Dependencies beyond the parent this row is placed under.
211    pub also_after: Vec<String>,
212}
213
214/// Task dependency tree of a run, parsed from a stored get-run response.
215/// Each task appears once, placed under its first dependency; additional
216/// dependencies are listed in `also_after`. Tasks whose placement parent
217/// is missing (or cyclic) are appended at root level.
218pub fn dag(raw: &str) -> Vec<DagRow> {
219    let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
220        return Vec::new();
221    };
222    let tasks = json["tasks"].as_array().cloned().unwrap_or_default();
223    let info: Vec<(String, Vec<String>, Status, Option<u64>)> = tasks
224        .iter()
225        .map(|t| {
226            let deps: Vec<String> = t["depends_on"]
227                .as_array()
228                .map(|ds| {
229                    ds.iter()
230                        .filter_map(|d| d["task_key"].as_str().map(str::to_string))
231                        .collect()
232                })
233                .unwrap_or_default();
234            let dur = t["execution_duration"]
235                .as_u64()
236                .or_else(|| t["run_duration"].as_u64())
237                .filter(|d| *d > 0);
238            (
239                t["task_key"].as_str().unwrap_or("?").to_string(),
240                deps,
241                run_status(t),
242                dur,
243            )
244        })
245        .collect();
246    let keys: Vec<&str> = info.iter().map(|(k, _, _, _)| k.as_str()).collect();
247
248    // children[i] = indices placed under task i (first dependency wins).
249    let mut children: Vec<Vec<usize>> = vec![Vec::new(); info.len()];
250    let mut roots: Vec<usize> = Vec::new();
251    for (i, (_, deps, _, _)) in info.iter().enumerate() {
252        match deps.first().and_then(|d| keys.iter().position(|k| k == d)) {
253            Some(parent) => children[parent].push(i),
254            None => roots.push(i),
255        }
256    }
257
258    struct Walker<'a> {
259        info: &'a [(String, Vec<String>, Status, Option<u64>)],
260        children: &'a [Vec<usize>],
261        seen: Vec<bool>,
262        rows: Vec<DagRow>,
263    }
264    impl Walker<'_> {
265        fn walk(&mut self, i: usize, guides: &str, last: bool, depth: usize) {
266            if self.seen[i] {
267                return;
268            }
269            self.seen[i] = true;
270            let (key, deps, status, dur) = &self.info[i];
271            let prefix = if depth == 0 {
272                String::new()
273            } else {
274                format!("{guides}{}", if last { "└─ " } else { "├─ " })
275            };
276            self.rows.push(DagRow {
277                prefix,
278                key: key.clone(),
279                status: status.clone(),
280                duration: *dur,
281                also_after: deps.iter().skip(1).cloned().collect(),
282            });
283            let next_guides = if depth == 0 {
284                String::new()
285            } else {
286                format!("{guides}{}", if last { "   " } else { "│  " })
287            };
288            let kids = self.children[i].clone();
289            for (n, &c) in kids.iter().enumerate() {
290                self.walk(c, &next_guides, n + 1 == kids.len(), depth + 1);
291            }
292        }
293    }
294    let mut w = Walker {
295        info: &info,
296        children: &children,
297        seen: vec![false; info.len()],
298        rows: Vec::with_capacity(info.len()),
299    };
300    for &r in &roots {
301        w.walk(r, "", true, 0);
302    }
303    // Anything unreached (parent missing from the task list, or a cycle)
304    // still deserves a row.
305    for i in 0..info.len() {
306        if !w.seen[i] {
307            w.walk(i, "", true, 0);
308        }
309    }
310    w.rows
311}
312
313/// Complete output of a run, task by task: the full error, stack trace
314/// and log tail from `jobs get-run-output`. One CLI call per task, so
315/// this is fetched on demand when the user opens the output view.
316/// The bool is true while the run is still executing, so the output
317/// view can keep tailing.
318pub async fn full_output(cli: &DatabricksCli, run_id: &str) -> (String, bool) {
319    let json = match cli.run(&["jobs", "get-run", run_id]).await {
320        Ok(v) => v,
321        Err(e) => return (format!("✗ {e:#}"), false),
322    };
323    let live = matches!(run_status(&json), Status::Running | Status::Pending);
324    // Multi-task runs carry per-task run ids; a legacy single-task run
325    // is its own task.
326    let tasks: Vec<(String, String, Status)> = match json["tasks"].as_array() {
327        Some(ts) if !ts.is_empty() => ts
328            .iter()
329            .filter_map(|t| {
330                Some((
331                    t["task_key"].as_str().unwrap_or("task").to_string(),
332                    t["run_id"].as_u64()?.to_string(),
333                    run_status(t),
334                ))
335            })
336            .collect(),
337        _ => vec![("run".to_string(), run_id.to_string(), run_status(&json))],
338    };
339
340    let mut out = String::new();
341    for (key, id, status) in &tasks {
342        if !out.is_empty() {
343            out.push('\n');
344        }
345        out.push_str(&format!("── {key} · {} ──\n", status.label()));
346        match cli.run(&["jobs", "get-run-output", id]).await {
347            Ok(o) => {
348                let mut wrote = false;
349                if let Some(err) = o["error"].as_str().filter(|s| !s.is_empty()) {
350                    out.push_str(err.trim_end());
351                    out.push('\n');
352                    wrote = true;
353                }
354                if let Some(trace) = o["error_trace"].as_str().filter(|s| !s.is_empty()) {
355                    out.push('\n');
356                    out.push_str(trace.trim_end());
357                    out.push('\n');
358                    wrote = true;
359                }
360                if let Some(result) = o["notebook_output"]["result"]
361                    .as_str()
362                    .filter(|s| !s.is_empty())
363                {
364                    out.push_str("notebook result: ");
365                    out.push_str(result.trim_end());
366                    out.push('\n');
367                    wrote = true;
368                }
369                if let Some(logs) = o["logs"].as_str().filter(|s| !s.is_empty()) {
370                    // Keep the tail — that's where the failure is.
371                    let tail: Vec<&str> = logs.lines().rev().take(200).collect();
372                    out.push_str("logs (tail):\n");
373                    for line in tail.iter().rev() {
374                        out.push_str(line);
375                        out.push('\n');
376                    }
377                    wrote = true;
378                }
379                if !wrote {
380                    out.push_str("(no output recorded for this task)\n");
381                }
382            }
383            // Running tasks have no output yet; say why instead of nothing.
384            Err(e) => {
385                let msg = format!("{e:#}");
386                let first = msg.lines().next().unwrap_or("no output available");
387                out.push_str(&format!("({first})\n"));
388            }
389        }
390    }
391    (out, live)
392}
393
394/// Full detail of one run: state, timing, per-task results, and the
395/// actual error output for failed tasks. The bool is true while the
396/// run is still executing.
397pub async fn fetch(cli: &DatabricksCli, run_id: &str) -> (DetailData, bool) {
398    let args = ["jobs", "get-run", run_id];
399    let json = match cli.run(&args).await {
400        Ok(v) => v,
401        Err(e) => {
402            return (
403                DetailData {
404                    summary: Vec::new(),
405                    activity: Vec::new(),
406                    raw: format!("✗ {e:#}"),
407                },
408                false,
409            )
410        }
411    };
412    let raw = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
413
414    let life = json["state"]["life_cycle_state"].as_str().unwrap_or("");
415    let result = json["state"]["result_state"].as_str().unwrap_or("");
416    let state_label = if result.is_empty() { life } else { result };
417    let status: Status = state_label.parse().unwrap();
418    let live = matches!(status, Status::Running | Status::Pending);
419
420    let mut summary = vec![("State".to_string(), state_label.to_string())];
421    if let Some(t) = json["start_time"].as_u64() {
422        summary.push(("Started".to_string(), relative_time(t)));
423    }
424    if let Some(d) = json["run_duration"]
425        .as_u64()
426        .or_else(|| json["execution_duration"].as_u64())
427        .filter(|d| *d > 0)
428    {
429        summary.push(("Duration".to_string(), fmt_duration_ms(d)));
430    }
431    if let Some(trigger) = json["trigger"].as_str() {
432        summary.push(("Trigger".to_string(), trigger.to_string()));
433    }
434    if let Some(msg) = json["state"]["state_message"]
435        .as_str()
436        .filter(|m| !m.is_empty())
437    {
438        summary.push(("Message".to_string(), msg.to_string()));
439    }
440
441    // One line per task; failed tasks get their error output inline so
442    // the reason is readable without leaving the terminal.
443    let mut activity: Vec<(Status, String)> = Vec::new();
444    let tasks = json["tasks"].as_array().cloned().unwrap_or_default();
445    for t in &tasks {
446        let key = t["task_key"].as_str().unwrap_or("?");
447        let t_status = run_status(t);
448        let dur = t["execution_duration"]
449            .as_u64()
450            .or_else(|| t["run_duration"].as_u64())
451            .filter(|d| *d > 0)
452            .map(|d| format!("  ·  {}", fmt_duration_ms(d)))
453            .unwrap_or_default();
454        let line = format!("{key}  ·  {}{dur}", t_status.label());
455        let failed = matches!(t_status, Status::Failed);
456        activity.push((t_status, line));
457        if failed {
458            if let Some(task_run_id) = t["run_id"].as_u64() {
459                let id = task_run_id.to_string();
460                let out_args = ["jobs", "get-run-output", &id];
461                if let Ok(out) = cli.run(&out_args).await {
462                    if let Some(err) = out["error"].as_str() {
463                        let mut msg = err.replace('\n', " ");
464                        if msg.chars().count() > 200 {
465                            msg = msg.chars().take(200).collect::<String>() + "…";
466                        }
467                        activity.push((Status::Failed, format!("  ↳ {msg}")));
468                    }
469                }
470            }
471        }
472    }
473    if activity.is_empty() {
474        activity.push((status, "single-task run — see raw for details".to_string()));
475    }
476
477    (
478        DetailData {
479            summary,
480            activity,
481            raw,
482        },
483        live,
484    )
485}
486
487#[cfg(test)]
488mod tests {
489    use super::timeline;
490    use crate::shape::Status;
491
492    #[test]
493    fn timeline_sorts_by_start_with_unstarted_last() {
494        let raw = r#"{"tasks":[
495            {"task_key":"b","start_time":2000,"end_time":5000,"state":{"result_state":"SUCCESS"}},
496            {"task_key":"c","start_time":0,"state":{"life_cycle_state":"BLOCKED"}},
497            {"task_key":"a","start_time":1000,"end_time":0,"state":{"life_cycle_state":"RUNNING"}}
498        ]}"#;
499        let ts = timeline(raw);
500        let keys: Vec<&str> = ts.iter().map(|t| t.key.as_str()).collect();
501        assert_eq!(keys, ["a", "b", "c"]);
502        // end_time 0 means still executing.
503        assert_eq!(ts[0].end, None);
504        assert!(matches!(ts[0].status, Status::Running));
505        assert_eq!(ts[1].end, Some(5000));
506        assert_eq!(ts[2].start, 0);
507    }
508
509    #[test]
510    fn timeline_tolerates_non_json_and_taskless_runs() {
511        assert!(timeline("✗ boom").is_empty());
512        assert!(timeline("{}").is_empty());
513    }
514
515    #[test]
516    fn dag_places_tasks_under_first_dependency() {
517        let raw = r#"{"tasks":[
518            {"task_key":"extract","state":{"result_state":"SUCCESS"},"execution_duration":1000},
519            {"task_key":"transform","depends_on":[{"task_key":"extract"}],"state":{"result_state":"SUCCESS"}},
520            {"task_key":"load","depends_on":[{"task_key":"extract"}],"state":{"result_state":"RUNNING"}},
521            {"task_key":"report","depends_on":[{"task_key":"transform"},{"task_key":"load"}],"state":{"life_cycle_state":"BLOCKED"}}
522        ]}"#;
523        let rows = super::dag(raw);
524        let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect();
525        assert_eq!(keys, ["extract", "transform", "report", "load"]);
526        assert_eq!(rows[0].prefix, "");
527        assert_eq!(rows[1].prefix, "├─ ");
528        assert_eq!(rows[2].prefix, "│  └─ ");
529        assert_eq!(rows[3].prefix, "└─ ");
530        // report also depends on load, beyond its placement parent.
531        assert_eq!(rows[2].also_after, ["load"]);
532        assert_eq!(rows[0].duration, Some(1000));
533    }
534
535    #[test]
536    fn grid_aligns_tasks_across_runs_oldest_first() {
537        // Newest first, as the API returns them; "load" is new in run 3.
538        let json = serde_json::json!([
539            {"run_id": 3, "start_time": 3000_u64, "state": {"result_state": "SUCCESS"},
540             "tasks": [
541                {"task_key": "extract", "execution_duration": 100, "state": {"result_state": "SUCCESS"}},
542                {"task_key": "load", "execution_duration": 50, "state": {"result_state": "FAILED"}}
543             ]},
544            {"run_id": 2, "start_time": 2000_u64, "state": {"result_state": "FAILED"},
545             "tasks": [
546                {"task_key": "extract", "execution_duration": 90, "state": {"result_state": "SUCCESS"}}
547             ]},
548            {"run_id": 1, "start_time": 1000_u64, "state": {"result_state": "SUCCESS"},
549             "tasks": [
550                {"task_key": "extract", "execution_duration": 80, "state": {"result_state": "SUCCESS"}}
551             ]}
552        ]);
553        let g = super::grid_from(&json);
554        let ids: Vec<&str> = g.runs.iter().map(|r| r.run_id.as_str()).collect();
555        assert_eq!(ids, ["1", "2", "3"]);
556        assert!(matches!(g.runs[1].status, Status::Failed));
557        // Rows follow the newest run's task order.
558        assert_eq!(g.tasks[0].key, "extract");
559        assert_eq!(g.tasks[1].key, "load");
560        assert_eq!(g.tasks[0].durations, [Some(80), Some(90), Some(100)]);
561        // "load" didn't exist in the two older runs.
562        assert!(g.tasks[1].cells[0].is_none());
563        assert!(g.tasks[1].cells[1].is_none());
564        assert!(matches!(g.tasks[1].cells[2], Some(Status::Failed)));
565    }
566
567    #[test]
568    fn grid_slowdown_flags_only_clear_regressions() {
569        let ok = Some(Status::Success);
570        let task = |durations: Vec<Option<u64>>| super::GridTask {
571            key: "t".to_string(),
572            cells: vec![ok.clone(); durations.len()],
573            durations,
574        };
575        // 100,100,100 then 160: 1.6× the median — flagged.
576        let slow = task(vec![Some(100), Some(100), Some(100), Some(160)]);
577        assert_eq!(slow.slowdown(), Some((160, 100)));
578        // 140 is below the 1.5× threshold.
579        assert_eq!(
580            task(vec![Some(100), Some(100), Some(100), Some(140)]).slowdown(),
581            None
582        );
583        // Too few prior samples to call it a trend.
584        assert_eq!(task(vec![Some(100), Some(100), Some(160)]).slowdown(), None);
585        // Failed runs don't count as samples.
586        let mut flaky = task(vec![Some(100), Some(10), Some(100), Some(100), Some(160)]);
587        flaky.cells[1] = Some(Status::Failed);
588        assert_eq!(flaky.slowdown(), Some((160, 100)));
589    }
590
591    #[test]
592    fn dag_tolerates_missing_parents_and_bad_json() {
593        assert!(super::dag("nope").is_empty());
594        let raw = r#"{"tasks":[
595            {"task_key":"orphan","depends_on":[{"task_key":"ghost"}],"state":{"result_state":"SUCCESS"}}
596        ]}"#;
597        let rows = super::dag(raw);
598        assert_eq!(rows.len(), 1);
599        assert_eq!(rows[0].key, "orphan");
600        assert_eq!(rows[0].prefix, "");
601    }
602}