Skip to main content

bhtune_server/routes/
history.rs

1//! Run-history routes: `GET /api/runs` (filtered, paginated list), `GET /api/runs/{id}`
2//! (full run detail: config, initial readings, samples, results, writes), `GET
3//! /api/runs/{id}/export` (CSV/JSON sample export), and `DELETE /api/runs/{id}`.
4//!
5//! DTO shapes deliberately mirror the `bhtune` CLI's `commands::history` `--output json` JSON
6//! (`RunSummaryJson`/`RunDetailJson`/etc.) field-for-field, so the CLI and the HTTP API
7//! describe the same run the same way -- one shape for the product's two faces, per this
8//! workspace's DTO-decoupling convention (every JSON-facing consumer builds its own
9//! projection of the non-`Serialize` `bhtune-db` row types, rather than the row types
10//! themselves growing a `Serialize` impl). The one deliberate addition over the CLI's own
11//! `RunDetailJson` is a full `samples` array (not just a `samples_recorded` count) -- the
12//! trend chart (`history-explorer-ui`) needs the raw per-tick data, and the data-volume math
13//! in AGENTS.md's "History explorer" section (thousands of rows per run, not millions) says
14//! inlining it is cheap enough not to need its own paginated route.
15
16use axum::extract::{Path, Query, State};
17use axum::http::{HeaderValue, StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use axum::routing::get;
20use axum::{Json, Router};
21use bhtune_core::{
22    ControllerDirection, ControllerPidValues, ControllerType, DcsTemplate, LoopConfig, PidRounding,
23    ProcessType, ResponseLevel, Tick, TuningResultInvalidReason, TuningResultStatus,
24};
25use bhtune_db::models::{
26    MvActuationKind, MvActuationStatus, Pagination, RestoreStatus, RollbackState, SampleQuality,
27    TemplateOrigin, TimingMetrics, TuneDriver, TuneMvActuationRow, TuneOutcome, TuneRecoveryState,
28    TuneResultRow, TuneRunFilter, TuneRunRow, TuneSampleRow, TuneWriteRow, WriteKind,
29};
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use std::future::Future;
33use utoipa::{IntoParams, ToSchema};
34
35use crate::error::{ApiError, ErrorBody};
36use crate::routes::runs::StartRunRequest;
37use crate::state::AppState;
38
39/// Query parameters for `GET /api/runs`, mirroring [`TuneRunFilter`]'s fields one-to-one
40/// plus [`Pagination`]. Every field is optional; an absent `limit`/`offset` falls back to
41/// [`Pagination::default`] (50 rows, offset 0), matching the CLI's own default page size.
42#[derive(Debug, Deserialize, IntoParams)]
43#[into_params(parameter_in = Query)]
44pub struct RunListQuery {
45    pub loop_id: Option<i64>,
46    pub process_type: Option<ProcessType>,
47    pub controller_type: Option<ControllerType>,
48    pub outcome: Option<TuneOutcome>,
49    pub driver: Option<TuneDriver>,
50    pub started_after: Option<DateTime<Utc>>,
51    pub started_before: Option<DateTime<Utc>>,
52    pub template_name: Option<String>,
53    pub template_origin: Option<TemplateOrigin>,
54    /// Filters on the run's recorded, *resolved* OPC server (`db-run-request-snapshot`) --
55    /// always absent for a simulator/replay run, so this filter alone never matches one.
56    pub opc_server: Option<String>,
57    /// Filters on the run's recorded, resolved bridge host, matching `opc_server` above.
58    pub bridge_host: Option<String>,
59    pub limit: Option<i64>,
60    pub offset: Option<i64>,
61}
62
63pub(crate) fn filter_from_query(query: &RunListQuery) -> TuneRunFilter {
64    let mut filter = TuneRunFilter::default();
65    if let Some(v) = query.loop_id {
66        filter = filter.with_loop_id(v);
67    }
68    if let Some(v) = query.process_type {
69        filter = filter.with_process_type(v);
70    }
71    if let Some(v) = query.controller_type {
72        filter = filter.with_controller_type(v);
73    }
74    if let Some(v) = query.outcome {
75        filter = filter.with_outcome(v);
76    }
77    if let Some(v) = query.driver {
78        filter = filter.with_driver(v);
79    }
80    if let Some(v) = query.started_after {
81        filter = filter.with_started_after(v);
82    }
83    if let Some(v) = query.started_before {
84        filter = filter.with_started_before(v);
85    }
86    if let Some(v) = &query.template_name {
87        filter = filter.with_template_name(v.clone());
88    }
89    if let Some(v) = query.template_origin {
90        filter = filter.with_template_origin(v);
91    }
92    if let Some(v) = &query.opc_server {
93        filter = filter.with_opc_server(v.clone());
94    }
95    if let Some(v) = &query.bridge_host {
96        filter = filter.with_bridge_host(v.clone());
97    }
98    filter
99}
100
101/// One run in `GET /api/runs`'s `runs` array -- deliberately a subset matching the CLI's own
102/// `history list` table columns, not the full detail (that's [`RunDetailResponse`], for
103/// `GET /api/runs/{id}`).
104#[derive(Debug, Serialize, ToSchema)]
105pub struct RunSummaryResponse {
106    pub id: i64,
107    pub tag_name: String,
108    pub notes: Option<String>,
109    pub driver: TuneDriver,
110    pub outcome: TuneOutcome,
111    pub process_type: ProcessType,
112    pub started_at: DateTime<Utc>,
113}
114
115impl From<&TuneRunRow> for RunSummaryResponse {
116    fn from(run: &TuneRunRow) -> Self {
117        RunSummaryResponse {
118            id: run.id,
119            tag_name: run.loop_name.clone(),
120            notes: run.notes.clone(),
121            driver: run.driver,
122            outcome: run.outcome,
123            process_type: run.config.process_type,
124            started_at: run.started_at,
125        }
126    }
127}
128
129#[derive(Debug, Serialize, ToSchema)]
130pub struct RunListResponse {
131    pub runs: Vec<RunSummaryResponse>,
132    /// How many rows are in `runs` (this page) -- distinct from `total`, the count of every
133    /// run matching the filter across all pages.
134    pub returned: usize,
135    pub total: i64,
136}
137
138/// List tune runs, filtered and paginated.
139///
140/// `GET /api/runs` -- newest-started-first, filtered by every present [`RunListQuery`] field,
141/// one [`Pagination`] page at a time.
142#[utoipa::path(
143    get,
144    path = "/api/runs",
145    tag = "runs",
146    params(RunListQuery),
147    responses(
148        (status = 200, description = "A page of runs matching the filter.", body = RunListResponse),
149    ),
150)]
151pub(crate) async fn list_runs(
152    State(state): State<AppState>,
153    Query(query): Query<RunListQuery>,
154) -> Result<Json<RunListResponse>, ApiError> {
155    let filter = filter_from_query(&query);
156    let pagination = Pagination::new(
157        query.limit.unwrap_or_else(|| Pagination::default().limit),
158        query.offset.unwrap_or(0),
159    );
160    let runs = TuneRunRow::list(&state.pool, &filter, pagination).await?;
161    let total = TuneRunRow::count(&state.pool, &filter).await?;
162    Ok(Json(RunListResponse {
163        returned: runs.len(),
164        runs: runs.iter().map(RunSummaryResponse::from).collect(),
165        total,
166    }))
167}
168
169/// The most recently started run's original request, if any.
170///
171/// `GET /api/runs/last-request` -- returns the newest run's `request_json`
172/// (`db-run-request-snapshot`), parsed back into a [`StartRunRequest`], or `null` on a fresh
173/// install with no runs yet, or if the newest run's stored request isn't usable (see
174/// [`parse_stored_request`]) (`ui-prefill-last-run`). The New tune form uses this only as a
175/// one-time fallback when no mutable `/api/runs/draft` exists, which lets older installations
176/// upgrade without losing their previous run settings while keeping future edits independent of
177/// immutable history. "Newest" means newest by `started_at`, matching `GET /api/runs`'s own
178/// ordering, regardless of that run's `outcome`.
179#[utoipa::path(
180    get,
181    path = "/api/runs/last-request",
182    tag = "runs",
183    responses(
184        (status = 200, description = "The newest run's original request, or `null` if no runs exist yet or its request isn't usable.", body = Option<StartRunRequest>),
185    ),
186)]
187pub(crate) async fn last_request(
188    State(state): State<AppState>,
189) -> Result<Json<Option<StartRunRequest>>, ApiError> {
190    let newest =
191        TuneRunRow::list(&state.pool, &TuneRunFilter::default(), Pagination::first(1)).await?;
192    let Some(run) = newest.into_iter().next() else {
193        return Ok(Json(None));
194    };
195    Ok(Json(parse_stored_request(run.id, &run.request_json)))
196}
197
198/// Parses a run's stored `request_json` back into a [`StartRunRequest`], or `None` if it
199/// isn't usable -- shared by [`last_request`] and [`build_run_detail`]'s `original_request`
200/// field, both of which exist to *prefill a form*, not to guarantee every historical row is
201/// well-formed. A row created by `prepare()` (every real CLI- or HTTP-started run) always
202/// parses; this only fails for a row that predates `db-run-request-snapshot`, or one poked
203/// at directly through the SQLite file itself -- a supported way to interact with bhtune's
204/// data, per this project's "just an open SQLite db, nothing hidden" design goal. Either way,
205/// the honest response is "nothing to prefill from", logged at `warn` so the data quality
206/// issue is visible without failing the request a real user is waiting on.
207pub(crate) fn parse_stored_request(run_id: i64, request_json: &str) -> Option<StartRunRequest> {
208    match serde_json::from_str(request_json) {
209        Ok(request) => Some(request),
210        Err(e) => {
211            tracing::warn!(
212                run_id,
213                error = %e,
214                "run's stored request_json did not parse as StartRunRequest; treating as unavailable"
215            );
216            None
217        }
218    }
219}
220
221/// Local projection of [`bhtune_db::models::TuneRunInitialReadings`] -- see this module's
222/// doc comment for why every JSON-facing type here is its own projection rather than a
223/// `Serialize` impl on the `bhtune-db` row type.
224#[derive(Debug, Serialize, ToSchema)]
225pub struct InitialReadingsResponse {
226    pub pv_ini: f32,
227    pub mv_ini: f32,
228    pub mv_range_low: f32,
229    pub mv_range_high: f32,
230    pub pv_range_high: f32,
231    pub pv_range_low: f32,
232    pub controller_direction: ControllerDirection,
233    pub mode_raw: Option<String>,
234    pub mode_attribute_raw: Option<String>,
235    pub setpoint_ini: Option<f32>,
236}
237
238impl From<bhtune_db::models::TuneRunInitialReadings> for InitialReadingsResponse {
239    fn from(r: bhtune_db::models::TuneRunInitialReadings) -> Self {
240        InitialReadingsResponse {
241            pv_ini: r.pv_ini,
242            mv_ini: r.mv_ini,
243            mv_range_low: r.mv_range_low,
244            mv_range_high: r.mv_range_high,
245            pv_range_high: r.pv_range_high,
246            pv_range_low: r.pv_range_low,
247            controller_direction: r.controller_direction,
248            mode_raw: r.mode_raw,
249            mode_attribute_raw: r.mode_attribute_raw,
250            setpoint_ini: r.setpoint_ini,
251        }
252    }
253}
254
255/// One recorded tick: the [`Tick`] input and resulting engine state, plus the driver-
256/// reported PV quality at read time. `Tick`/`MrftState` already derive `Serialize` in
257/// `bhtune-core` (they round-trip through golden-trace fixtures too), so they're embedded
258/// directly rather than re-projected field-by-field like the other DTOs here.
259#[derive(Debug, Serialize, ToSchema)]
260pub struct SampleResponse {
261    pub tick_index: i64,
262    pub sample: Tick,
263    pub state: bhtune_core::MrftState,
264    pub pv_quality: SampleQuality,
265}
266
267impl From<&TuneSampleRow> for SampleResponse {
268    fn from(row: &TuneSampleRow) -> Self {
269        SampleResponse {
270            tick_index: row.tick_index,
271            sample: row.sample,
272            state: row.state,
273            pv_quality: row.pv_quality,
274        }
275    }
276}
277
278/// Local projection of [`TuneResultRow`].
279#[derive(Debug, Serialize, ToSchema)]
280pub struct ResultResponse {
281    pub response_level: ResponseLevel,
282    pub kp: Option<f32>,
283    pub ti_minutes: Option<f32>,
284    pub td_minutes: Option<f32>,
285    pub proportional: Option<f32>,
286    pub integral: Option<f32>,
287    pub derivative: Option<f32>,
288    pub status: TuningResultStatus,
289    pub invalid_reason: Option<TuningResultInvalidReason>,
290    /// Controller-ready final-unit values and canonical text; raw fields remain unchanged.
291    pub controller_values: Option<ControllerPidValues>,
292    /// Why no safe controller target exists, including an active term rounded to zero.
293    pub controller_target_error: Option<String>,
294}
295
296impl ResultResponse {
297    pub(super) fn from_recorded(r: &TuneResultRow, run: &TuneRunRow) -> Self {
298        let preview =
299            bhtune_runtime::tune::pid_write_preview(r, run.config.controller_type, &run.template);
300        ResultResponse {
301            response_level: r.response_level,
302            kp: r.kp,
303            ti_minutes: r.ti_minutes,
304            td_minutes: r.td_minutes,
305            proportional: r.proportional,
306            integral: r.integral,
307            derivative: r.derivative,
308            status: r.status,
309            invalid_reason: r.invalid_reason,
310            controller_values: preview.values,
311            controller_target_error: preview.unavailable_reason,
312        }
313    }
314}
315
316/// Local projection of [`TuneWriteRow`].
317#[derive(Debug, Serialize, ToSchema)]
318pub struct WriteResponse {
319    pub kind: WriteKind,
320    pub response_level: ResponseLevel,
321    pub allow_uncertain_quality: bool,
322    pub written_at: DateTime<Utc>,
323    pub proportional_previous: Option<f32>,
324    pub integral_previous: Option<f32>,
325    pub derivative_previous: Option<f32>,
326    pub proportional_written: Option<f32>,
327    pub integral_written: Option<f32>,
328    pub derivative_written: Option<f32>,
329    pub proportional_readback: Option<f32>,
330    pub integral_readback: Option<f32>,
331    pub derivative_readback: Option<f32>,
332    pub success: bool,
333    pub error_message: Option<String>,
334    pub rollback_state: Option<RollbackState>,
335    pub rollback_error: Option<String>,
336}
337
338impl From<&TuneWriteRow> for WriteResponse {
339    fn from(w: &TuneWriteRow) -> Self {
340        WriteResponse {
341            kind: w.kind,
342            response_level: w.response_level,
343            allow_uncertain_quality: w.allow_uncertain_quality,
344            written_at: w.written_at,
345            proportional_previous: w.previous.map(|p| p.proportional),
346            integral_previous: w.previous.map(|p| p.integral),
347            derivative_previous: w.previous.map(|p| p.derivative),
348            proportional_written: w.proportional_written,
349            integral_written: w.integral_written,
350            derivative_written: w.derivative_written,
351            proportional_readback: w.proportional_readback,
352            integral_readback: w.integral_readback,
353            derivative_readback: w.derivative_readback,
354            success: w.success,
355            error_message: w.error_message.clone(),
356            rollback_state: w.rollback_state,
357            rollback_error: w.rollback_error.clone(),
358        }
359    }
360}
361
362/// Local projection of one accepted OPC DA manipulated-variable command and its independent
363/// live readback evidence. Commanded MV samples remain in [`SampleResponse`]; this audit trail
364/// is the only response surface that reports measured MV values.
365#[derive(Debug, Serialize, ToSchema)]
366pub struct MvActuationResponse {
367    pub id: i64,
368    pub sequence: i64,
369    pub kind: MvActuationKind,
370    pub commanded_at: DateTime<Utc>,
371    pub target_mv: f32,
372    pub previous_commanded_mv: Option<f32>,
373    pub tolerance: f32,
374    pub confirmation_due_at: DateTime<Utc>,
375    pub last_checked_at: Option<DateTime<Utc>>,
376    pub readback_mv: Option<f32>,
377    pub readback_quality: Option<SampleQuality>,
378    pub attempt_count: i64,
379    pub status: MvActuationStatus,
380    pub detail: Option<String>,
381}
382
383impl From<&TuneMvActuationRow> for MvActuationResponse {
384    fn from(row: &TuneMvActuationRow) -> Self {
385        Self {
386            id: row.id,
387            sequence: row.sequence,
388            kind: row.kind,
389            commanded_at: row.commanded_at,
390            target_mv: row.target_mv,
391            previous_commanded_mv: row.previous_commanded_mv,
392            tolerance: row.tolerance,
393            confirmation_due_at: row.confirmation_due_at,
394            last_checked_at: row.last_checked_at,
395            readback_mv: row.readback_mv,
396            readback_quality: row.readback_quality,
397            attempt_count: row.attempt_count,
398            status: row.status,
399            detail: row.detail.clone(),
400        }
401    }
402}
403
404/// A run's snapshotted PID constant tag names, present only when all three were configured.
405/// Nested under `RunDetailResponse::pid_constant_tags` following the same
406/// "`Option<...>` presence itself is the signal" convention `initial_readings` already uses,
407/// rather than a separate boolean plus three more nullable top-level fields.
408#[derive(Debug, Serialize, ToSchema)]
409pub struct PidConstantTagsResponse {
410    pub proportional: String,
411    pub integral: String,
412    pub derivative: String,
413}
414
415/// The operator-facing names for the three calculated PID constants, derived from the
416/// template snapshot stored on the run rather than the mutable template catalog.
417#[derive(Debug, Serialize, ToSchema)]
418pub struct PidParameterLabelsResponse {
419    pub proportional: String,
420    pub integral: String,
421    pub derivative: String,
422}
423
424impl From<&DcsTemplate> for PidParameterLabelsResponse {
425    fn from(template: &DcsTemplate) -> Self {
426        Self {
427            proportional: pid_parameter_label(&template.proportional_constant_suffix, "P"),
428            integral: pid_parameter_label(&template.integral_constant_suffix, "I"),
429            derivative: pid_parameter_label(&template.derivative_constant_suffix, "D"),
430        }
431    }
432}
433
434fn pid_parameter_label(suffix: &str, fallback: &str) -> String {
435    if suffix.is_empty() {
436        fallback.to_owned()
437    } else {
438        suffix.to_owned()
439    }
440}
441
442#[derive(Debug, Serialize, ToSchema)]
443pub struct RunDetailResponse {
444    pub id: i64,
445    pub tag_name: String,
446    pub notes: Option<String>,
447    pub driver: TuneDriver,
448    pub outcome: TuneOutcome,
449    pub failure_reason: Option<String>,
450    pub started_at: DateTime<Utc>,
451    pub completed_at: Option<DateTime<Utc>>,
452    /// Name of the template snapshotted onto this run at start time -- not necessarily what
453    /// `template_name` currently resolves to in the catalog (`safety-run-snapshot`).
454    pub template_name: String,
455    pub template_origin: TemplateOrigin,
456    /// Whether this run accepted `Uncertain` OPC quality, captured when the run started.
457    pub allow_uncertain_quality: bool,
458    pub config: LoopConfig,
459    /// Precision policy from the run's immutable template snapshot.
460    pub pid_rounding: PidRounding,
461    /// Concrete global timing and safety values frozen when this run was prepared. `None`
462    /// identifies a run created before effective-tuning snapshots were stored.
463    pub effective_tuning: Option<bhtune_db::models::EffectiveTuning>,
464    /// The resolved OPC DA server ProgID this run actually used, or `None` for a
465    /// simulator/replay run (`db-run-request-snapshot`). This is what `history revert`
466    /// trusts over any `--server` flag -- see `bhtune_runtime::history::revert_run`.
467    pub opc_server: Option<String>,
468    /// The resolved bridge host this run actually used, matching `opc_server` above.
469    pub bridge_host: Option<String>,
470    /// `Some` exactly when `routes::runs::require_writable_run`'s tag-presence check would
471    /// pass -- i.e. when all three PID constant tags were configured on this run. The
472    /// frontend uses this (together with `driver`/`outcome`/`opc_server`/`bridge_host`) to
473    /// decide whether the post-hoc write/revert buttons are enabled and, when they are not,
474    /// to explain why -- without duplicating `require_writable_run`'s logic client-side or
475    /// discovering ineligibility only after a failed request (`api-post-run-write`).
476    pub pid_constant_tags: Option<PidConstantTagsResponse>,
477    /// Operator-facing calculated-result column labels from the run's historical template
478    /// snapshot. Empty user-template suffixes use the conventional P/I/D labels.
479    pub pid_parameter_labels: PidParameterLabelsResponse,
480    pub initial_readings: Option<InitialReadingsResponse>,
481    pub timing_metrics: Option<TimingMetrics>,
482    pub samples: Vec<SampleResponse>,
483    pub results: Vec<ResultResponse>,
484    pub writes: Vec<WriteResponse>,
485    pub mv_actuations: Vec<MvActuationResponse>,
486    pub restore_status: Option<RestoreStatus>,
487    pub restore_detail: Option<String>,
488    /// This run's own `request_json` (`db-run-request-snapshot`), parsed back into a
489    /// [`StartRunRequest`], or `None` if it isn't usable -- see `parse_stored_request`.
490    /// Powers the run detail page's "Duplicate this run" action (`ui-prefill-last-run`):
491    /// unlike `GET /api/runs/last-request`, which only ever answers for the single newest
492    /// run, this lets the New tune form seed itself from *this specific* historical run
493    /// regardless of how many later runs exist.
494    pub original_request: Option<StartRunRequest>,
495    /// Gateway protocol snapshot recorded before this run's live mutation, when the run used
496    /// OPC DA. `None` for simulator/replay runs and for OPC runs that predate the snapshot.
497    pub gateway_compatibility: Option<crate::routes::opc::GatewayCompatibilityResponse>,
498}
499
500/// Builds the full `RunDetailResponse` for one run, or `Ok(None)` if no run has that id --
501/// shared by `show_run` (`GET /api/runs/{id}`, which maps `None` to a 404) and
502/// `routes::runs::start_run` (`POST /api/runs`'s `201` body is the very same detail view of
503/// the run it just created, so both routes describe a run identically rather than the HTTP
504/// API growing two different shapes for "what a run looks like").
505pub(crate) async fn build_run_detail(
506    pool: &bhtune_db::SqlitePool,
507    run_id: i64,
508) -> Result<Option<RunDetailResponse>, ApiError> {
509    let Some(run) = TuneRunRow::get(pool, run_id).await? else {
510        return Ok(None);
511    };
512    let samples = TuneSampleRow::list_for_run(pool, run_id).await?;
513    let results = TuneResultRow::list_for_run(pool, run_id).await?;
514    let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
515    let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
516    let pid_constant_tags = match (
517        &run.tags.proportional_constant,
518        &run.tags.integral_constant,
519        &run.tags.derivative_constant,
520    ) {
521        (Some(proportional), Some(integral), Some(derivative)) => Some(PidConstantTagsResponse {
522            proportional: proportional.clone(),
523            integral: integral.clone(),
524            derivative: derivative.clone(),
525        }),
526        _ => None,
527    };
528    let pid_parameter_labels = PidParameterLabelsResponse::from(&run.template);
529    let results = results
530        .iter()
531        .map(|result| ResultResponse::from_recorded(result, &run))
532        .collect();
533
534    Ok(Some(RunDetailResponse {
535        id: run.id,
536        tag_name: run.loop_name,
537        notes: run.notes,
538        driver: run.driver,
539        outcome: run.outcome,
540        failure_reason: run.failure_reason,
541        started_at: run.started_at,
542        completed_at: run.completed_at,
543        template_name: run.template.name,
544        template_origin: run.template_origin,
545        allow_uncertain_quality: run.allow_uncertain_quality,
546        config: run.config,
547        pid_rounding: run.template.pid_rounding,
548        effective_tuning: run.effective_tuning,
549        opc_server: run.opc_server,
550        bridge_host: run.bridge_host,
551        pid_constant_tags,
552        pid_parameter_labels,
553        initial_readings: run.initial_readings.map(InitialReadingsResponse::from),
554        timing_metrics: run.timing_metrics,
555        samples: samples.iter().map(SampleResponse::from).collect(),
556        results,
557        writes: writes.iter().map(WriteResponse::from).collect(),
558        mv_actuations: mv_actuations
559            .iter()
560            .map(MvActuationResponse::from)
561            .collect(),
562        restore_status: run.restore_status,
563        restore_detail: run.restore_detail,
564        original_request: parse_stored_request(run.id, &run.request_json),
565        gateway_compatibility: run
566            .gateway_compatibility_json
567            .as_deref()
568            .map(bhtune_runtime::gateway::parse_stored_compatibility)
569            .transpose()?
570            .map(crate::routes::opc::GatewayCompatibilityResponse::from),
571    }))
572}
573
574/// Fetch one run's full detail.
575///
576/// `GET /api/runs/{id}` -- 404 if no run has that id.
577#[utoipa::path(
578    get,
579    path = "/api/runs/{id}",
580    tag = "runs",
581    params(
582        ("id" = i64, Path, description = "Run id"),
583    ),
584    responses(
585        (status = 200, description = "The full recorded detail for one run.", body = RunDetailResponse),
586        (status = 404, description = "No run with that id.", body = ErrorBody),
587    ),
588)]
589pub(crate) async fn show_run(
590    State(state): State<AppState>,
591    Path(run_id): Path<i64>,
592) -> Result<Json<RunDetailResponse>, ApiError> {
593    build_run_detail(&state.pool, run_id)
594        .await?
595        .map(Json)
596        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
597}
598
599/// Format for `GET /api/runs/{id}/export` -- deliberately a local, HTTP-facing enum rather
600/// than reusing the runtime's internal
601/// [`bhtune_runtime::export::SampleExportFormat`] directly, matching this module's
602/// DTO-decoupling convention (see the module doc comment). Converted at the call site so
603/// the shared runtime owns CSV/JSON serialization while this type remains specific to the
604/// HTTP query and its OpenAPI schema.
605#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
606#[serde(rename_all = "snake_case")]
607pub enum RunExportFormat {
608    Csv,
609    Json,
610}
611
612impl From<RunExportFormat> for bhtune_runtime::export::SampleExportFormat {
613    fn from(format: RunExportFormat) -> Self {
614        match format {
615            RunExportFormat::Csv => bhtune_runtime::export::SampleExportFormat::Csv,
616            RunExportFormat::Json => bhtune_runtime::export::SampleExportFormat::Json,
617        }
618    }
619}
620
621#[derive(Debug, Deserialize, IntoParams)]
622#[into_params(parameter_in = Query)]
623pub struct RunExportQuery {
624    /// Defaults to `csv` when omitted, matching `bhtune export`'s own CLI default.
625    pub format: Option<RunExportFormat>,
626}
627
628/// Export one run's recorded samples as CSV or JSON.
629///
630/// `GET /api/runs/{id}/export?format=csv|json` -- 404 if no run has that id or it has no
631/// recorded samples yet. Defaults to CSV. Sets `Content-Disposition: attachment` so a
632/// browser downloads the response as a file rather than rendering it.
633#[utoipa::path(
634    get,
635    path = "/api/runs/{id}/export",
636    tag = "runs",
637    params(
638        ("id" = i64, Path, description = "Run id"),
639        RunExportQuery,
640    ),
641    responses(
642        (status = 200, description = "The run's recorded samples, as CSV (default) or JSON.", content_type = "text/csv"),
643        (status = 404, description = "No run with that id, or it has no recorded samples.", body = ErrorBody),
644    ),
645)]
646pub(crate) async fn export_run(
647    State(state): State<AppState>,
648    Path(run_id): Path<i64>,
649    Query(query): Query<RunExportQuery>,
650) -> Result<Response, ApiError> {
651    let format = query.format.unwrap_or(RunExportFormat::Csv);
652    let samples = TuneSampleRow::list_for_run(&state.pool, run_id).await?;
653    if samples.is_empty() {
654        return Err(ApiError::NotFound(format!(
655            "run {run_id} has no recorded samples (unknown run id, or it never started)"
656        )));
657    }
658    let bytes = bhtune_runtime::export::samples_to_bytes(&samples, format.into())?;
659    let (content_type, extension) = match format {
660        RunExportFormat::Csv => ("text/csv", "csv"),
661        RunExportFormat::Json => ("application/json", "json"),
662    };
663    let mut response = bytes.into_response();
664    let headers = response.headers_mut();
665    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
666    headers.insert(
667        header::CONTENT_DISPOSITION,
668        HeaderValue::from_str(&format!(
669            "attachment; filename=\"run-{run_id}.{extension}\""
670        ))
671        .map_err(|e| ApiError::Internal(e.into()))?,
672    );
673    Ok(response)
674}
675
676/// Delete one run and its recorded samples/results/write-back audit rows.
677///
678/// `DELETE /api/runs/{id}` -- 404 if no run has that id, 409 if the run's own recorded
679/// `outcome` is still [`TuneOutcome::Running`] (deleting the row out from under an in-flight
680/// task would corrupt whatever it tries to write next; cancel it first). Deliberately checks
681/// the run row's own `outcome` rather than [`crate::active_run::ActiveRun`]'s in-memory
682/// active-run registry entry: `drive()` persists every terminal outcome (`persist_results` then
683/// `TuneRunRow::complete`/`fail`/`abort`) *before* returning, and `ActiveRun::release` only
684/// runs strictly after `drive()` returns (see `routes::runs::start_run`'s spawned task), so
685/// there is a real -- if brief -- window where a run's outcome is already durably
686/// `completed`/`failed`/`aborted` but `ActiveRun` hasn't been told the registry entry is free yet.
687/// Checking the DB's own authoritative, durable state instead of the best-effort in-memory
688/// tracker closes that race outright, rather than requiring the caller to retry (as
689/// `frontend/e2e/tune.spec.ts`'s `startTune()` already has to for the equivalent gap on the
690/// *start* side).
691#[utoipa::path(
692    delete,
693    path = "/api/runs/{id}",
694    tag = "runs",
695    params(
696        ("id" = i64, Path, description = "Run id"),
697    ),
698    responses(
699        (status = 204, description = "Run deleted."),
700        (status = 404, description = "No run with that id.", body = ErrorBody),
701        (status = 409, description = "The run is active or has unresolved live recovery.", body = ErrorBody),
702    ),
703)]
704pub(crate) async fn delete_run(
705    State(state): State<AppState>,
706    Path(run_id): Path<i64>,
707) -> Result<StatusCode, ApiError> {
708    delete_run_with_hook(state, run_id, |_| async {}).await
709}
710
711async fn delete_run_with_hook<F, Fut>(
712    state: AppState,
713    run_id: i64,
714    after_lookup: F,
715) -> Result<StatusCode, ApiError>
716where
717    F: FnOnce(&AppState) -> Fut,
718    Fut: Future<Output = ()>,
719{
720    let run = TuneRunRow::get(&state.pool, run_id)
721        .await?
722        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
723    if run.outcome == TuneOutcome::Running {
724        return Err(ApiError::Conflict(format!(
725            "run {run_id} has not finished yet; cancel it before deleting"
726        )));
727    }
728    if recovery_blocks_deletion(run.recovery_state) {
729        return Err(recovery_delete_conflict(run_id));
730    }
731    after_lookup(&state).await;
732    if TuneRunRow::delete(&state.pool, run_id).await? {
733        Ok(StatusCode::NO_CONTENT)
734    } else {
735        if TuneRunRow::get(&state.pool, run_id)
736            .await?
737            .is_some_and(|run| recovery_blocks_deletion(run.recovery_state))
738        {
739            return Err(recovery_delete_conflict(run_id));
740        }
741        // Only reachable if the row was deleted by a concurrent request between the
742        // `get` above and this call -- still a well-defined 404 ("no run with that id"
743        // is simply true again by the time this responds), not a real error.
744        Err(ApiError::NotFound(format!("no run with id {run_id}")))
745    }
746}
747
748fn recovery_blocks_deletion(recovery_state: Option<TuneRecoveryState>) -> bool {
749    matches!(
750        recovery_state,
751        Some(
752            TuneRecoveryState::Eligible
753                | TuneRecoveryState::Running
754                | TuneRecoveryState::Incomplete
755        )
756    )
757}
758
759fn recovery_delete_conflict(run_id: i64) -> ApiError {
760    ApiError::Conflict(format!(
761        "run {run_id} has an unresolved live-loop recovery; use `bhtune restore-loop {run_id} --yes` or follow the manual recovery procedure before deleting it"
762    ))
763}
764
765pub fn router() -> Router<AppState> {
766    Router::new()
767        .route("/api/runs", get(list_runs))
768        .route("/api/runs/last-request", get(last_request))
769        .route("/api/runs/{id}", get(show_run).delete(delete_run))
770        .route("/api/runs/{id}/export", get(export_run))
771}
772
773#[cfg(test)]
774mod tests;