From 7543918deda326c9d5acdbc47691bb836fa0ffbb Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Tue, 24 Mar 2026 12:33:58 -0700 Subject: [PATCH 1/2] add jobs commands --- README.md | 12 +++ skills/hotdata-cli/SKILL.md | 10 ++ src/command.rs | 47 ++++++++ src/jobs.rs | 208 ++++++++++++++++++++++++++++++++++++ src/main.rs | 21 +++- 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/jobs.rs diff --git a/README.md b/README.md index 7098579..3d30c82 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var | `datasets` | `list`, `create` | Manage uploaded datasets | | `query` | | Execute a SQL query | | `results` | `list` | Retrieve stored query results | +| `jobs` | `list` | Manage background jobs | | `skills` | `install`, `status` | Manage the hotdata-cli agent skill | ## Global options @@ -147,6 +148,17 @@ hotdata results list [--workspace-id ] [--limit ] [--offset ] [--forma - Query results include a `result-id` in the table footer — use it to retrieve past results without re-running queries. +## Jobs + +```sh +hotdata jobs list [--workspace-id ] [--job-type ] [--status ] [--all] [--limit ] [--offset ] [--format table|json|yaml] +hotdata jobs [--workspace-id ] [--format table|json|yaml] +``` + +- `list` shows only active jobs (`pending` and `running`) by default. Use `--all` to see all jobs. +- `--job-type` accepts: `data_refresh_table`, `data_refresh_connection`, `create_index`. +- `--status` accepts: `pending`, `running`, `succeeded`, `partially_succeeded`, `failed`. + ## Configuration Config is stored at `~/.hotdata/config.yml` keyed by profile (default: `default`). diff --git a/skills/hotdata-cli/SKILL.md b/skills/hotdata-cli/SKILL.md index e8c3dae..0d09893 100644 --- a/skills/hotdata-cli/SKILL.md +++ b/skills/hotdata-cli/SKILL.md @@ -176,6 +176,16 @@ hotdata results [--workspace-id ] [--format table|json - Query results include a `result-id` in the footer (e.g. `[result-id: rslt...]`). - **Always use this command to retrieve past query results rather than re-running the same query.** Re-running queries wastes resources and may return different results. +### Jobs +``` +hotdata jobs list [--workspace-id ] [--job-type ] [--status ] [--all] [--format table|json|yaml] +hotdata jobs [--workspace-id ] [--format table|json|yaml] +``` +- `list` shows only active jobs (`pending`, `running`) by default. Use `--all` to see all jobs. +- `--job-type`: `data_refresh_table`, `data_refresh_connection`, `create_index`. +- `--status`: `pending`, `running`, `succeeded`, `partially_succeeded`, `failed`. +- Use `hotdata jobs ` to inspect a specific job's status, error, and result. + ### Auth ``` hotdata auth # Browser-based login diff --git a/src/command.rs b/src/command.rs index 25945f7..eb87b7e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -87,6 +87,23 @@ pub enum Commands { #[command(subcommand)] command: Option, }, + + /// Manage background jobs + Jobs { + /// Job ID (omit to use a subcommand) + id: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, global = true)] + workspace_id: Option, + + /// Output format (used with job ID) + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + + #[command(subcommand)] + command: Option, + }, } #[derive(Subcommand)] @@ -98,6 +115,36 @@ pub enum AuthCommands { Status, } +#[derive(Subcommand)] +pub enum JobsCommands { + /// List background jobs (shows active jobs by default) + List { + /// Filter by job type + #[arg(long, value_parser = ["noop", "data_refresh_table", "data_refresh_connection", "create_index"])] + job_type: Option, + + /// Filter by status + #[arg(long, value_parser = ["pending", "running", "succeeded", "partially_succeeded", "failed"])] + status: Option, + + /// Show all jobs, not just active ones + #[arg(long)] + all: bool, + + /// Maximum number of results (default: 50) + #[arg(long)] + limit: Option, + + /// Pagination offset + #[arg(long)] + offset: Option, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + }, +} + #[derive(Subcommand)] pub enum DatasetsCommands { /// List all datasets in a workspace diff --git a/src/jobs.rs b/src/jobs.rs new file mode 100644 index 0000000..bca4281 --- /dev/null +++ b/src/jobs.rs @@ -0,0 +1,208 @@ +use crate::config; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +struct Job { + id: String, + job_type: String, + status: String, + attempts: u64, + created_at: String, + completed_at: Option, + error_message: Option, + result: Option, +} + +#[derive(Deserialize)] +struct ListResponse { + jobs: Vec, +} + +pub fn get(job_id: &str, workspace_id: &str, format: &str) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + let url = format!("{}/jobs/{job_id}", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let job: Job = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&job).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&job).unwrap()), + "table" => { + use crossterm::style::Stylize; + let label = |l: &str| format!("{:<12}", l).dark_grey().to_string(); + let status_colored = match job.status.as_str() { + "succeeded" => job.status.green().to_string(), + "failed" => job.status.red().to_string(), + "running" | "pending" => job.status.yellow().to_string(), + "partially_succeeded" => job.status.dark_yellow().to_string(), + _ => job.status.clone(), + }; + println!("{}{}", label("id:"), job.id); + println!("{}{}", label("type:"), job.job_type); + println!("{}{}", label("status:"), status_colored); + println!("{}{}", label("attempts:"), job.attempts.to_string().dark_cyan()); + println!("{}{}", label("created:"), crate::util::format_date(&job.created_at)); + println!("{}{}", label("completed:"), job.completed_at.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".dark_grey().to_string())); + if let Some(err) = &job.error_message { + println!("{}{}", label("error:"), err.as_str().red()); + } + if let Some(result) = &job.result { + if !result.is_null() { + println!("{}{}", label("result:"), serde_json::to_string_pretty(result).unwrap()); + } + } + } + _ => unreachable!(), + } +} + +fn fetch_jobs( + client: &reqwest::blocking::Client, + api_key: &str, + api_url: &str, + workspace_id: &str, + job_type: Option<&str>, + status: Option<&str>, + limit: Option, + offset: Option, +) -> Vec { + let mut params = vec![]; + if let Some(jt) = job_type { params.push(format!("job_type={jt}")); } + if let Some(s) = status { params.push(format!("status={s}")); } + if let Some(l) = limit { params.push(format!("limit={l}")); } + if let Some(o) = offset { params.push(format!("offset={o}")); } + + let mut url = format!("{api_url}/jobs"); + if !params.is_empty() { url = format!("{url}?{}", params.join("&")); } + + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + match resp.json::() { + Ok(v) => v.jobs, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + } +} + +pub fn list( + workspace_id: &str, + job_type: Option<&str>, + status: Option<&str>, + all: bool, + limit: Option, + offset: Option, + format: &str, +) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth' to log in."); + std::process::exit(1); + } + }; + + let client = reqwest::blocking::Client::new(); + let api_url = profile_config.api_url.to_string(); + + let jobs = if !all && status.is_none() { + // Default: show only active jobs (pending + running) + let mut jobs = fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("pending"), limit, offset); + jobs.extend(fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("running"), limit, offset)); + jobs + } else { + fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, status, limit, offset) + }; + + let body = ListResponse { jobs }; + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&body.jobs).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&body.jobs).unwrap()), + "table" => { + if body.jobs.is_empty() { + use crossterm::style::Stylize; + let msg = if !all && status.is_none() { "No active jobs found." } else { "No jobs found." }; + eprintln!("{}", msg.dark_grey()); + } else { + let rows: Vec> = body.jobs.iter().map(|j| vec![ + j.id.clone(), + j.job_type.clone(), + j.status.clone(), + j.attempts.to_string(), + crate::util::format_date(&j.created_at), + j.completed_at.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".to_string()), + ]).collect(); + crate::table::print(&["ID", "TYPE", "STATUS", "ATTEMPTS", "CREATED", "COMPLETED"], &rows); + } + } + _ => unreachable!(), + } +} diff --git a/src/main.rs b/src/main.rs index 92068a6..6b20eda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod config; mod connections; mod connections_new; mod datasets; +mod jobs; mod query; mod results; mod skill; @@ -14,7 +15,7 @@ mod workspace; use anstyle::AnsiColor; use clap::{Parser, builder::Styles}; -use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; +use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, JobsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; #[derive(Parser)] #[command(name = "hotdata", version, about = concat!("Hotdata CLI - Command line interface for Hotdata (v", env!("CARGO_PKG_VERSION"), ")"), long_about = None, disable_version_flag = true)] @@ -176,6 +177,24 @@ fn main() { } } } + Commands::Jobs { id, workspace_id, format, command } => { + let workspace_id = resolve_workspace(workspace_id); + if let Some(id) = id { + jobs::get(&id, &workspace_id, &format) + } else { + match command { + Some(JobsCommands::List { job_type, status, all, limit, offset, format }) => { + jobs::list(&workspace_id, job_type.as_deref(), status.as_deref(), all, limit, offset, &format) + } + None => { + use clap::CommandFactory; + let mut cmd = Cli::command(); + cmd.build(); + cmd.find_subcommand_mut("jobs").unwrap().print_help().unwrap(); + } + } + } + } }, } } From a4cfa6cbb5bf3089658b7a5644332521629f2ed1 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Tue, 24 Mar 2026 12:45:59 -0700 Subject: [PATCH 2/2] remove noop job type --- src/command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/command.rs b/src/command.rs index eb87b7e..b35e3c9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -120,7 +120,7 @@ pub enum JobsCommands { /// List background jobs (shows active jobs by default) List { /// Filter by job type - #[arg(long, value_parser = ["noop", "data_refresh_table", "data_refresh_connection", "create_index"])] + #[arg(long, value_parser = ["data_refresh_table", "data_refresh_connection", "create_index"])] job_type: Option, /// Filter by status