1use crate::cli::DatabricksCli;
2use crate::fetchers::jobs::run_status;
3use crate::shape::{fmt_duration_ms, relative_time, DetailData, Status};
4
5pub 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
26pub struct TimelineTask {
28 pub key: String,
29 pub start: u64,
31 pub end: Option<u64>,
33 pub status: Status,
34}
35
36pub 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
63pub 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 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 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 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
141pub 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 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 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}