Skip to content

Commit c1a86b3

Browse files
authored
feat(jobs): Add jobs commands
1 parent 4ee1241 commit c1a86b3

5 files changed

Lines changed: 297 additions & 1 deletion

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var
5757
| `datasets` | `list`, `create` | Manage uploaded datasets |
5858
| `query` | | Execute a SQL query |
5959
| `results` | `list` | Retrieve stored query results |
60+
| `jobs` | `list` | Manage background jobs |
6061
| `skills` | `install`, `status` | Manage the hotdata-cli agent skill |
6162

6263
## Global options
@@ -147,6 +148,17 @@ hotdata results list [--workspace-id <id>] [--limit <n>] [--offset <n>] [--forma
147148

148149
- Query results include a `result-id` in the table footer — use it to retrieve past results without re-running queries.
149150

151+
## Jobs
152+
153+
```sh
154+
hotdata jobs list [--workspace-id <id>] [--job-type <type>] [--status <status>] [--all] [--limit <n>] [--offset <n>] [--format table|json|yaml]
155+
hotdata jobs <job_id> [--workspace-id <id>] [--format table|json|yaml]
156+
```
157+
158+
- `list` shows only active jobs (`pending` and `running`) by default. Use `--all` to see all jobs.
159+
- `--job-type` accepts: `data_refresh_table`, `data_refresh_connection`, `create_index`.
160+
- `--status` accepts: `pending`, `running`, `succeeded`, `partially_succeeded`, `failed`.
161+
150162
## Configuration
151163

152164
Config is stored at `~/.hotdata/config.yml` keyed by profile (default: `default`).

skills/hotdata-cli/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ hotdata results <result_id> [--workspace-id <workspace_id>] [--format table|json
176176
- Query results include a `result-id` in the footer (e.g. `[result-id: rslt...]`).
177177
- **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.
178178

179+
### Jobs
180+
```
181+
hotdata jobs list [--workspace-id <workspace_id>] [--job-type <type>] [--status <status>] [--all] [--format table|json|yaml]
182+
hotdata jobs <job_id> [--workspace-id <workspace_id>] [--format table|json|yaml]
183+
```
184+
- `list` shows only active jobs (`pending`, `running`) by default. Use `--all` to see all jobs.
185+
- `--job-type`: `data_refresh_table`, `data_refresh_connection`, `create_index`.
186+
- `--status`: `pending`, `running`, `succeeded`, `partially_succeeded`, `failed`.
187+
- Use `hotdata jobs <job_id>` to inspect a specific job's status, error, and result.
188+
179189
### Auth
180190
```
181191
hotdata auth # Browser-based login

src/command.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ pub enum Commands {
8787
#[command(subcommand)]
8888
command: Option<ResultsCommands>,
8989
},
90+
91+
/// Manage background jobs
92+
Jobs {
93+
/// Job ID (omit to use a subcommand)
94+
id: Option<String>,
95+
96+
/// Workspace ID (defaults to first workspace from login)
97+
#[arg(long, global = true)]
98+
workspace_id: Option<String>,
99+
100+
/// Output format (used with job ID)
101+
#[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])]
102+
format: String,
103+
104+
#[command(subcommand)]
105+
command: Option<JobsCommands>,
106+
},
90107
}
91108

92109
#[derive(Subcommand)]
@@ -98,6 +115,36 @@ pub enum AuthCommands {
98115
Status,
99116
}
100117

118+
#[derive(Subcommand)]
119+
pub enum JobsCommands {
120+
/// List background jobs (shows active jobs by default)
121+
List {
122+
/// Filter by job type
123+
#[arg(long, value_parser = ["data_refresh_table", "data_refresh_connection", "create_index"])]
124+
job_type: Option<String>,
125+
126+
/// Filter by status
127+
#[arg(long, value_parser = ["pending", "running", "succeeded", "partially_succeeded", "failed"])]
128+
status: Option<String>,
129+
130+
/// Show all jobs, not just active ones
131+
#[arg(long)]
132+
all: bool,
133+
134+
/// Maximum number of results (default: 50)
135+
#[arg(long)]
136+
limit: Option<u32>,
137+
138+
/// Pagination offset
139+
#[arg(long)]
140+
offset: Option<u32>,
141+
142+
/// Output format
143+
#[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])]
144+
format: String,
145+
},
146+
}
147+
101148
#[derive(Subcommand)]
102149
pub enum DatasetsCommands {
103150
/// List all datasets in a workspace

src/jobs.rs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
use crate::config;
2+
use serde::{Deserialize, Serialize};
3+
4+
#[derive(Deserialize, Serialize)]
5+
struct Job {
6+
id: String,
7+
job_type: String,
8+
status: String,
9+
attempts: u64,
10+
created_at: String,
11+
completed_at: Option<String>,
12+
error_message: Option<String>,
13+
result: Option<serde_json::Value>,
14+
}
15+
16+
#[derive(Deserialize)]
17+
struct ListResponse {
18+
jobs: Vec<Job>,
19+
}
20+
21+
pub fn get(job_id: &str, workspace_id: &str, format: &str) {
22+
let profile_config = match config::load("default") {
23+
Ok(c) => c,
24+
Err(e) => {
25+
eprintln!("{e}");
26+
std::process::exit(1);
27+
}
28+
};
29+
30+
let api_key = match &profile_config.api_key {
31+
Some(key) if key != "PLACEHOLDER" => key.clone(),
32+
_ => {
33+
eprintln!("error: not authenticated. Run 'hotdata auth' to log in.");
34+
std::process::exit(1);
35+
}
36+
};
37+
38+
let url = format!("{}/jobs/{job_id}", profile_config.api_url);
39+
let client = reqwest::blocking::Client::new();
40+
41+
let resp = match client
42+
.get(&url)
43+
.header("Authorization", format!("Bearer {api_key}"))
44+
.header("X-Workspace-Id", workspace_id)
45+
.send()
46+
{
47+
Ok(r) => r,
48+
Err(e) => {
49+
eprintln!("error connecting to API: {e}");
50+
std::process::exit(1);
51+
}
52+
};
53+
54+
if !resp.status().is_success() {
55+
use crossterm::style::Stylize;
56+
eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red());
57+
std::process::exit(1);
58+
}
59+
60+
let job: Job = match resp.json() {
61+
Ok(v) => v,
62+
Err(e) => {
63+
eprintln!("error parsing response: {e}");
64+
std::process::exit(1);
65+
}
66+
};
67+
68+
match format {
69+
"json" => println!("{}", serde_json::to_string_pretty(&job).unwrap()),
70+
"yaml" => print!("{}", serde_yaml::to_string(&job).unwrap()),
71+
"table" => {
72+
use crossterm::style::Stylize;
73+
let label = |l: &str| format!("{:<12}", l).dark_grey().to_string();
74+
let status_colored = match job.status.as_str() {
75+
"succeeded" => job.status.green().to_string(),
76+
"failed" => job.status.red().to_string(),
77+
"running" | "pending" => job.status.yellow().to_string(),
78+
"partially_succeeded" => job.status.dark_yellow().to_string(),
79+
_ => job.status.clone(),
80+
};
81+
println!("{}{}", label("id:"), job.id);
82+
println!("{}{}", label("type:"), job.job_type);
83+
println!("{}{}", label("status:"), status_colored);
84+
println!("{}{}", label("attempts:"), job.attempts.to_string().dark_cyan());
85+
println!("{}{}", label("created:"), crate::util::format_date(&job.created_at));
86+
println!("{}{}", label("completed:"), job.completed_at.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".dark_grey().to_string()));
87+
if let Some(err) = &job.error_message {
88+
println!("{}{}", label("error:"), err.as_str().red());
89+
}
90+
if let Some(result) = &job.result {
91+
if !result.is_null() {
92+
println!("{}{}", label("result:"), serde_json::to_string_pretty(result).unwrap());
93+
}
94+
}
95+
}
96+
_ => unreachable!(),
97+
}
98+
}
99+
100+
fn fetch_jobs(
101+
client: &reqwest::blocking::Client,
102+
api_key: &str,
103+
api_url: &str,
104+
workspace_id: &str,
105+
job_type: Option<&str>,
106+
status: Option<&str>,
107+
limit: Option<u32>,
108+
offset: Option<u32>,
109+
) -> Vec<Job> {
110+
let mut params = vec![];
111+
if let Some(jt) = job_type { params.push(format!("job_type={jt}")); }
112+
if let Some(s) = status { params.push(format!("status={s}")); }
113+
if let Some(l) = limit { params.push(format!("limit={l}")); }
114+
if let Some(o) = offset { params.push(format!("offset={o}")); }
115+
116+
let mut url = format!("{api_url}/jobs");
117+
if !params.is_empty() { url = format!("{url}?{}", params.join("&")); }
118+
119+
let resp = match client
120+
.get(&url)
121+
.header("Authorization", format!("Bearer {api_key}"))
122+
.header("X-Workspace-Id", workspace_id)
123+
.send()
124+
{
125+
Ok(r) => r,
126+
Err(e) => {
127+
eprintln!("error connecting to API: {e}");
128+
std::process::exit(1);
129+
}
130+
};
131+
132+
if !resp.status().is_success() {
133+
use crossterm::style::Stylize;
134+
eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red());
135+
std::process::exit(1);
136+
}
137+
138+
match resp.json::<ListResponse>() {
139+
Ok(v) => v.jobs,
140+
Err(e) => {
141+
eprintln!("error parsing response: {e}");
142+
std::process::exit(1);
143+
}
144+
}
145+
}
146+
147+
pub fn list(
148+
workspace_id: &str,
149+
job_type: Option<&str>,
150+
status: Option<&str>,
151+
all: bool,
152+
limit: Option<u32>,
153+
offset: Option<u32>,
154+
format: &str,
155+
) {
156+
let profile_config = match config::load("default") {
157+
Ok(c) => c,
158+
Err(e) => {
159+
eprintln!("{e}");
160+
std::process::exit(1);
161+
}
162+
};
163+
164+
let api_key = match &profile_config.api_key {
165+
Some(key) if key != "PLACEHOLDER" => key.clone(),
166+
_ => {
167+
eprintln!("error: not authenticated. Run 'hotdata auth' to log in.");
168+
std::process::exit(1);
169+
}
170+
};
171+
172+
let client = reqwest::blocking::Client::new();
173+
let api_url = profile_config.api_url.to_string();
174+
175+
let jobs = if !all && status.is_none() {
176+
// Default: show only active jobs (pending + running)
177+
let mut jobs = fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("pending"), limit, offset);
178+
jobs.extend(fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, Some("running"), limit, offset));
179+
jobs
180+
} else {
181+
fetch_jobs(&client, &api_key, &api_url, workspace_id, job_type, status, limit, offset)
182+
};
183+
184+
let body = ListResponse { jobs };
185+
186+
match format {
187+
"json" => println!("{}", serde_json::to_string_pretty(&body.jobs).unwrap()),
188+
"yaml" => print!("{}", serde_yaml::to_string(&body.jobs).unwrap()),
189+
"table" => {
190+
if body.jobs.is_empty() {
191+
use crossterm::style::Stylize;
192+
let msg = if !all && status.is_none() { "No active jobs found." } else { "No jobs found." };
193+
eprintln!("{}", msg.dark_grey());
194+
} else {
195+
let rows: Vec<Vec<String>> = body.jobs.iter().map(|j| vec![
196+
j.id.clone(),
197+
j.job_type.clone(),
198+
j.status.clone(),
199+
j.attempts.to_string(),
200+
crate::util::format_date(&j.created_at),
201+
j.completed_at.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".to_string()),
202+
]).collect();
203+
crate::table::print(&["ID", "TYPE", "STATUS", "ATTEMPTS", "CREATED", "COMPLETED"], &rows);
204+
}
205+
}
206+
_ => unreachable!(),
207+
}
208+
}

src/main.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod config;
44
mod connections;
55
mod connections_new;
66
mod datasets;
7+
mod jobs;
78
mod query;
89
mod results;
910
mod skill;
@@ -14,7 +15,7 @@ mod workspace;
1415

1516
use anstyle::AnsiColor;
1617
use clap::{Parser, builder::Styles};
17-
use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands};
18+
use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, JobsCommands, ResultsCommands, SkillCommands, TablesCommands, WorkspaceCommands};
1819

1920
#[derive(Parser)]
2021
#[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() {
176177
}
177178
}
178179
}
180+
Commands::Jobs { id, workspace_id, format, command } => {
181+
let workspace_id = resolve_workspace(workspace_id);
182+
if let Some(id) = id {
183+
jobs::get(&id, &workspace_id, &format)
184+
} else {
185+
match command {
186+
Some(JobsCommands::List { job_type, status, all, limit, offset, format }) => {
187+
jobs::list(&workspace_id, job_type.as_deref(), status.as_deref(), all, limit, offset, &format)
188+
}
189+
None => {
190+
use clap::CommandFactory;
191+
let mut cmd = Cli::command();
192+
cmd.build();
193+
cmd.find_subcommand_mut("jobs").unwrap().print_help().unwrap();
194+
}
195+
}
196+
}
197+
}
179198
},
180199
}
181200
}

0 commit comments

Comments
 (0)