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