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] }