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 GridRun {
28 pub run_id: String,
29 pub status: Status,
30 pub age: String,
31}
32
33pub struct GridTask {
37 pub key: String,
38 pub cells: Vec<Option<Status>>,
39 pub durations: Vec<Option<u64>>,
40}
41
42impl GridTask {
43 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 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
70pub struct GridData {
72 pub runs: Vec<GridRun>,
74 pub tasks: Vec<GridTask>,
75}
76
77pub 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 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 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
154pub struct TimelineTask {
156 pub key: String,
157 pub start: u64,
159 pub end: Option<u64>,
161 pub status: Status,
162}
163
164pub 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
191pub struct DagRow {
193 pub prefix: String,
195 pub key: String,
196 pub status: Status,
197 pub duration: Option<u64>,
199 pub also_after: Vec<String>,
201}
202
203pub 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 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 for i in 0..info.len() {
295 if !w.seen[i] {
296 w.walk(i, "", true, 0);
297 }
298 }
299 w.rows
300}
301
302pub 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 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 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 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
383pub 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 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 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 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 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 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 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 let slow = task(vec![Some(100), Some(100), Some(100), Some(160)]);
566 assert_eq!(slow.slowdown(), Some((160, 100)));
567 assert_eq!(
569 task(vec![Some(100), Some(100), Some(100), Some(140)]).slowdown(),
570 None
571 );
572 assert_eq!(task(vec![Some(100), Some(100), Some(160)]).slowdown(), None);
574 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}