cortex/frontend/retention.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-data **retention** — managing the one unbounded-growth table, `historical_tasks` (one
9//! per-task status snapshot per save-snapshot). The admin screen surfaces the snapshot count + the
10//! oldest snapshot, lets the admin pick a cutoff date, shows a **dry-run count** of exactly what a
11//! prune would remove, and only then offers a confirmed delete (gated + audited — same safety
12//! pattern as `delete_corpus`). The run *summaries* (`historical_runs`) are never touched, so the
13//! run history and charts survive; only the bulky per-task snapshots age out.
14
15use chrono::{NaiveDate, NaiveDateTime};
16use rocket::form::Form;
17use rocket::http::Status;
18use rocket::response::Redirect;
19use rocket::serde::json::Json;
20use rocket::{Route, State};
21use rocket_dyn_templates::{Template, context};
22use serde::Serialize;
23
24use crate::backend::DbPool;
25use crate::frontend::actor::{Actor, AdminReject, AdminSession, ReturnTo, require_admin_to};
26use crate::models::HistoricalTask;
27
28/// Parses a `YYYY-MM-DD` cutoff to the start of that day (midnight); `None` on a malformed date.
29fn parse_cutoff(date: &str) -> Option<NaiveDateTime> {
30 NaiveDate::parse_from_str(date, "%Y-%m-%d")
31 .ok()?
32 .and_hms_opt(0, 0, 0)
33}
34
35/// Formats an optional snapshot timestamp for display.
36fn fmt_oldest(oldest: Option<NaiveDateTime>) -> String {
37 oldest.map_or_else(|| "none".to_string(), crate::frontend::helpers::iso_utc)
38}
39
40/// Per-task snapshot retention stats, as exposed over the API/UI.
41#[derive(Debug, Serialize, schemars::JsonSchema)]
42pub struct HistoricalStatsDto {
43 /// Total per-task snapshot rows (`historical_tasks`) — the unbounded-growth table.
44 pub snapshot_rows: i64,
45 /// The oldest snapshot's timestamp, formatted (`none` if there are no snapshots).
46 pub oldest: String,
47}
48
49/// The per-task snapshot retention stats (agent twin of the `/admin/retention` screen). **Token-
50/// gated.** `503` if the pool is exhausted.
51#[rocket_okapi::openapi(tag = "Management")]
52#[get("/api/historical/stats")]
53pub fn api_historical_stats(
54 _caller: Actor,
55 pool: &State<DbPool>,
56) -> Result<Json<HistoricalStatsDto>, Status> {
57 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
58 let (total, oldest) =
59 HistoricalTask::retention_stats(&mut connection).map_err(|_| Status::InternalServerError)?;
60 Ok(Json(HistoricalStatsDto {
61 snapshot_rows: total,
62 oldest: fmt_oldest(oldest),
63 }))
64}
65
66/// The data-retention screen (`GET /admin/retention?<before>&<pruned>`): snapshot stats; with
67/// `?before=YYYY-MM-DD`, a **dry-run** count of how many snapshots that cutoff would prune (the
68/// page then offers a confirmed delete). `?pruned=N` flashes the result of a completed prune.
69/// Signed-in admins only (unauthenticated → sign-in, returning here).
70#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
71#[get("/admin/retention?<before>&<pruned>")]
72pub fn retention_page(
73 before: Option<String>,
74 pruned: Option<i64>,
75 session: Option<AdminSession>,
76 return_to: ReturnTo,
77 pool: &State<DbPool>,
78) -> Result<Template, AdminReject> {
79 let admin = require_admin_to(session, &return_to)?;
80 let mut snapshot_rows = 0i64;
81 let mut oldest = "none".to_string();
82 let mut preview: Option<serde_json::Value> = None;
83 if let Ok(mut connection) = pool.get() {
84 if let Ok((total, old)) = HistoricalTask::retention_stats(&mut connection) {
85 snapshot_rows = total;
86 oldest = fmt_oldest(old);
87 }
88 // A dry-run preview only when the cutoff parses (a bad date shows no preview, never deletes).
89 if let Some(cutoff) = before.as_deref().and_then(parse_cutoff) {
90 let count = HistoricalTask::count_before(&mut connection, cutoff).unwrap_or(0);
91 preview = Some(serde_json::json!({ "before": before, "count": count }));
92 }
93 }
94 let global = serde_json::json!({
95 "title": "Historical data retention",
96 "description": "Prune old per-task status snapshots",
97 });
98 Ok(Template::render(
99 "retention",
100 context! { global, owner: admin.owner, snapshot_rows, oldest, preview, pruned },
101 ))
102}
103
104/// The prune form: the cutoff date the preview was shown for.
105#[derive(FromForm)]
106pub struct PruneForm {
107 /// `YYYY-MM-DD` — snapshots strictly older than midnight of this day are pruned.
108 pub before: String,
109}
110
111/// Prunes per-task snapshots older than the cutoff (`POST /admin/retention/prune`). Confirmed by
112/// the two-step preview + the form's `confirm()` dialog; **gated** (AdminSession) and **audited**
113/// (the audit fairing records the action + actor). Only `historical_tasks` is touched — run
114/// summaries survive. Redirects back to the screen with the count removed; a malformed cutoff is a
115/// no-op.
116#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
117#[post("/admin/retention/prune", data = "<form>")]
118pub fn prune(
119 form: Form<PruneForm>,
120 session: Option<AdminSession>,
121 return_to: ReturnTo,
122 pool: &State<DbPool>,
123) -> Result<Redirect, AdminReject> {
124 let admin = require_admin_to(session, &return_to)?;
125 let Some(cutoff) = parse_cutoff(&form.before) else {
126 return Ok(Redirect::to("/admin/retention"));
127 };
128 let pruned = pool
129 .get()
130 .ok()
131 .and_then(|mut connection| HistoricalTask::prune_before(&mut connection, cutoff).ok())
132 .unwrap_or(0);
133 // Server-side record of who pruned what (the audit fairing also logs the action + actor +
134 // outcome to the DB; this is the operational journal line).
135 tracing::info!(
136 actor = %admin.owner,
137 pruned,
138 before = %form.before,
139 "retention prune"
140 );
141 Ok(Redirect::to(format!("/admin/retention?pruned={pruned}")))
142}
143
144// NOTE (history immutability, owner directive 2026-06-15): the historical tables are append-only as
145// far as the **agent API** is concerned — there is deliberately NO `/api/...` endpoint that deletes
146// or modifies `historical_tasks` / `historical_runs`. Pruning the unbounded-growth snapshot table
147// is a gated, audited, confirm-dialog **human admin** operation only (`prune`, above). History
148// stays immutable and reliable for every programmatic consumer.
149
150/// The human retention screen + prune (the agent `api_historical_stats` read twin is mounted via
151/// `frontend::apidoc`; there is intentionally no agent mutation twin — see the note above).
152pub fn routes() -> Vec<Route> { routes![retention_page, prune] }