Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -147,6 +148,17 @@ hotdata results list [--workspace-id <id>] [--limit <n>] [--offset <n>] [--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 <id>] [--job-type <type>] [--status <status>] [--all] [--limit <n>] [--offset <n>] [--format table|json|yaml]
hotdata jobs <job_id> [--workspace-id <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`).
Expand Down
10 changes: 10 additions & 0 deletions skills/hotdata-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ hotdata results <result_id> [--workspace-id <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 <workspace_id>] [--job-type <type>] [--status <status>] [--all] [--format table|json|yaml]
hotdata jobs <job_id> [--workspace-id <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 <job_id>` to inspect a specific job's status, error, and result.

### Auth
```
hotdata auth # Browser-based login
Expand Down
47 changes: 47 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ pub enum Commands {
#[command(subcommand)]
command: Option<ResultsCommands>,
},

/// Manage background jobs
Jobs {
/// Job ID (omit to use a subcommand)
id: Option<String>,

/// Workspace ID (defaults to first workspace from login)
#[arg(long, global = true)]
workspace_id: Option<String>,

/// Output format (used with job ID)
#[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])]
format: String,

#[command(subcommand)]
command: Option<JobsCommands>,
},
}

#[derive(Subcommand)]
Expand All @@ -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 = ["data_refresh_table", "data_refresh_connection", "create_index"])]
job_type: Option<String>,

/// Filter by status
#[arg(long, value_parser = ["pending", "running", "succeeded", "partially_succeeded", "failed"])]
status: Option<String>,

/// Show all jobs, not just active ones
#[arg(long)]
all: bool,

/// Maximum number of results (default: 50)
#[arg(long)]
limit: Option<u32>,

/// Pagination offset
#[arg(long)]
offset: Option<u32>,

/// 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
Expand Down
208 changes: 208 additions & 0 deletions src/jobs.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
error_message: Option<String>,
result: Option<serde_json::Value>,
}

#[derive(Deserialize)]
struct ListResponse {
jobs: Vec<Job>,
}

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<u32>,
offset: Option<u32>,
) -> Vec<Job> {
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::<ListResponse>() {
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<u32>,
offset: Option<u32>,
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<Vec<String>> = 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!(),
}
}
21 changes: 20 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod config;
mod connections;
mod connections_new;
mod datasets;
mod jobs;
mod query;
mod results;
mod skill;
Expand All @@ -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)]
Expand Down Expand Up @@ -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();
}
}
}
}
},
}
}
Expand Down
Loading