Skip to main content

cortex/frontend/
jobs.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Jobs capability: poll long-running jobs. One shared [`JobDto`] renders as JSON for agents
9//! (`GET /api/jobs/<uuid>`) and the progress page (`GET /jobs/<uuid>`) polls that same JSON.
10//! The job mechanism itself lives in [`crate::jobs`].
11
12use rocket::http::Status;
13use rocket::serde::json::Json;
14use rocket::{Route, State};
15use rocket_dyn_templates::{Template, context};
16use serde::Serialize;
17use serde_json::Value;
18use uuid::Uuid;
19
20use crate::backend::DbPool;
21use crate::frontend::actor::{Actor, AdminReject, AdminSession, ReturnTo, require_admin_to};
22use crate::jobs::{self, Job};
23
24/// A job as exposed over the API/UI (uuid handle, no internal serial id).
25#[derive(Debug, Serialize, schemars::JsonSchema)]
26pub struct JobDto {
27  /// External handle.
28  pub uuid: String,
29  /// Operation kind.
30  pub kind: String,
31  /// Lifecycle status.
32  pub status: String,
33  /// Units of work completed.
34  pub progress_current: i32,
35  /// Total units, when known.
36  pub progress_total: Option<i32>,
37  /// Current step, or the error on failure.
38  pub message: String,
39  /// Who started it.
40  pub actor: String,
41  /// Terminal result payload.
42  pub result: Option<Value>,
43  /// Created timestamp.
44  pub created_at: String,
45  /// Last-updated timestamp.
46  pub updated_at: String,
47  /// Seconds of activity (`updated_at - created_at`): a finished job's total runtime, or a running
48  /// one's time from start to its last progress update — observability on duration.
49  pub duration_seconds: i64,
50  /// Seconds since the last progress update (`now - updated_at`), measured against the DB clock.
51  /// For a *running* job this is its **heartbeat age**: it keeps climbing while the job makes no
52  /// progress, so a large value flags a stalled job (the W-4 residual — a hung body Rust cannot
53  /// force-cancel — surfaced transparently rather than auto-killed). Terminal jobs report their
54  /// age-since-completion. `0` when the DB clock is unavailable (degrade to "no age", never
55  /// bogus).
56  pub seconds_since_update: i64,
57  /// Normalized health derived from `status`: `ok` (succeeded), `failed`, `interrupted`, `pending`
58  /// (queued), or `running` — the at-a-glance state for the fleet-wide pending check.
59  pub health: String,
60}
61
62/// Maps a raw lifecycle `status` (`queued`→`running`→`succeeded`/`failed`, plus `interrupted` for
63/// orphans) to a normalized health label.
64fn health_of(status: &str) -> &'static str {
65  match status {
66    "succeeded" => "ok",
67    "failed" => "failed",
68    "interrupted" => "interrupted",
69    "queued" => "pending",
70    "running" => "running",
71    _ => "unknown",
72  }
73}
74
75impl JobDto {
76  /// Builds the DTO, computing the heartbeat age (`seconds_since_update`) against `now` — the DB
77  /// clock from [`jobs::db_now`]. `None` (probe failed, or a just-spawned job where the age is
78  /// trivially ~0) yields `seconds_since_update = 0`.
79  pub fn at(job: Job, now: Option<chrono::NaiveDateTime>) -> Self {
80    let seconds_since_update = now
81      .map(|n| (n - job.updated_at).num_seconds().max(0))
82      .unwrap_or(0);
83    JobDto {
84      uuid: job.uuid.to_string(),
85      health: health_of(&job.status).to_string(),
86      duration_seconds: (job.updated_at - job.created_at).num_seconds().max(0),
87      seconds_since_update,
88      kind: job.kind,
89      status: job.status,
90      progress_current: job.progress_current,
91      progress_total: job.progress_total,
92      message: job.message,
93      actor: job.actor,
94      result: job.result,
95      created_at: crate::frontend::helpers::iso_utc(job.created_at),
96      updated_at: crate::frontend::helpers::iso_utc(job.updated_at),
97    }
98  }
99}
100
101impl From<Job> for JobDto {
102  /// Used by the spawn-return paths, where the job was created moments ago so its heartbeat age is
103  /// trivially ~0; the polling list/get handlers use [`JobDto::at`] with the live DB clock instead.
104  fn from(job: Job) -> Self { JobDto::at(job, None) }
105}
106
107/// Polls a job by its uuid (the agent twin of the progress page). **Token-gated** ([`Actor`]):
108/// jobs carry admin attribution (`actor`) + operational params, so — like the human `/jobs/<uuid>`
109/// (`require_admin`) and `/api/audit` — they are not public (X-10's sibling read-twin gap).
110#[rocket_okapi::openapi(tag = "Jobs")]
111#[get("/api/jobs/<uuid>")]
112pub fn api_job(_caller: Actor, uuid: &str, pool: &State<DbPool>) -> Result<Json<JobDto>, Status> {
113  let parsed = Uuid::parse_str(uuid).map_err(|_| Status::NotFound)?;
114  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
115  let job = jobs::find_job(&mut connection, parsed).ok_or(Status::NotFound)?;
116  let now = jobs::db_now(&mut connection);
117  Ok(Json(JobDto::at(job, now)))
118}
119
120/// Lists recent jobs across every background-task capability (import / extend / activate / …) — the
121/// fleet-wide **pending check** the observability mandate requires. `?active=true` narrows to the
122/// non-terminal (queued/running) jobs; `?limit=` caps the page (default 50, max 200). Most-recent
123/// first; each carries `health` + `duration_seconds`.
124#[rocket_okapi::openapi(tag = "Jobs")]
125#[get("/api/jobs?<active>&<limit>")]
126pub fn api_jobs(
127  _caller: Actor,
128  active: Option<bool>,
129  limit: Option<i64>,
130  pool: &State<DbPool>,
131) -> Result<Json<Vec<JobDto>>, Status> {
132  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
133  let limit = limit.unwrap_or(50).clamp(1, 200);
134  let jobs = jobs::list_recent(&mut connection, active.unwrap_or(false), limit);
135  let now = jobs::db_now(&mut connection);
136  Ok(Json(
137    jobs.into_iter().map(|job| JobDto::at(job, now)).collect(),
138  ))
139}
140
141/// The human jobs dashboard (HTML twin of [`api_jobs`]): recent background jobs with their health,
142/// duration and progress — the at-a-glance observability screen. `?active=true` shows only pending.
143#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
144#[get("/jobs?<active>&<limit>")]
145pub fn jobs_page(
146  active: Option<bool>,
147  limit: Option<i64>,
148  session: Option<AdminSession>,
149  return_to: ReturnTo,
150  pool: &State<DbPool>,
151) -> Result<Template, AdminReject> {
152  require_admin_to(session, &return_to)?;
153  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
154  let limit = limit.unwrap_or(50).clamp(1, 200);
155  let active = active.unwrap_or(false);
156  let now = jobs::db_now(&mut connection);
157  let jobs: Vec<JobDto> = jobs::list_recent(&mut connection, active, limit)
158    .into_iter()
159    .map(|job| JobDto::at(job, now))
160    .collect();
161  // Auto-refresh the (otherwise static) list while any job is in flight, so an admin who just
162  // kicked off a multi-minute refresh/reindex/import watches its progress live — no JS, no manual
163  // reload.
164  let has_active = jobs
165    .iter()
166    .any(|job| matches!(job.health.as_str(), "pending" | "running"));
167  // The operator-tunable stall threshold (W-4): a running/pending job idle past this is flagged in
168  // the list so a stuck job is obvious before the reaper flips it to `interrupted`.
169  let stale_timeout = crate::config::config().jobs.stale_timeout_seconds;
170  let global = serde_json::json!({
171    "title": "Background jobs",
172    "description": "Recent background jobs across the CorTeX framework",
173  });
174  Ok(Template::render(
175    "jobs",
176    context! { global, jobs, active, has_active, stale_timeout },
177  ))
178}
179
180/// The human progress page; it polls `GET /api/jobs/<uuid>` (vanilla fetch, no JS framework — D11).
181/// **Signed-in admins only** (unauthenticated → sign-in page); the polled JSON twin is the agent
182/// surface.
183#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
184#[get("/jobs/<uuid>")]
185pub fn job_page(
186  uuid: &str,
187  session: Option<AdminSession>,
188  return_to: ReturnTo,
189  pool: &State<DbPool>,
190) -> Result<Template, AdminReject> {
191  require_admin_to(session, &return_to)?;
192  // Load the job server-side so a terminal job — especially a FAILED one — renders its status and
193  // message immediately. This page is cookie-gated (AdminSession), but the client-side poll hits
194  // the Actor-token-gated `/api/jobs/<uuid>`; without the token the fetch 401s and the page used
195  // to show a misleading "not found". Rendering the state here makes failures visible regardless
196  // of the poll.
197  let job = Uuid::parse_str(uuid).ok().and_then(|parsed| {
198    let mut connection = pool.get().ok()?;
199    let found = jobs::find_job(&mut connection, parsed)?;
200    let now = jobs::db_now(&mut connection);
201    Some(JobDto::at(found, now))
202  });
203  let global = serde_json::json!({
204    "title": "Job progress",
205    "description": "Background job progress",
206    "job": job,
207  });
208  Ok(Template::render("job", context! { uuid, global }))
209}
210
211/// The route set for the jobs capability.
212// NB: `api_jobs` + `api_job` are mounted via `frontend::apidoc` (rocket_okapi).
213pub fn routes() -> Vec<Route> { routes![jobs_page, job_page] }