use crate::cli::DatabricksCli;
use crate::shape::{relative_time, ListItem, Shape, Status};
use anyhow::Result;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Semaphore;
const CONCURRENCY: usize = 8;
const RUNS_PER_JOB: &str = "8";
pub(crate) fn run_status(r: &Value) -> Status {
r["state"]["result_state"]
.as_str()
.or_else(|| r["state"]["life_cycle_state"].as_str())
.or_else(|| r["status"]["state"].as_str())
.unwrap_or("")
.parse()
.unwrap()
}
fn runs_of(json: &Value) -> Vec<Value> {
json.as_array()
.cloned()
.or_else(|| json["runs"].as_array().cloned())
.unwrap_or_default()
}
pub async fn fetch(cli: &DatabricksCli) -> Result<Shape> {
let jobs = cli.run(&["jobs", "list"]).await?;
let Some(jobs) = jobs.as_array() else {
return Ok(Shape::List(Vec::new()));
};
let sem = Arc::new(Semaphore::new(CONCURRENCY));
let mut tasks = Vec::with_capacity(jobs.len());
for j in jobs {
let job_id = j["job_id"].as_u64();
let name = j["settings"]["name"]
.as_str()
.unwrap_or("unknown")
.to_string();
let settings = j["settings"].clone();
let cli = cli.clone();
let sem = Arc::clone(&sem);
tasks.push(tokio::spawn(async move {
let mut runs: Vec<Value> = Vec::new();
if let Some(id) = job_id {
let _permit = sem.acquire().await;
let id = id.to_string();
let args = [
"jobs",
"list-runs",
"--job-id",
&id,
"--limit",
RUNS_PER_JOB,
];
if let Ok(json) = cli.run(&args).await {
runs = runs_of(&json);
}
}
build_item(name, job_id, &runs, &settings)
}));
}
let mut items = Vec::with_capacity(tasks.len());
for t in tasks {
if let Ok(item) = t.await {
items.push(item);
}
}
Ok(Shape::List(items))
}
pub async fn toggle_pause(cli: &DatabricksCli, job_id: &str, name: &str) -> Result<String, String> {
let job = cli
.run(&["jobs", "get", job_id])
.await
.map_err(|e| format!("✗ {e:#}"))?;
let settings = &job["settings"];
let field = ["schedule", "continuous", "trigger"]
.into_iter()
.find(|f| settings[*f].is_object())
.ok_or_else(|| format!("✗ “{name}” only runs on demand — nothing to pause"))?;
let mut block = settings[field].clone();
let paused = block["pause_status"].as_str() == Some("PAUSED");
block["pause_status"] = Value::String(if paused { "UNPAUSED" } else { "PAUSED" }.to_string());
let id: u64 = job_id
.parse()
.map_err(|_| format!("✗ bad job id: {job_id}"))?;
let payload = serde_json::json!({"job_id": id, "new_settings": {field: block}}).to_string();
cli.run_action(&["jobs", "update", "--json", &payload])
.await
.map_err(|e| format!("✗ {e:#}"))?;
Ok(if paused {
format!("▶ resumed “{name}” — {field} active again")
} else {
format!("⏸ paused “{name}” — won't fire until resumed (S)")
})
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum ParamKind {
Job,
Notebook,
}
impl ParamKind {
pub fn payload_key(self) -> &'static str {
match self {
ParamKind::Job => "job_parameters",
ParamKind::Notebook => "notebook_params",
}
}
}
pub async fn params(
cli: &DatabricksCli,
job_id: &str,
) -> Result<(Vec<(String, String)>, ParamKind), String> {
let job = cli
.run(&["jobs", "get", job_id])
.await
.map_err(|e| format!("✗ {e:#}"))?;
Ok(params_of(&job["settings"]))
}
fn params_of(settings: &Value) -> (Vec<(String, String)>, ParamKind) {
if let Some(defs) = settings["parameters"].as_array() {
let pairs = defs
.iter()
.filter_map(|d| {
let name = d["name"].as_str()?;
Some((name.to_string(), d["default"].as_str().unwrap_or("").into()))
})
.collect();
return (pairs, ParamKind::Job);
}
let mut pairs: Vec<(String, String)> = Vec::new();
for t in settings["tasks"].as_array().into_iter().flatten() {
if let Some(base) = t["notebook_task"]["base_parameters"].as_object() {
for (k, v) in base {
if !pairs.iter().any(|(name, _)| name == k) {
pairs.push((k.clone(), v.as_str().unwrap_or("").to_string()));
}
}
}
}
(pairs, ParamKind::Notebook)
}
fn duration_of(r: &Value) -> Option<u64> {
r["execution_duration"]
.as_u64()
.or_else(|| r["run_duration"].as_u64())
.filter(|d| *d > 0)
}
fn running_long(runs: &[Value], now_ms: u64) -> Option<(f64, u64, u64)> {
let newest = runs.first()?;
if !matches!(run_status(newest), Status::Running) {
return None;
}
let elapsed = now_ms.saturating_sub(newest["start_time"].as_u64()?);
let mut successes: Vec<u64> = runs[1..]
.iter()
.filter(|r| matches!(run_status(r), Status::Success))
.filter_map(duration_of)
.collect();
if successes.len() < 3 {
return None;
}
successes.sort_unstable();
let median = successes[successes.len() / 2];
(median > 0 && elapsed * 2 >= median * 3)
.then(|| (elapsed as f64 / median as f64, elapsed, median))
}
fn build_item(name: String, job_id: Option<u64>, runs: &[Value], settings: &Value) -> ListItem {
let status = runs
.first()
.map(run_status)
.unwrap_or(Status::Unknown("NO RUNS".to_string()));
let history: Vec<Status> = runs.iter().take(5).rev().map(run_status).collect();
let last_start = runs.first().and_then(|r| r["start_time"].as_u64());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let next =
crate::schedule::next_run(settings, last_start, now).map(|n| format!("⏱ {}", n.label));
let mut detail = match (last_start.map(relative_time), next) {
(Some(ago), Some(next)) => Some(format!("{ago} · {next}")),
(ago, next) => ago.or(next),
};
let slow = running_long(runs, now);
if let Some((ratio, _, _)) = slow {
let tag = format!("⚠ {ratio:.1}× usual");
detail = Some(match detail {
Some(d) => format!("{tag} · {d}"),
None => tag,
});
}
ListItem {
name,
status,
detail,
id: job_id.map(|id| id.to_string()),
history,
alert: slow.map(|(ratio, elapsed, median)| {
format!(
"running {ratio:.1}× longer than usual — {} vs ~{} median",
crate::shape::fmt_duration_ms(elapsed),
crate::shape::fmt_duration_ms(median)
)
}),
}
}
#[cfg(test)]
mod tests {
use super::{params_of, running_long, ParamKind};
use serde_json::json;
fn run(state: &str, start: u64, dur: u64) -> serde_json::Value {
if state == "RUNNING" {
json!({"start_time": start, "state": {"life_cycle_state": "RUNNING"}})
} else {
json!({"start_time": start, "execution_duration": dur,
"state": {"result_state": state}})
}
}
#[test]
fn running_long_flags_only_clear_overruns() {
let history = vec![
run("SUCCESS", 3000, 100),
run("SUCCESS", 2000, 100),
run("SUCCESS", 1000, 100),
];
let mut runs = vec![run("RUNNING", 10_000, 0)];
runs.extend(history.clone());
let (ratio, elapsed, median) = running_long(&runs, 10_160).unwrap();
assert_eq!((elapsed, median), (160, 100));
assert!((ratio - 1.6).abs() < 0.01);
assert!(running_long(&runs, 10_140).is_none());
assert!(running_long(&runs[..3], 10_600).is_none());
assert!(running_long(&history, 10_600).is_none());
}
#[test]
fn running_long_ignores_failed_durations() {
let runs = vec![
run("RUNNING", 10_000, 0),
run("FAILED", 4000, 5),
run("SUCCESS", 3000, 100),
run("SUCCESS", 2000, 100),
run("SUCCESS", 1000, 100),
];
assert!(running_long(&runs, 10_140).is_none());
assert!(running_long(&runs, 10_160).is_some());
}
#[test]
fn params_prefer_job_level_over_notebook() {
let settings = json!({
"parameters": [{"name": "date", "default": "2026-07-18"}, {"name": "mode"}],
"tasks": [{"notebook_task": {"base_parameters": {"x": "1"}}}]
});
let (pairs, kind) = params_of(&settings);
assert_eq!(kind, ParamKind::Job);
assert_eq!(
pairs,
vec![
("date".to_string(), "2026-07-18".to_string()),
("mode".to_string(), String::new())
]
);
let settings = json!({
"tasks": [
{"notebook_task": {"base_parameters": {"env": "prod"}}},
{"notebook_task": {"base_parameters": {"env": "dev", "day": "mon"}}},
{"spark_python_task": {"python_file": "x.py"}}
]
});
let (pairs, kind) = params_of(&settings);
assert_eq!(kind, ParamKind::Notebook);
assert_eq!(
pairs,
vec![
("env".to_string(), "prod".to_string()),
("day".to_string(), "mon".to_string())
]
);
}
}