Skip to main content

cortex/frontend/
runs.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//! Historical-runs capability: inspect the run history of a `(corpus, service)` as an agent API
9//! (the JSON twin of the human history screen).
10//!
11//! Follows the symmetry contract — one shared [`RunDto`] is the read model for both the agent API
12//! (`GET /api/runs/...`) and the server-rendered human screen ([`runs_page`], `GET /runs/...`).
13//! Handlers live here; the app is assembled in [`crate::frontend::server`]. The binary's legacy
14//! `history` page (Vega charts) still renders today and migrates onto this surface in a later
15//! increment.
16
17use chrono::NaiveDateTime;
18use rocket::http::Status;
19use rocket::response::Redirect;
20use rocket::serde::json::Json;
21use rocket::{Route, State};
22use rocket_dyn_templates::{Template, context};
23use serde::Serialize;
24
25use std::collections::HashMap;
26
27use crate::backend::{DbPool, list_task_diffs, summary_task_diffs};
28use crate::frontend::actor::{AdminReject, AdminSession, ReturnTo, require_admin_to};
29use crate::frontend::helpers::uri_escape;
30use crate::frontend::params::{MAX_REPORT_OFFSET, MAX_REPORT_PAGE_SIZE};
31use crate::helpers::TaskStatus;
32use crate::models::{
33  Corpus, DiffStatusFilter, HistoricalRun, RunMetadata, RunMetadataStack, Service, TaskRunMetadata,
34};
35
36/// A historical `(corpus, service)` run as exposed over the API: a stable `id` handle,
37/// who/why/when, whether it has completed, and the per-severity task tallies. For a **completed**
38/// run these are the snapshot frozen at completion; for an **open** run they are overlaid with
39/// **live** progress (`HistoricalRun::with_live_tallies`), since stored tallies are only frozen at
40/// completion — so an in-progress run reports its real state, not zeros.
41#[derive(Debug, Serialize, schemars::JsonSchema)]
42pub struct RunDto {
43  /// Stable external handle (UUIDv7) for this run — the durable token for referencing it.
44  pub public_id: String,
45  /// Internal serial run id.
46  pub id: i32,
47  /// Who initiated the run.
48  pub owner: String,
49  /// Why the run was initiated (free-text description / rerun filter summary).
50  pub description: String,
51  /// Run start, ISO-8601 (`YYYY-MM-DDThh:mm:ss`, naive/local).
52  pub start_time: String,
53  /// Run end, ISO-8601; `None` while the run is still open (it closes when the next run starts).
54  pub end_time: Option<String>,
55  /// Whether the run has completed (`end_time` is set).
56  pub completed: bool,
57  /// Total tasks in the run (excludes invalids from the denominator elsewhere).
58  pub total: i32,
59  /// Tasks that completed with no notable problems.
60  pub no_problem: i32,
61  /// Tasks that completed with warnings.
62  pub warning: i32,
63  /// Tasks that completed with errors.
64  pub error: i32,
65  /// Tasks that failed fatally.
66  pub fatal: i32,
67  /// Invalid tasks (excluded from totals).
68  pub invalid: i32,
69  /// Tasks still in progress when the run closed.
70  pub in_progress: i32,
71}
72
73impl From<HistoricalRun> for RunDto {
74  fn from(run: HistoricalRun) -> RunDto {
75    RunDto {
76      public_id: run.public_id.to_string(),
77      id: run.id,
78      owner: run.owner,
79      description: run.description,
80      start_time: crate::frontend::helpers::iso_utc(run.start_time),
81      end_time: run.end_time.map(crate::frontend::helpers::iso_utc),
82      completed: run.end_time.is_some(),
83      total: run.total,
84      no_problem: run.no_problem,
85      warning: run.warning,
86      error: run.error,
87      fatal: run.fatal,
88      invalid: run.invalid,
89      in_progress: run.in_progress,
90    }
91  }
92}
93
94/// One run-over-run tally change for the human history table: the signed delta and the CSS class
95/// that colours it (good/bad/neutral). Human-render only — the agent `/api/runs` feed stays raw
96/// tallies (agents compute their own deltas).
97#[derive(Debug, Serialize)]
98struct DeltaCell {
99  /// Signed change vs the next-older run (`this.tally - older.tally`).
100  v: i32,
101  /// CSS class encoding the *direction's meaning*: `delta-good` (improvement), `delta-bad`
102  /// (regression), or `delta-zero` (no change). Severity-aware — see [`delta_class`].
103  cls: &'static str,
104}
105
106/// Per-severity change of a run vs the next-older run, so the table answers "how did this run move
107/// the conversion tallies" directly instead of making the reader subtract consecutive rows.
108#[derive(Debug, Serialize)]
109struct RunDelta {
110  /// Change in clean conversions (more is better — the headline conversion-rate movement).
111  no_problem: DeltaCell,
112  /// Change in warnings (fewer is better).
113  warning: DeltaCell,
114  /// Change in errors (fewer is better).
115  error: DeltaCell,
116  /// Change in fatal failures (fewer is better).
117  fatal: DeltaCell,
118}
119
120/// A run-history table row: the shared [`RunDto`] tallies plus the human-only run-over-run delta.
121/// `delta` is `None` for the oldest run (nothing older to compare against).
122#[derive(Debug, Serialize)]
123struct RunRow {
124  /// The run's absolute tallies (flattened, so the template reads `run.no_problem` etc.).
125  #[serde(flatten)]
126  run: RunDto,
127  /// Change vs the next-older run; `None` for the oldest row.
128  delta: Option<RunDelta>,
129}
130
131/// Maps a signed tally delta to its colour class given the severity's polarity. For `no_problem`
132/// more is better (`higher_is_better = true`); for warning/error/fatal fewer is better. A zero
133/// delta is `delta-zero` (muted) so an unchanged column reads as "no change", not "no data".
134fn delta_class(delta: i32, higher_is_better: bool) -> &'static str {
135  if delta == 0 {
136    "delta-zero"
137  } else if (delta > 0) == higher_is_better {
138    "delta-good"
139  } else {
140    "delta-bad"
141  }
142}
143
144impl RunDelta {
145  /// Builds the per-severity deltas of `run` against the next-older `older` run.
146  fn between(run: &RunDto, older: &RunDto) -> RunDelta {
147    let cell = |delta: i32, higher_is_better: bool| DeltaCell {
148      v: delta,
149      cls: delta_class(delta, higher_is_better),
150    };
151    RunDelta {
152      no_problem: cell(run.no_problem - older.no_problem, true),
153      warning: cell(run.warning - older.warning, false),
154      error: cell(run.error - older.error, false),
155      fatal: cell(run.fatal - older.fatal, false),
156    }
157  }
158}
159
160/// A historical run as exposed in the **system-wide** overview: the per-`(corpus, service)`
161/// [`RunDto`] fields plus the corpus + service names (the overview spans every pair, so the names
162/// are part of the row). The read model for `GET /api/runs` and the `/admin/runs` management
163/// screen.
164#[derive(Debug, Serialize, schemars::JsonSchema)]
165pub struct RunOverviewDto {
166  /// Stable external handle (UUIDv7) for this run — the durable token for referencing it.
167  pub public_id: String,
168  /// The corpus the run targeted.
169  pub corpus: String,
170  /// The service the run targeted.
171  pub service: String,
172  /// Who initiated the run.
173  pub owner: String,
174  /// Why the run was initiated.
175  pub description: String,
176  /// Run start, ISO-8601.
177  pub start_time: String,
178  /// Run end, ISO-8601; `None` while still open.
179  pub end_time: Option<String>,
180  /// Whether the run has completed.
181  pub completed: bool,
182  /// Total tasks in the run.
183  pub total: i32,
184  /// Tasks with no notable problems.
185  pub no_problem: i32,
186  /// Tasks with warnings.
187  pub warning: i32,
188  /// Tasks with errors.
189  pub error: i32,
190  /// Fatally-failed tasks.
191  pub fatal: i32,
192  /// Invalid tasks.
193  pub invalid: i32,
194  /// Tasks still in progress when the run closed.
195  pub in_progress: i32,
196}
197
198impl RunOverviewDto {
199  /// Builds the overview row from a run + the corpus/service name lookups (unknown ids render as
200  /// their numeric id rather than failing the whole listing).
201  fn build(
202    run: HistoricalRun,
203    corpora: &HashMap<i32, String>,
204    services: &HashMap<i32, String>,
205  ) -> RunOverviewDto {
206    RunOverviewDto {
207      public_id: run.public_id.to_string(),
208      corpus: corpora
209        .get(&run.corpus_id)
210        .cloned()
211        .unwrap_or_else(|| format!("#{}", run.corpus_id)),
212      service: services
213        .get(&run.service_id)
214        .cloned()
215        .unwrap_or_else(|| format!("#{}", run.service_id)),
216      owner: run.owner,
217      description: run.description,
218      start_time: crate::frontend::helpers::iso_utc(run.start_time),
219      end_time: run.end_time.map(crate::frontend::helpers::iso_utc),
220      completed: run.end_time.is_some(),
221      total: run.total,
222      no_problem: run.no_problem,
223      warning: run.warning,
224      error: run.error,
225      fatal: run.fatal,
226      invalid: run.invalid,
227      in_progress: run.in_progress,
228    }
229  }
230}
231
232/// Loads the most-recent historical runs as overview rows, optionally narrowed by `corpus` and/or
233/// `service` name and/or exact `owner` (the **filter-driven** run-management surface the owner
234/// asked for) — the shared core of `GET /api/runs` and the `/admin/runs` screen. An unknown
235/// corpus/service name matches nothing (empty result, not an error). `limit` is clamped by the
236/// caller.
237fn load_recent_runs(
238  pool: &DbPool,
239  corpus: Option<&str>,
240  service: Option<&str>,
241  owner: Option<&str>,
242  limit: i64,
243) -> Result<Vec<RunOverviewDto>, Status> {
244  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
245  // Resolve the corpus/service name filters to ids; an unknown name narrows to nothing.
246  let corpus_id = match corpus.filter(|name| !name.is_empty()) {
247    Some(name) => match Corpus::find_by_name(name, &mut connection) {
248      Ok(corpus) => Some(corpus.id),
249      Err(_) => return Ok(Vec::new()),
250    },
251    None => None,
252  };
253  let service_id = match service.filter(|name| !name.is_empty()) {
254    Some(name) => match Service::find_by_name(name, &mut connection) {
255      Ok(service) => Some(service.id),
256      Err(_) => return Ok(Vec::new()),
257    },
258    None => None,
259  };
260  let owner = owner.filter(|owner| !owner.is_empty());
261  let runs = HistoricalRun::recent_filtered(&mut connection, corpus_id, service_id, owner, limit)
262    .map_err(|_| Status::InternalServerError)?;
263  // Open runs freeze their tallies only at completion, so their stored counts are zero; overlay the
264  // live progress so an in-progress run shows real numbers (a no-op for completed runs). Batched
265  // into one query across all open runs — this is a system-wide list, so a per-run overlay would be
266  // an N+1 over the open-run count (KNOWN_ISSUES P-1).
267  let runs = HistoricalRun::overlay_live_tallies(runs, &mut connection);
268  // The corpora/services tables are small; one batched read each beats N+1 per-run lookups.
269  let corpora: HashMap<i32, String> = Corpus::all(&mut connection)
270    .unwrap_or_default()
271    .into_iter()
272    .map(|corpus| (corpus.id, corpus.name))
273    .collect();
274  let services: HashMap<i32, String> = Service::all(&mut connection)
275    .unwrap_or_default()
276    .into_iter()
277    .map(|service| (service.id, service.name))
278    .collect();
279  Ok(
280    runs
281      .into_iter()
282      .map(|run| RunOverviewDto::build(run, &corpora, &services))
283      .collect(),
284  )
285}
286
287/// The system-wide run history (agent twin of the `/admin/runs` screen): the most recent runs,
288/// newest first, optionally filtered by `corpus`/`service`/`owner`, capped at `limit` (default 100,
289/// max 500). `503` if the pool is exhausted.
290#[rocket_okapi::openapi(tag = "Runs")]
291#[get("/api/runs?<corpus>&<service>&<owner>&<limit>")]
292pub fn api_all_runs(
293  corpus: Option<String>,
294  service: Option<String>,
295  owner: Option<String>,
296  limit: Option<i64>,
297  pool: &State<DbPool>,
298) -> Result<Json<Vec<RunOverviewDto>>, Status> {
299  let limit = limit.unwrap_or(100).clamp(1, 500);
300  Ok(Json(load_recent_runs(
301    pool,
302    corpus.as_deref(),
303    service.as_deref(),
304    owner.as_deref(),
305    limit,
306  )?))
307}
308
309/// The system-wide run-management overview (`GET /admin/runs`): the most recent runs, filterable by
310/// corpus/service/owner, each linking into its per-service history + diff drill-downs. Signed-in
311/// admins only (unauthenticated → sign-in, returning here).
312#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
313#[get("/admin/runs?<corpus>&<service>&<owner>&<limit>")]
314pub fn all_runs_page(
315  corpus: Option<String>,
316  service: Option<String>,
317  owner: Option<String>,
318  limit: Option<i64>,
319  session: Option<AdminSession>,
320  return_to: ReturnTo,
321  pool: &State<DbPool>,
322) -> Result<Template, AdminReject> {
323  let admin = require_admin_to(session, &return_to)?;
324  let limit = limit.unwrap_or(100).clamp(1, 500);
325  // Best-effort, like the other admin screens: a db hiccup renders an empty table, never a 500.
326  let runs = load_recent_runs(
327    pool,
328    corpus.as_deref(),
329    service.as_deref(),
330    owner.as_deref(),
331    limit,
332  )
333  .unwrap_or_default();
334  // The known corpus + service names seed the filter dropdowns (small tables).
335  let mut corpus_names: Vec<String> = Vec::new();
336  let mut service_names: Vec<String> = Vec::new();
337  if let Ok(mut connection) = pool.get() {
338    corpus_names = Corpus::all(&mut connection)
339      .unwrap_or_default()
340      .into_iter()
341      .map(|corpus| corpus.name)
342      .collect();
343    service_names = Service::all(&mut connection)
344      .unwrap_or_default()
345      .into_iter()
346      .map(|service| service.name)
347      .collect();
348  }
349  let global = serde_json::json!({
350    "title": "Historical runs",
351    "description": "Recent conversion runs across every corpus and service",
352  });
353  Ok(Template::render(
354    "admin-runs",
355    context! {
356      global, owner: admin.owner, runs, corpus_names, service_names,
357      filter_corpus: corpus, filter_service: service, filter_owner: owner,
358    },
359  ))
360}
361
362/// Resolves a `(corpus, service)` name pair to its records, mapping each miss to `404`.
363fn resolve(
364  corpus: &str,
365  service: &str,
366  connection: &mut diesel::PgConnection,
367) -> Result<(Corpus, Service), Status> {
368  let corpus = Corpus::find_by_name(corpus, connection).map_err(|_| Status::NotFound)?;
369  let service = Service::find_by_name(service, connection).map_err(|_| Status::NotFound)?;
370  Ok((corpus, service))
371}
372
373/// Lists the run history of a `(corpus, service)`, most-recent first (the agent twin of the history
374/// screen). `404` if the corpus or service is unknown.
375#[rocket_okapi::openapi(tag = "Runs")]
376#[get("/api/runs/<corpus>/<service>")]
377pub fn api_runs(
378  corpus: &str,
379  service: &str,
380  pool: &State<DbPool>,
381) -> Result<Json<Vec<RunDto>>, Status> {
382  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
383  let (corpus, service) = resolve(corpus, service, &mut connection)?;
384  let runs = HistoricalRun::find_by(&corpus, &service, &mut connection).unwrap_or_default();
385  // An open run's tallies are frozen only at completion; overlay live progress for any open run.
386  Ok(Json(
387    runs
388      .into_iter()
389      .map(|run| RunDto::from(run.with_live_tallies(&mut connection)))
390      .collect(),
391  ))
392}
393
394/// Returns the currently open run of a `(corpus, service)`, or `null` if none is in progress. `404`
395/// if the corpus or service is unknown.
396#[rocket_okapi::openapi(tag = "Runs")]
397#[get("/api/runs/<corpus>/<service>/current")]
398pub fn api_run_current(
399  corpus: &str,
400  service: &str,
401  pool: &State<DbPool>,
402) -> Result<Json<Option<RunDto>>, Status> {
403  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
404  let (corpus, service) = resolve(corpus, service, &mut connection)?;
405  let current = HistoricalRun::find_current(&corpus, &service, &mut connection)
406    .map_err(|_| Status::InternalServerError)?;
407  // The current run is by definition open, so its stored tallies are zero — overlay live progress.
408  Ok(Json(current.map(|run| {
409    RunDto::from(run.with_live_tallies(&mut connection))
410  })))
411}
412
413/// One cell of the run-comparison matrix: how many tasks moved from `previous_status` to
414/// `current_status` between the two snapshots.
415#[derive(Debug, Serialize, schemars::JsonSchema)]
416pub struct RunDiffTransitionDto {
417  /// Severity key in the earlier snapshot (`no_problem`, `warning`, `error`, `fatal`).
418  pub previous_status: String,
419  /// Severity key in the later snapshot.
420  pub current_status: String,
421  /// Number of tasks that made this transition.
422  pub task_count: usize,
423}
424
425/// A comparison of two saved task-status snapshots of a `(corpus, service)`: the status-transition
426/// matrix (what improved / regressed between runs) plus the snapshot dates available to compare.
427#[derive(Debug, Serialize, schemars::JsonSchema)]
428pub struct RunDiffDto {
429  /// Snapshot dates available for comparison.
430  pub available_dates: Vec<String>,
431  /// The full previous→current status-transition matrix, with task counts.
432  pub transitions: Vec<RunDiffTransitionDto>,
433}
434
435/// Parses an optional `YYYY-MM-DD hh:mm:ss[.fff]` snapshot timestamp, mapping a malformed value to
436/// `400`. (The legacy HTML diff route `.unwrap()`s here and panics — a dispatch-path panic this
437/// twin fixes; see `docs/KNOWN_ISSUES.md`.)
438fn parse_snapshot_date(raw: Option<&str>) -> Result<Option<NaiveDateTime>, Status> {
439  match raw.map(str::trim).filter(|value| !value.is_empty()) {
440    None => Ok(None),
441    Some(value) => NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f")
442      .map(Some)
443      .map_err(|_| Status::BadRequest),
444  }
445}
446
447/// Parses an optional severity key (`no_problem`/`warning`/`error`/`fatal`/…) into a status filter,
448/// mapping a present-but-unknown value to `400`. Absent or empty means "no filter on this side".
449fn parse_status(raw: Option<&str>) -> Result<Option<TaskStatus>, Status> {
450  match raw.map(str::trim).filter(|value| !value.is_empty()) {
451    None => Ok(None),
452    Some(value) => TaskStatus::from_key(value)
453      .map(Some)
454      .ok_or(Status::BadRequest),
455  }
456}
457
458/// Compares two task-status snapshots of a `(corpus, service)` (the agent twin of the diff-summary
459/// screen). `previous`/`current` are snapshot timestamps from `available_dates`; omit them to use
460/// the most recent saved pair. `400` on a malformed date, `404` if the corpus/service is unknown.
461#[rocket_okapi::openapi(tag = "Runs")]
462#[get("/api/runs/<corpus>/<service>/diff?<previous>&<current>")]
463pub fn api_run_diff(
464  corpus: &str,
465  service: &str,
466  previous: Option<&str>,
467  current: Option<&str>,
468  pool: &State<DbPool>,
469) -> Result<Json<RunDiffDto>, Status> {
470  let previous_date = parse_snapshot_date(previous)?;
471  let current_date = parse_snapshot_date(current)?;
472  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
473  let (corpus, service) = resolve(corpus, service, &mut connection)?;
474  let (available_dates, rows) = summary_task_diffs(
475    &mut connection,
476    &corpus,
477    &service,
478    previous_date,
479    current_date,
480  );
481  let transitions = rows
482    .into_iter()
483    .map(|row| RunDiffTransitionDto {
484      previous_status: row.previous_status,
485      current_status: row.current_status,
486      task_count: row.task_count,
487    })
488    .collect();
489  Ok(Json(RunDiffDto {
490    available_dates,
491    transitions,
492  }))
493}
494
495/// A single task's status transition between two snapshots — which document regressed or improved,
496/// and when each snapshot was taken.
497#[derive(Debug, Serialize, schemars::JsonSchema)]
498pub struct TaskDiffDto {
499  /// Task identifier.
500  pub task_id: String,
501  /// Document entry name (trimmed).
502  pub entry: String,
503  /// Severity key in the earlier snapshot.
504  pub previous_status: String,
505  /// Severity key in the later snapshot.
506  pub current_status: String,
507  /// When the earlier snapshot was saved (`YYYY-MM-DD`).
508  pub previous_saved_at: String,
509  /// When the later snapshot was saved (`YYYY-MM-DD`).
510  pub current_saved_at: String,
511}
512
513impl From<TaskRunMetadata> for TaskDiffDto {
514  fn from(task: TaskRunMetadata) -> TaskDiffDto {
515    TaskDiffDto {
516      task_id: task.task_id,
517      entry: task.entry,
518      previous_status: task.previous_status,
519      current_status: task.current_status,
520      previous_saved_at: task.previous_saved_at,
521      current_saved_at: task.current_saved_at,
522    }
523  }
524}
525
526/// Lists the individual tasks whose status changed between two snapshots of a `(corpus, service)`
527/// — the drill-down behind the comparison matrix (which documents regressed/improved). Optionally
528/// filtered to a `previous_status`/`current_status` transition and paginated (`offset`/`page_size`,
529/// default 100). `400` on a malformed date or status, `404` if the corpus/service is unknown.
530#[allow(clippy::too_many_arguments)]
531#[rocket_okapi::openapi(tag = "Runs")]
532#[get(
533  "/api/runs/<corpus>/<service>/tasks?<previous>&<current>&<previous_status>&<current_status>&<offset>&<page_size>"
534)]
535pub fn api_run_task_diffs(
536  corpus: &str,
537  service: &str,
538  previous: Option<&str>,
539  current: Option<&str>,
540  previous_status: Option<&str>,
541  current_status: Option<&str>,
542  offset: Option<usize>,
543  page_size: Option<usize>,
544  pool: &State<DbPool>,
545) -> Result<Json<Vec<TaskDiffDto>>, Status> {
546  let filters = DiffStatusFilter {
547    previous_status: parse_status(previous_status)?,
548    current_status: parse_status(current_status)?,
549    previous_date: parse_snapshot_date(previous)?,
550    current_date: parse_snapshot_date(current)?,
551    // Bound both: `?page_size=0` would request an unpaginated diff (R-8) and a huge value would
552    // `LIMIT` a whole task-diff set into one response; a deep `offset` is a scan-and-discard (P-4).
553    // Same bounds as the report paths.
554    offset: offset.unwrap_or(0).min(MAX_REPORT_OFFSET as usize),
555    page_size: page_size
556      .unwrap_or(100)
557      .clamp(1, MAX_REPORT_PAGE_SIZE as usize),
558  };
559  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
560  let (corpus, service) = resolve(corpus, service, &mut connection)?;
561  let tasks = list_task_diffs(&mut connection, &corpus, &service, filters);
562  Ok(Json(tasks.into_iter().map(TaskDiffDto::from).collect()))
563}
564
565/// Severity keys a human can filter a task-diff on (the transition endpoints we record snapshots
566/// for). Offered as the dropdown options on the task-diff screen.
567const DIFF_SEVERITY_KEYS: [&str; 6] =
568  ["no_problem", "warning", "error", "fatal", "invalid", "todo"];
569
570/// The human task-diff screen: a server-rendered, filterable table of the individual tasks whose
571/// status changed between two snapshots — the 1:1 HTML twin of [`api_run_task_diffs`], sharing
572/// [`TaskDiffDto`]. This is the *filter-driven* heart of run management: pick a
573/// `previous_status → current_status` transition (and optionally a snapshot pair) and see exactly
574/// which documents regressed or improved. This parses gracefully: `400` on a malformed
575/// date/status, `404` on an unknown corpus/service, and an empty filter just lists every change.
576/// It replaced the legacy `diff-history` binary route, which `.expect()`ed the status params and
577/// `.unwrap()`ed the dates — **panicking on the dispatch path** (the F-1 gap, now closed).
578#[allow(clippy::too_many_arguments)]
579#[get(
580  "/runs/<corpus>/<service>/tasks?<previous>&<current>&<previous_status>&<current_status>&<offset>&<page_size>"
581)]
582pub fn runs_tasks_page(
583  corpus: &str,
584  service: &str,
585  previous: Option<&str>,
586  current: Option<&str>,
587  previous_status: Option<&str>,
588  current_status: Option<&str>,
589  offset: Option<usize>,
590  page_size: Option<usize>,
591  pool: &State<DbPool>,
592) -> Result<Template, Status> {
593  // Parse before touching the DB so bad input fails fast and cheaply (mirrors the agent twin).
594  let previous_status_filter = parse_status(previous_status)?;
595  let current_status_filter = parse_status(current_status)?;
596  let previous_date = parse_snapshot_date(previous)?;
597  let current_date = parse_snapshot_date(current)?;
598  // Bound the paginate params (R-8: no `page_size=0`; P-4: no huge page / deep `OFFSET` scan).
599  let offset = offset.unwrap_or(0).min(MAX_REPORT_OFFSET as usize);
600  let page_size = page_size
601    .unwrap_or(100)
602    .clamp(1, MAX_REPORT_PAGE_SIZE as usize);
603
604  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
605  let (corpus_record, service_record) = resolve(corpus, service, &mut connection)?;
606  let tasks: Vec<TaskDiffDto> = list_task_diffs(
607    &mut connection,
608    &corpus_record,
609    &service_record,
610    DiffStatusFilter {
611      previous_status: previous_status_filter,
612      current_status: current_status_filter,
613      previous_date,
614      current_date,
615      offset,
616      page_size,
617    },
618  )
619  .into_iter()
620  .map(TaskDiffDto::from)
621  .collect();
622
623  // A full page implies there may be more; any non-zero offset implies a previous page exists.
624  let page_len = tasks.len();
625  let has_next = page_len == page_size;
626  let has_prev = offset > 0;
627  // Normalize the selected filter back to canonical keys for the form's `selected` state, so a
628  // round-trip preserves the choice (and an unknown-but-accepted alias collapses to its key).
629  let selected_previous = previous_status_filter
630    .map(|s| s.to_key())
631    .unwrap_or_default();
632  let selected_current = current_status_filter
633    .map(|s| s.to_key())
634    .unwrap_or_default();
635  let global = serde_json::json!({
636    "title": format!("Task changes · {service} / {corpus}"),
637    "description": format!("Per-task severity changes of service {service} over corpus {corpus}"),
638  });
639  Ok(Template::render(
640    "runs-tasks",
641    context! {
642      global,
643      corpus,
644      service,
645      tasks,
646      severities: DIFF_SEVERITY_KEYS,
647      selected_previous,
648      selected_current,
649      previous_date: previous.unwrap_or_default(),
650      current_date: current.unwrap_or_default(),
651      offset: offset as i64,
652      page_size: page_size as i64,
653      from_offset: offset as i64 + 1,
654      to_offset: offset as i64 + page_len as i64,
655      prev_offset: offset.saturating_sub(page_size) as i64,
656      next_offset: (offset + page_size) as i64,
657      has_prev,
658      has_next,
659    },
660  ))
661}
662
663/// The human diff-summary screen: the server-rendered status-transition **matrix** between two
664/// snapshots — the 1:1 HTML twin of [`api_run_diff`], sharing [`RunDiffTransitionDto`]. A snapshot
665/// pair is chosen with two date dropdowns (a JS-free `<form method=get>`); each transition cell
666/// links into the [`runs_tasks_page`] drill-down pre-filtered to that `previous → current`
667/// transition. Reuses `parse_snapshot_date`, so a malformed date is a `400` and `404` on an unknown
668/// corpus/service. It replaced the legacy `diff-summary` binary route, which `.unwrap()`ed the date
669/// and **panicked on the dispatch path** (the F-1 gap, now closed).
670#[get("/runs/<corpus>/<service>/diff?<previous>&<current>")]
671pub fn runs_diff_page(
672  corpus: &str,
673  service: &str,
674  previous: Option<&str>,
675  current: Option<&str>,
676  pool: &State<DbPool>,
677) -> Result<Template, Status> {
678  let previous_date = parse_snapshot_date(previous)?;
679  let current_date = parse_snapshot_date(current)?;
680  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
681  let (corpus_record, service_record) = resolve(corpus, service, &mut connection)?;
682  let (available_dates, rows) = summary_task_diffs(
683    &mut connection,
684    &corpus_record,
685    &service_record,
686    previous_date,
687    current_date,
688  );
689  let transitions: Vec<RunDiffTransitionDto> = rows
690    .into_iter()
691    .map(|row| RunDiffTransitionDto {
692      previous_status: row.previous_status,
693      current_status: row.current_status,
694      task_count: row.task_count,
695    })
696    .collect();
697  let global = serde_json::json!({
698    "title": format!("Run diff · {service} / {corpus}"),
699    "description": format!("Status-transition matrix of service {service} over corpus {corpus}"),
700  });
701  Ok(Template::render(
702    "runs-diff",
703    context! {
704      global,
705      corpus,
706      service,
707      available_dates,
708      transitions,
709      previous_date: previous.unwrap_or_default(),
710      current_date: current.unwrap_or_default(),
711    },
712  ))
713}
714
715/// The human run-history screen: a server-rendered table of the same runs `GET /api/runs/...`
716/// returns (the 1:1 HTML twin, sharing [`RunDto`]). `404` if the corpus/service is unknown.
717#[get("/runs/<corpus>/<service>")]
718pub fn runs_page(corpus: &str, service: &str, pool: &State<DbPool>) -> Result<Template, Status> {
719  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
720  let (corpus_record, service_record) = resolve(corpus, service, &mut connection)?;
721  // Fetch the historical runs once (overlaying live progress on any still-open run so it isn't
722  // shown as all-zeros) and derive BOTH the table DTOs and the chart's `RunMetadata` from the
723  // same overlaid records — so the inline success-rate chart and the delta table can never
724  // disagree.
725  let historical: Vec<HistoricalRun> =
726    HistoricalRun::find_by(&corpus_record, &service_record, &mut connection)
727      .unwrap_or_default()
728      .into_iter()
729      .map(|run| run.with_live_tallies(&mut connection))
730      .collect();
731  let runs_meta: Vec<RunMetadata> = historical.iter().cloned().map(RunMetadata::from).collect();
732  let runs: Vec<RunDto> = historical.into_iter().map(RunDto::from).collect();
733  // Run-over-run deltas (newest-first, so row `i` compares against the next-older row `i+1`) so the
734  // human table answers "how did this run move the conversion tallies" without the reader
735  // subtracting consecutive rows. The agent `/api/runs` feed stays raw (agents diff themselves).
736  let deltas: Vec<Option<RunDelta>> = (0..runs.len())
737    .map(|i| {
738      runs
739        .get(i + 1)
740        .map(|older| RunDelta::between(&runs[i], older))
741    })
742    .collect();
743  let runs: Vec<RunRow> = runs
744    .into_iter()
745    .zip(deltas)
746    .map(|(run, delta)| RunRow { run, delta })
747    .collect();
748  // The inline success-rate chart (merged in from the former standalone `/history` page): the same
749  // Vega stack the `vega-history` partial renders. Escape `<`/`>`/`&` to their JSON `\uXXXX` forms
750  // before embedding in the `<script>` block — a run `description` containing `</script>` would
751  // otherwise break out of the tag (stored XSS, and `/runs` is public). `JSON.parse` decodes them
752  // back, so the chart data is byte-identical while the markup can no longer be escaped.
753  let history_serialized = serde_json::to_string(&RunMetadataStack::transform(&runs_meta))
754    .unwrap_or_default()
755    .replace('<', "\\u003c")
756    .replace('>', "\\u003e")
757    .replace('&', "\\u0026");
758  let history_length = runs_meta
759    .iter()
760    .filter(|run| !run.end_time.is_empty())
761    .count()
762    .to_string();
763  // `global` carries the title/description the shared `layout` template expects, plus the
764  // corpus/service names + run count the chart partial reads.
765  let global = serde_json::json!({
766    "title": format!("Run history · {service} / {corpus}"),
767    "description": format!("Historical runs of service {service} over corpus {corpus}"),
768    "service_name": service,
769    "corpus_name": corpus,
770    // The shared layout breadcrumb (corpus › service) links via the percent-encoded `_uri` forms;
771    // supply them as the report pages' `decorate_uri_encodings` does, else the breadcrumb (now
772    // active because corpus_name/service_name are set) references undefined vars and 500s.
773    "corpus_name_uri": uri_escape(Some(corpus.to_string())).unwrap_or_default(),
774    "service_name_uri": uri_escape(Some(service.to_string())).unwrap_or_default(),
775    "history_length": history_length,
776  });
777  Ok(Template::render(
778    "runs",
779    context! { global, corpus, service, runs, history_serialized },
780  ))
781}
782
783/// The former standalone history **chart** page. The success-rate Vega chart is now merged into
784/// [`runs_page`], rendering inline above the run-history table at `/runs/<corpus>/<service>`, so
785/// this route redirects there — keeping old links, bookmarks, and nav entries working.
786#[get("/history/<corpus>/<service>")]
787pub fn history_page(corpus: &str, service: &str) -> Redirect {
788  // corpus/service are cortex slugs (no spaces or path separators — `serve_entry` even rejects the
789  // latter), so a plain path interpolation is a safe, well-formed redirect target.
790  Redirect::to(format!("/runs/{corpus}/{service}"))
791}
792
793/// The route set for the historical-runs capability.
794pub fn routes() -> Vec<Route> {
795  // NB: the `api_run*` routes (incl. `api_all_runs`) are mounted via `frontend::apidoc`
796  // (rocket_okapi).
797  routes![
798    all_runs_page,
799    runs_tasks_page,
800    runs_diff_page,
801    runs_page,
802    history_page
803  ]
804}
805
806#[cfg(test)]
807mod tests {
808  use super::delta_class;
809
810  #[test]
811  fn delta_class_polarity() {
812    // no_problem: more clean conversions is an improvement.
813    assert_eq!(delta_class(26, true), "delta-good");
814    assert_eq!(delta_class(-5, true), "delta-bad");
815    // error/fatal/warning: fewer problems is an improvement.
816    assert_eq!(delta_class(3, false), "delta-bad");
817    assert_eq!(delta_class(-3, false), "delta-good");
818    // unchanged columns are muted, regardless of polarity.
819    assert_eq!(delta_class(0, true), "delta-zero");
820    assert_eq!(delta_class(0, false), "delta-zero");
821  }
822}