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 task's execution window, for the timeline view.
27pub struct TimelineTask {
28    pub key: String,
29    /// Epoch ms; 0 when the task hasn't started.
30    pub start: u64,
31    /// None while the task is still executing.
32    pub end: Option<u64>,
33    pub status: Status,
34}
35
36/// Per-task execution windows parsed from a stored get-run response,
37/// sorted by start time with not-yet-started tasks last.
38pub fn timeline(raw: &str) -> Vec<TimelineTask> {
39    let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
40        return Vec::new();
41    };
42    let mut tasks: Vec<TimelineTask> = json["tasks"]
43        .as_array()
44        .map(|ts| {
45            ts.iter()
46                .map(|t| {
47                    let start = t["start_time"].as_u64().unwrap_or(0);
48                    let end = t["end_time"].as_u64().filter(|e| *e > 0 && start > 0);
49                    TimelineTask {
50                        key: t["task_key"].as_str().unwrap_or("?").to_string(),
51                        start,
52                        end,
53                        status: run_status(t),
54                    }
55                })
56                .collect()
57        })
58        .unwrap_or_default();
59    tasks.sort_by_key(|t| if t.start == 0 { u64::MAX } else { t.start });
60    tasks
61}
62
63/// Complete output of a run, task by task: the full error, stack trace
64/// and log tail from `jobs get-run-output`. One CLI call per task, so
65/// this is fetched on demand when the user opens the output view.
66pub async fn full_output(cli: &DatabricksCli, run_id: &str) -> String {
67    let json = match cli.run(&["jobs", "get-run", run_id]).await {
68        Ok(v) => v,
69        Err(e) => return format!("✗ {e:#}"),
70    };
71    // Multi-task runs carry per-task run ids; a legacy single-task run
72    // is its own task.
73    let tasks: Vec<(String, String, Status)> = match json["tasks"].as_array() {
74        Some(ts) if !ts.is_empty() => ts
75            .iter()
76            .filter_map(|t| {
77                Some((
78                    t["task_key"].as_str().unwrap_or("task").to_string(),
79                    t["run_id"].as_u64()?.to_string(),
80                    run_status(t),
81                ))
82            })
83            .collect(),
84        _ => vec![("run".to_string(), run_id.to_string(), run_status(&json))],
85    };
86
87    let mut out = String::new();
88    for (key, id, status) in &tasks {
89        if !out.is_empty() {
90            out.push('\n');
91        }
92        out.push_str(&format!("── {key} · {} ──\n", status.label()));
93        match cli.run(&["jobs", "get-run-output", id]).await {
94            Ok(o) => {
95                let mut wrote = false;
96                if let Some(err) = o["error"].as_str().filter(|s| !s.is_empty()) {
97                    out.push_str(err.trim_end());
98                    out.push('\n');
99                    wrote = true;
100                }
101                if let Some(trace) = o["error_trace"].as_str().filter(|s| !s.is_empty()) {
102                    out.push('\n');
103                    out.push_str(trace.trim_end());
104                    out.push('\n');
105                    wrote = true;
106                }
107                if let Some(result) = o["notebook_output"]["result"]
108                    .as_str()
109                    .filter(|s| !s.is_empty())
110                {
111                    out.push_str("notebook result: ");
112                    out.push_str(result.trim_end());
113                    out.push('\n');
114                    wrote = true;
115                }
116                if let Some(logs) = o["logs"].as_str().filter(|s| !s.is_empty()) {
117                    // Keep the tail — that's where the failure is.
118                    let tail: Vec<&str> = logs.lines().rev().take(200).collect();
119                    out.push_str("logs (tail):\n");
120                    for line in tail.iter().rev() {
121                        out.push_str(line);
122                        out.push('\n');
123                    }
124                    wrote = true;
125                }
126                if !wrote {
127                    out.push_str("(no output recorded for this task)\n");
128                }
129            }
130            // Running tasks have no output yet; say why instead of nothing.
131            Err(e) => {
132                let msg = format!("{e:#}");
133                let first = msg.lines().next().unwrap_or("no output available");
134                out.push_str(&format!("({first})\n"));
135            }
136        }
137    }
138    out
139}
140
141/// Full detail of one run: state, timing, per-task results, and the
142/// actual error output for failed tasks. The bool is true while the
143/// run is still executing.
144pub async fn fetch(cli: &DatabricksCli, run_id: &str) -> (DetailData, bool) {
145    let args = ["jobs", "get-run", run_id];
146    let json = match cli.run(&args).await {
147        Ok(v) => v,
148        Err(e) => {
149            return (
150                DetailData {
151                    summary: Vec::new(),
152                    activity: Vec::new(),
153                    raw: format!("✗ {e:#}"),
154                },
155                false,
156            )
157        }
158    };
159    let raw = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
160
161    let life = json["state"]["life_cycle_state"].as_str().unwrap_or("");
162    let result = json["state"]["result_state"].as_str().unwrap_or("");
163    let state_label = if result.is_empty() { life } else { result };
164    let status: Status = state_label.parse().unwrap();
165    let live = matches!(status, Status::Running | Status::Pending);
166
167    let mut summary = vec![("State".to_string(), state_label.to_string())];
168    if let Some(t) = json["start_time"].as_u64() {
169        summary.push(("Started".to_string(), relative_time(t)));
170    }
171    if let Some(d) = json["run_duration"]
172        .as_u64()
173        .or_else(|| json["execution_duration"].as_u64())
174        .filter(|d| *d > 0)
175    {
176        summary.push(("Duration".to_string(), fmt_duration_ms(d)));
177    }
178    if let Some(trigger) = json["trigger"].as_str() {
179        summary.push(("Trigger".to_string(), trigger.to_string()));
180    }
181    if let Some(msg) = json["state"]["state_message"]
182        .as_str()
183        .filter(|m| !m.is_empty())
184    {
185        summary.push(("Message".to_string(), msg.to_string()));
186    }
187
188    // One line per task; failed tasks get their error output inline so
189    // the reason is readable without leaving the terminal.
190    let mut activity: Vec<(Status, String)> = Vec::new();
191    let tasks = json["tasks"].as_array().cloned().unwrap_or_default();
192    for t in &tasks {
193        let key = t["task_key"].as_str().unwrap_or("?");
194        let t_status = run_status(t);
195        let dur = t["execution_duration"]
196            .as_u64()
197            .or_else(|| t["run_duration"].as_u64())
198            .filter(|d| *d > 0)
199            .map(|d| format!("  ·  {}", fmt_duration_ms(d)))
200            .unwrap_or_default();
201        let line = format!("{key}  ·  {}{dur}", t_status.label());
202        let failed = matches!(t_status, Status::Failed);
203        activity.push((t_status, line));
204        if failed {
205            if let Some(task_run_id) = t["run_id"].as_u64() {
206                let id = task_run_id.to_string();
207                let out_args = ["jobs", "get-run-output", &id];
208                if let Ok(out) = cli.run(&out_args).await {
209                    if let Some(err) = out["error"].as_str() {
210                        let mut msg = err.replace('\n', " ");
211                        if msg.chars().count() > 200 {
212                            msg = msg.chars().take(200).collect::<String>() + "…";
213                        }
214                        activity.push((Status::Failed, format!("  ↳ {msg}")));
215                    }
216                }
217            }
218        }
219    }
220    if activity.is_empty() {
221        activity.push((status, "single-task run — see raw for details".to_string()));
222    }
223
224    (
225        DetailData {
226            summary,
227            activity,
228            raw,
229        },
230        live,
231    )
232}
233
234#[cfg(test)]
235mod tests {
236    use super::timeline;
237    use crate::shape::Status;
238
239    #[test]
240    fn timeline_sorts_by_start_with_unstarted_last() {
241        let raw = r#"{"tasks":[
242            {"task_key":"b","start_time":2000,"end_time":5000,"state":{"result_state":"SUCCESS"}},
243            {"task_key":"c","start_time":0,"state":{"life_cycle_state":"BLOCKED"}},
244            {"task_key":"a","start_time":1000,"end_time":0,"state":{"life_cycle_state":"RUNNING"}}
245        ]}"#;
246        let ts = timeline(raw);
247        let keys: Vec<&str> = ts.iter().map(|t| t.key.as_str()).collect();
248        assert_eq!(keys, ["a", "b", "c"]);
249        // end_time 0 means still executing.
250        assert_eq!(ts[0].end, None);
251        assert!(matches!(ts[0].status, Status::Running));
252        assert_eq!(ts[1].end, Some(5000));
253        assert_eq!(ts[2].start, 0);
254    }
255
256    #[test]
257    fn timeline_tolerates_non_json_and_taskless_runs() {
258        assert!(timeline("✗ boom").is_empty());
259        assert!(timeline("{}").is_empty());
260    }
261}