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 `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, ControllerType, DcsTemplate, LoopConfig, ProcessType, ResponseLevel, Tick,
23    TuningResultInvalidReason, TuningResultStatus,
24};
25use bhtune_db::models::{
26    MvActuationKind, MvActuationStatus, Pagination, RestoreStatus, RollbackState, SampleQuality,
27    TemplateOrigin, TimingMetrics, TuneDriver, TuneMvActuationRow, TuneOutcome, TuneResultRow,
28    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}
291
292impl From<&TuneResultRow> for ResultResponse {
293    fn from(r: &TuneResultRow) -> Self {
294        ResultResponse {
295            response_level: r.response_level,
296            kp: r.kp,
297            ti_minutes: r.ti_minutes,
298            td_minutes: r.td_minutes,
299            proportional: r.proportional,
300            integral: r.integral,
301            derivative: r.derivative,
302            status: r.status,
303            invalid_reason: r.invalid_reason,
304        }
305    }
306}
307
308/// Local projection of [`TuneWriteRow`].
309#[derive(Debug, Serialize, ToSchema)]
310pub struct WriteResponse {
311    pub kind: WriteKind,
312    pub response_level: ResponseLevel,
313    pub allow_uncertain_quality: bool,
314    pub written_at: DateTime<Utc>,
315    pub proportional_previous: Option<f32>,
316    pub integral_previous: Option<f32>,
317    pub derivative_previous: Option<f32>,
318    pub proportional_written: Option<f32>,
319    pub integral_written: Option<f32>,
320    pub derivative_written: Option<f32>,
321    pub proportional_readback: Option<f32>,
322    pub integral_readback: Option<f32>,
323    pub derivative_readback: Option<f32>,
324    pub success: bool,
325    pub error_message: Option<String>,
326    pub rollback_state: Option<RollbackState>,
327    pub rollback_error: Option<String>,
328}
329
330impl From<&TuneWriteRow> for WriteResponse {
331    fn from(w: &TuneWriteRow) -> Self {
332        WriteResponse {
333            kind: w.kind,
334            response_level: w.response_level,
335            allow_uncertain_quality: w.allow_uncertain_quality,
336            written_at: w.written_at,
337            proportional_previous: w.previous.map(|p| p.proportional),
338            integral_previous: w.previous.map(|p| p.integral),
339            derivative_previous: w.previous.map(|p| p.derivative),
340            proportional_written: w.proportional_written,
341            integral_written: w.integral_written,
342            derivative_written: w.derivative_written,
343            proportional_readback: w.proportional_readback,
344            integral_readback: w.integral_readback,
345            derivative_readback: w.derivative_readback,
346            success: w.success,
347            error_message: w.error_message.clone(),
348            rollback_state: w.rollback_state,
349            rollback_error: w.rollback_error.clone(),
350        }
351    }
352}
353
354/// Local projection of one accepted OPC DA manipulated-variable command and its independent
355/// live readback evidence. Commanded MV samples remain in [`SampleResponse`]; this audit trail
356/// is the only response surface that reports measured MV values.
357#[derive(Debug, Serialize, ToSchema)]
358pub struct MvActuationResponse {
359    pub id: i64,
360    pub sequence: i64,
361    pub kind: MvActuationKind,
362    pub commanded_at: DateTime<Utc>,
363    pub target_mv: f32,
364    pub previous_commanded_mv: Option<f32>,
365    pub tolerance: f32,
366    pub confirmation_due_at: DateTime<Utc>,
367    pub last_checked_at: Option<DateTime<Utc>>,
368    pub readback_mv: Option<f32>,
369    pub readback_quality: Option<SampleQuality>,
370    pub attempt_count: i64,
371    pub status: MvActuationStatus,
372    pub detail: Option<String>,
373}
374
375impl From<&TuneMvActuationRow> for MvActuationResponse {
376    fn from(row: &TuneMvActuationRow) -> Self {
377        Self {
378            id: row.id,
379            sequence: row.sequence,
380            kind: row.kind,
381            commanded_at: row.commanded_at,
382            target_mv: row.target_mv,
383            previous_commanded_mv: row.previous_commanded_mv,
384            tolerance: row.tolerance,
385            confirmation_due_at: row.confirmation_due_at,
386            last_checked_at: row.last_checked_at,
387            readback_mv: row.readback_mv,
388            readback_quality: row.readback_quality,
389            attempt_count: row.attempt_count,
390            status: row.status,
391            detail: row.detail.clone(),
392        }
393    }
394}
395
396/// A run's snapshotted PID constant tag names, present only when all three were configured.
397/// Nested under `RunDetailResponse::pid_constant_tags` following the same
398/// "`Option<...>` presence itself is the signal" convention `initial_readings` already uses,
399/// rather than a separate boolean plus three more nullable top-level fields.
400#[derive(Debug, Serialize, ToSchema)]
401pub struct PidConstantTagsResponse {
402    pub proportional: String,
403    pub integral: String,
404    pub derivative: String,
405}
406
407/// The operator-facing names for the three calculated PID constants, derived from the
408/// template snapshot stored on the run rather than the mutable template catalog.
409#[derive(Debug, Serialize, ToSchema)]
410pub struct PidParameterLabelsResponse {
411    pub proportional: String,
412    pub integral: String,
413    pub derivative: String,
414}
415
416impl From<&DcsTemplate> for PidParameterLabelsResponse {
417    fn from(template: &DcsTemplate) -> Self {
418        Self {
419            proportional: pid_parameter_label(&template.proportional_constant_suffix, "P"),
420            integral: pid_parameter_label(&template.integral_constant_suffix, "I"),
421            derivative: pid_parameter_label(&template.derivative_constant_suffix, "D"),
422        }
423    }
424}
425
426fn pid_parameter_label(suffix: &str, fallback: &str) -> String {
427    if suffix.is_empty() {
428        fallback.to_owned()
429    } else {
430        suffix.to_owned()
431    }
432}
433
434#[derive(Debug, Serialize, ToSchema)]
435pub struct RunDetailResponse {
436    pub id: i64,
437    pub tag_name: String,
438    pub notes: Option<String>,
439    pub driver: TuneDriver,
440    pub outcome: TuneOutcome,
441    pub failure_reason: Option<String>,
442    pub started_at: DateTime<Utc>,
443    pub completed_at: Option<DateTime<Utc>>,
444    /// Name of the template snapshotted onto this run at start time -- not necessarily what
445    /// `template_name` currently resolves to in the catalog (`safety-run-snapshot`).
446    pub template_name: String,
447    pub template_origin: TemplateOrigin,
448    /// Whether this run accepted `Uncertain` OPC quality, captured when the run started.
449    pub allow_uncertain_quality: bool,
450    pub config: LoopConfig,
451    /// Concrete global timing and safety values frozen when this run was prepared. `None`
452    /// identifies a run created before effective-tuning snapshots were stored.
453    pub effective_tuning: Option<bhtune_db::models::EffectiveTuning>,
454    /// The resolved OPC DA server ProgID this run actually used, or `None` for a
455    /// simulator/replay run (`db-run-request-snapshot`). This is what `history revert`
456    /// trusts over any `--server` flag -- see `bhtune-cli::commands::history`.
457    pub opc_server: Option<String>,
458    /// The resolved bridge host this run actually used, matching `opc_server` above.
459    pub bridge_host: Option<String>,
460    /// `Some` exactly when `routes::runs::require_writable_run`'s tag-presence check would
461    /// pass -- i.e. when all three PID constant tags were configured on this run. The
462    /// frontend uses this (together with `driver`/`outcome`/`opc_server`/`bridge_host`) to
463    /// decide whether the post-hoc write/revert buttons are enabled and, when they are not,
464    /// to explain why -- without duplicating `require_writable_run`'s logic client-side or
465    /// discovering ineligibility only after a failed request (`api-post-run-write`).
466    pub pid_constant_tags: Option<PidConstantTagsResponse>,
467    /// Operator-facing calculated-result column labels from the run's historical template
468    /// snapshot. Empty user-template suffixes use the conventional P/I/D labels.
469    pub pid_parameter_labels: PidParameterLabelsResponse,
470    pub initial_readings: Option<InitialReadingsResponse>,
471    pub timing_metrics: Option<TimingMetrics>,
472    pub samples: Vec<SampleResponse>,
473    pub results: Vec<ResultResponse>,
474    pub writes: Vec<WriteResponse>,
475    pub mv_actuations: Vec<MvActuationResponse>,
476    pub restore_status: Option<RestoreStatus>,
477    pub restore_detail: Option<String>,
478    /// This run's own `request_json` (`db-run-request-snapshot`), parsed back into a
479    /// [`StartRunRequest`], or `None` if it isn't usable -- see [`parse_stored_request`].
480    /// Powers the run detail page's "Duplicate this run" action (`ui-prefill-last-run`):
481    /// unlike `GET /api/runs/last-request`, which only ever answers for the single newest
482    /// run, this lets the New tune form seed itself from *this specific* historical run
483    /// regardless of how many later runs exist.
484    pub original_request: Option<StartRunRequest>,
485}
486
487/// Builds the full `RunDetailResponse` for one run, or `Ok(None)` if no run has that id --
488/// shared by `show_run` (`GET /api/runs/{id}`, which maps `None` to a 404) and
489/// `routes::runs::start_run` (`POST /api/runs`'s `201` body is the very same detail view of
490/// the run it just created, so both routes describe a run identically rather than the HTTP
491/// API growing two different shapes for "what a run looks like").
492pub(crate) async fn build_run_detail(
493    pool: &bhtune_db::SqlitePool,
494    run_id: i64,
495) -> Result<Option<RunDetailResponse>, ApiError> {
496    let Some(run) = TuneRunRow::get(pool, run_id).await? else {
497        return Ok(None);
498    };
499    let samples = TuneSampleRow::list_for_run(pool, run_id).await?;
500    let results = TuneResultRow::list_for_run(pool, run_id).await?;
501    let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
502    let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
503    let pid_constant_tags = match (
504        &run.tags.proportional_constant,
505        &run.tags.integral_constant,
506        &run.tags.derivative_constant,
507    ) {
508        (Some(proportional), Some(integral), Some(derivative)) => Some(PidConstantTagsResponse {
509            proportional: proportional.clone(),
510            integral: integral.clone(),
511            derivative: derivative.clone(),
512        }),
513        _ => None,
514    };
515    let pid_parameter_labels = PidParameterLabelsResponse::from(&run.template);
516
517    Ok(Some(RunDetailResponse {
518        id: run.id,
519        tag_name: run.loop_name,
520        notes: run.notes,
521        driver: run.driver,
522        outcome: run.outcome,
523        failure_reason: run.failure_reason,
524        started_at: run.started_at,
525        completed_at: run.completed_at,
526        template_name: run.template.name,
527        template_origin: run.template_origin,
528        allow_uncertain_quality: run.allow_uncertain_quality,
529        config: run.config,
530        effective_tuning: run.effective_tuning,
531        opc_server: run.opc_server,
532        bridge_host: run.bridge_host,
533        pid_constant_tags,
534        pid_parameter_labels,
535        initial_readings: run.initial_readings.map(InitialReadingsResponse::from),
536        timing_metrics: run.timing_metrics,
537        samples: samples.iter().map(SampleResponse::from).collect(),
538        results: results.iter().map(ResultResponse::from).collect(),
539        writes: writes.iter().map(WriteResponse::from).collect(),
540        mv_actuations: mv_actuations
541            .iter()
542            .map(MvActuationResponse::from)
543            .collect(),
544        restore_status: run.restore_status,
545        restore_detail: run.restore_detail,
546        original_request: parse_stored_request(run.id, &run.request_json),
547    }))
548}
549
550/// Fetch one run's full detail.
551///
552/// `GET /api/runs/{id}` -- 404 if no run has that id.
553#[utoipa::path(
554    get,
555    path = "/api/runs/{id}",
556    tag = "runs",
557    params(
558        ("id" = i64, Path, description = "Run id"),
559    ),
560    responses(
561        (status = 200, description = "The full recorded detail for one run.", body = RunDetailResponse),
562        (status = 404, description = "No run with that id.", body = ErrorBody),
563    ),
564)]
565pub(crate) async fn show_run(
566    State(state): State<AppState>,
567    Path(run_id): Path<i64>,
568) -> Result<Json<RunDetailResponse>, ApiError> {
569    build_run_detail(&state.pool, run_id)
570        .await?
571        .map(Json)
572        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
573}
574
575/// Format for `GET /api/runs/{id}/export` -- deliberately a local, HTTP-facing enum rather
576/// than reusing `bhtune_cli::args::ExportFormat` directly: that type is `clap`-oriented
577/// (`ValueEnum`) and has no `Deserialize`/`ToSchema`, matching this module's own
578/// DTO-decoupling convention (see the module doc comment). Converted to
579/// `bhtune_cli::args::ExportFormat` at the one call site that needs it ([`export_run`]), so
580/// the actual CSV/JSON serialization (`bhtune_cli::commands::export::samples_to_bytes`) is
581/// implemented exactly once and the CLI's `bhtune export` and this route can never disagree
582/// about what a run's export looks like.
583#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
584#[serde(rename_all = "snake_case")]
585pub enum RunExportFormat {
586    Csv,
587    Json,
588}
589
590impl From<RunExportFormat> for bhtune_cli::args::ExportFormat {
591    fn from(format: RunExportFormat) -> Self {
592        match format {
593            RunExportFormat::Csv => bhtune_cli::args::ExportFormat::Csv,
594            RunExportFormat::Json => bhtune_cli::args::ExportFormat::Json,
595        }
596    }
597}
598
599#[derive(Debug, Deserialize, IntoParams)]
600#[into_params(parameter_in = Query)]
601pub struct RunExportQuery {
602    /// Defaults to `csv` when omitted, matching `bhtune export`'s own CLI default.
603    pub format: Option<RunExportFormat>,
604}
605
606/// Export one run's recorded samples as CSV or JSON.
607///
608/// `GET /api/runs/{id}/export?format=csv|json` -- 404 if no run has that id or it has no
609/// recorded samples yet. Defaults to CSV. Sets `Content-Disposition: attachment` so a
610/// browser downloads the response as a file rather than rendering it.
611#[utoipa::path(
612    get,
613    path = "/api/runs/{id}/export",
614    tag = "runs",
615    params(
616        ("id" = i64, Path, description = "Run id"),
617        RunExportQuery,
618    ),
619    responses(
620        (status = 200, description = "The run's recorded samples, as CSV (default) or JSON.", content_type = "text/csv"),
621        (status = 404, description = "No run with that id, or it has no recorded samples.", body = ErrorBody),
622    ),
623)]
624pub(crate) async fn export_run(
625    State(state): State<AppState>,
626    Path(run_id): Path<i64>,
627    Query(query): Query<RunExportQuery>,
628) -> Result<Response, ApiError> {
629    let format = query.format.unwrap_or(RunExportFormat::Csv);
630    let samples = TuneSampleRow::list_for_run(&state.pool, run_id).await?;
631    if samples.is_empty() {
632        return Err(ApiError::NotFound(format!(
633            "run {run_id} has no recorded samples (unknown run id, or it never started)"
634        )));
635    }
636    let bytes = bhtune_cli::commands::export::samples_to_bytes(&samples, format.into())?;
637    let (content_type, extension) = match format {
638        RunExportFormat::Csv => ("text/csv", "csv"),
639        RunExportFormat::Json => ("application/json", "json"),
640    };
641    let mut response = bytes.into_response();
642    let headers = response.headers_mut();
643    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
644    headers.insert(
645        header::CONTENT_DISPOSITION,
646        HeaderValue::from_str(&format!(
647            "attachment; filename=\"run-{run_id}.{extension}\""
648        ))
649        .map_err(|e| ApiError::Internal(e.into()))?,
650    );
651    Ok(response)
652}
653
654/// Delete one run and its recorded samples/results/write-back audit rows.
655///
656/// `DELETE /api/runs/{id}` -- 404 if no run has that id, 409 if the run's own recorded
657/// `outcome` is still [`TuneOutcome::Running`] (deleting the row out from under an in-flight
658/// task would corrupt whatever it tries to write next; cancel it first). Deliberately checks
659/// the run row's own `outcome` rather than [`crate::active_run::ActiveRun`]'s in-memory
660/// active-run registry entry: `drive()` persists every terminal outcome (`persist_results` then
661/// `TuneRunRow::complete`/`fail`/`abort`) *before* returning, and `ActiveRun::release` only
662/// runs strictly after `drive()` returns (see `routes::runs::start_run`'s spawned task), so
663/// there is a real -- if brief -- window where a run's outcome is already durably
664/// `completed`/`failed`/`aborted` but `ActiveRun` hasn't been told the registry entry is free yet.
665/// Checking the DB's own authoritative, durable state instead of the best-effort in-memory
666/// tracker closes that race outright, rather than requiring the caller to retry (as
667/// `frontend/e2e/tune.spec.ts`'s `startTune()` already has to for the equivalent gap on the
668/// *start* side).
669#[utoipa::path(
670    delete,
671    path = "/api/runs/{id}",
672    tag = "runs",
673    params(
674        ("id" = i64, Path, description = "Run id"),
675    ),
676    responses(
677        (status = 204, description = "Run deleted."),
678        (status = 404, description = "No run with that id.", body = ErrorBody),
679        (status = 409, description = "The run has not finished yet.", body = ErrorBody),
680    ),
681)]
682pub(crate) async fn delete_run(
683    State(state): State<AppState>,
684    Path(run_id): Path<i64>,
685) -> Result<StatusCode, ApiError> {
686    delete_run_with_hook(state, run_id, |_| async {}).await
687}
688
689async fn delete_run_with_hook<F, Fut>(
690    state: AppState,
691    run_id: i64,
692    after_lookup: F,
693) -> Result<StatusCode, ApiError>
694where
695    F: FnOnce(&AppState) -> Fut,
696    Fut: Future<Output = ()>,
697{
698    let run = TuneRunRow::get(&state.pool, run_id)
699        .await?
700        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
701    if run.outcome == TuneOutcome::Running {
702        return Err(ApiError::Conflict(format!(
703            "run {run_id} has not finished yet; cancel it before deleting"
704        )));
705    }
706    after_lookup(&state).await;
707    if TuneRunRow::delete(&state.pool, run_id).await? {
708        Ok(StatusCode::NO_CONTENT)
709    } else {
710        // Only reachable if the row was deleted by a concurrent request between the
711        // `get` above and this call -- still a well-defined 404 ("no run with that id"
712        // is simply true again by the time this responds), not a real error.
713        Err(ApiError::NotFound(format!("no run with id {run_id}")))
714    }
715}
716
717pub fn router() -> Router<AppState> {
718    Router::new()
719        .route("/api/runs", get(list_runs))
720        .route("/api/runs/last-request", get(last_request))
721        .route("/api/runs/{id}", get(show_run).delete(delete_run))
722        .route("/api/runs/{id}/export", get(export_run))
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use axum::body::{Body, to_bytes};
729    use axum::http::{Request, StatusCode};
730    use bhtune_db::models::NewTuneMvActuation;
731    use tower::ServiceExt;
732
733    async fn body_json(response: axum::response::Response) -> serde_json::Value {
734        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
735        serde_json::from_slice(&bytes).unwrap()
736    }
737
738    async fn seed_one_run(state: &AppState) -> i64 {
739        let template_row =
740            bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
741                .await
742                .unwrap()
743                .unwrap();
744        let template = template_row.template;
745        let config = LoopConfig {
746            process_type: ProcessType::Flow,
747            controller_type: ControllerType::Pi,
748            relay_amp_percent: 5.0,
749            num_cycles_skip: 1,
750            num_cycles_count: 3,
751            noise_protection_secs: 0,
752            mrft_delay_secs: 0,
753        };
754        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop1.PV", &template);
755        let run = TuneRunRow::start(
756            &state.pool,
757            None,
758            "Loop1",
759            TuneDriver::Simulator,
760            config,
761            template_row.origin,
762            &template,
763            &tags,
764            Utc::now(),
765        )
766        .await
767        .unwrap();
768        run.id
769    }
770
771    /// Seeds a run carrying real data in every optional slot `seed_one_run` leaves empty --
772    /// initial readings, a sample, a result, and both a successful and a rolled-back write --
773    /// so every `From` impl in this module and every `filter_from_query` branch actually runs
774    /// at least once. Returns `(run_id, loop_id)`: unlike `seed_one_run`'s ad hoc
775    /// `loop_id = None` run, this one is attached to a real `loops` row so the `loop_id`
776    /// filter has something to match against.
777    async fn seed_full_run(state: &AppState) -> (i64, i64) {
778        let template_row =
779            bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
780                .await
781                .unwrap()
782                .unwrap();
783        let template = template_row.template;
784        let config = LoopConfig {
785            process_type: ProcessType::Flow,
786            controller_type: ControllerType::Pi,
787            relay_amp_percent: 5.0,
788            num_cycles_skip: 1,
789            num_cycles_count: 3,
790            noise_protection_secs: 0,
791            mrft_delay_secs: 0,
792        };
793        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop2.PV", &template);
794        let now = Utc::now();
795
796        let loop_id = sqlx::query(
797            r#"
798            INSERT INTO loops (
799                name, dcs_template_id, tags_json, process_type, controller_type,
800                relay_amp_percent, num_cycles_skip, num_cycles_count, noise_protection_secs,
801                mrft_delay_secs, created_at, updated_at
802            ) VALUES ('Loop2', ?, '{}', 'flow', 'pi', 5.0, 1, 3, 0, 0, ?, ?)
803            "#,
804        )
805        .bind(template_row.id)
806        .bind(now)
807        .bind(now)
808        .execute(&state.pool)
809        .await
810        .unwrap()
811        .last_insert_rowid();
812
813        let run = TuneRunRow::start(
814            &state.pool,
815            Some(loop_id),
816            "Loop2",
817            TuneDriver::Simulator,
818            config,
819            template_row.origin,
820            &template,
821            &tags,
822            now,
823        )
824        .await
825        .unwrap();
826        let run_id = run.id;
827
828        TuneRunRow::record_initial_readings(
829            &state.pool,
830            run_id,
831            bhtune_db::models::TuneRunInitialReadings {
832                pv_ini: 50.0,
833                mv_ini: 45.0,
834                mv_range_low: 0.0,
835                mv_range_high: 100.0,
836                pv_range_high: 100.0,
837                pv_range_low: 0.0,
838                controller_direction: ControllerDirection::Direct,
839                mode_raw: Some("1".to_string()),
840                mode_attribute_raw: Some("2".to_string()),
841                setpoint_ini: Some(50.0),
842            },
843        )
844        .await
845        .unwrap();
846
847        TuneRunRow::record_timing_metrics(
848            &state.pool,
849            run_id,
850            TimingMetrics {
851                basis: bhtune_db::models::TimingBasis::SimulatedFixedStep,
852                requested_interval_ms: 5,
853                sample_gap_count: 9,
854                mean_sample_gap_ms: Some(5.0),
855                max_sample_gap_ms: Some(5.0),
856                missed_poll_opportunity_count: 0,
857                measured_oscillation_period_ms: Some(50.0),
858                approximate_samples_per_period: Some(10.0),
859                sampling_adequacy: bhtune_db::models::SamplingAdequacy::Adequate,
860                poll_latency: Some(bhtune_db::models::PollLatencyMetrics {
861                    pv_read: bhtune_db::models::TimingSummary {
862                        count: 10,
863                        mean_ms: Some(1.25),
864                        max_ms: Some(2.5),
865                    },
866                    mv_write: bhtune_db::models::TimingSummary {
867                        count: 3,
868                        mean_ms: Some(3.5),
869                        max_ms: Some(4.0),
870                    },
871                    mv_verification: bhtune_db::models::TimingSummary {
872                        count: 3,
873                        mean_ms: Some(2.0),
874                        max_ms: Some(2.5),
875                    },
876                    sample_persist: bhtune_db::models::TimingSummary {
877                        count: 10,
878                        mean_ms: Some(0.75),
879                        max_ms: Some(1.0),
880                    },
881                    tick_work: bhtune_db::models::TimingSummary {
882                        count: 10,
883                        mean_ms: Some(5.5),
884                        max_ms: Some(7.0),
885                    },
886                }),
887            },
888        )
889        .await
890        .unwrap();
891
892        TuneSampleRow::insert(
893            &state.pool,
894            run_id,
895            0,
896            Tick {
897                time: now,
898                pv: 50.0,
899            },
900            bhtune_core::MrftState {
901                hysteresis: 1.0,
902                mv_value_current: 45.0,
903                mv_sign_next_step: 1,
904                counter_all_switches: 0,
905                cycles_completed: 0,
906                cycles_remaining: 3,
907            },
908            SampleQuality::Good,
909        )
910        .await
911        .unwrap();
912
913        TuneResultRow::insert(
914            &state.pool,
915            &TuneResultRow {
916                id: 0,
917                run_id,
918                response_level: ResponseLevel::Moderate,
919                kp: Some(1.5),
920                ti_minutes: Some(2.0),
921                td_minutes: Some(0.0),
922                proportional: Some(66.7),
923                integral: Some(2.0),
924                derivative: Some(0.0),
925                status: TuningResultStatus::Valid,
926                invalid_reason: None,
927            },
928        )
929        .await
930        .unwrap();
931
932        // A confirmed write: previous/written/readback all populated, no rollback needed.
933        let mut successful_write =
934            bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, now);
935        successful_write.previous = Some(bhtune_db::models::WriteReadback {
936            proportional: 60.0,
937            integral: 3.0,
938            derivative: 0.0,
939        });
940        successful_write.proportional_written = Some(66.7);
941        successful_write.integral_written = Some(2.0);
942        successful_write.derivative_written = Some(0.0);
943        successful_write.proportional_readback = Some(66.7);
944        successful_write.integral_readback = Some(2.0);
945        successful_write.derivative_readback = Some(0.0);
946        successful_write.success = true;
947        TuneWriteRow::insert(&state.pool, run_id, successful_write)
948            .await
949            .unwrap();
950
951        // A rejected write that was rolled back: readback stays unset (never confirmed) and
952        // `rollback_state` is populated -- exercises the `Option`/`success = false` branches
953        // `WriteResponse::from` otherwise never reaches.
954        let mut failed_write = bhtune_db::models::NewTuneWrite::new(ResponseLevel::Aggressive, now);
955        failed_write.previous = Some(bhtune_db::models::WriteReadback {
956            proportional: 60.0,
957            integral: 3.0,
958            derivative: 0.0,
959        });
960        failed_write.proportional_written = Some(100.0);
961        failed_write.success = false;
962        failed_write.error_message = Some("write rejected: value out of range".to_string());
963        failed_write.rollback_state = Some(RollbackState::Succeeded);
964        TuneWriteRow::insert(&state.pool, run_id, failed_write)
965            .await
966            .unwrap();
967
968        let confirmed_actuation = TuneMvActuationRow::insert_pending(
969            &state.pool,
970            run_id,
971            NewTuneMvActuation {
972                sequence: 0,
973                kind: MvActuationKind::Relay,
974                commanded_at: now,
975                target_mv: 55.0,
976                previous_commanded_mv: Some(50.0),
977                tolerance: 0.5,
978                confirmation_due_at: now + chrono::Duration::seconds(4),
979            },
980        )
981        .await
982        .unwrap();
983        TuneMvActuationRow::record_final_observation(
984            &state.pool,
985            confirmed_actuation.id,
986            now + chrono::Duration::seconds(1),
987            Some(55.0),
988            Some(SampleQuality::Good),
989            MvActuationStatus::Confirmed,
990            None,
991        )
992        .await
993        .unwrap();
994
995        let superseded_actuation = TuneMvActuationRow::insert_pending(
996            &state.pool,
997            run_id,
998            NewTuneMvActuation {
999                sequence: 1,
1000                kind: MvActuationKind::Restore,
1001                commanded_at: now + chrono::Duration::seconds(5),
1002                target_mv: 50.0,
1003                previous_commanded_mv: Some(55.0),
1004                tolerance: 0.5,
1005                confirmation_due_at: now + chrono::Duration::seconds(9),
1006            },
1007        )
1008        .await
1009        .unwrap();
1010        TuneMvActuationRow::finalize(
1011            &state.pool,
1012            superseded_actuation.id,
1013            MvActuationStatus::Superseded,
1014            Some("restore took over confirmation"),
1015        )
1016        .await
1017        .unwrap();
1018
1019        TuneRunRow::complete(&state.pool, run_id, now)
1020            .await
1021            .unwrap();
1022
1023        (run_id, loop_id)
1024    }
1025
1026    #[tokio::test]
1027    async fn list_runs_returns_empty_when_no_runs_exist() {
1028        let app = router().with_state(crate::test_support::in_memory_state().await);
1029        let response = app
1030            .oneshot(Request::get("/api/runs").body(Body::empty()).unwrap())
1031            .await
1032            .unwrap();
1033        assert_eq!(response.status(), StatusCode::OK);
1034        let body = body_json(response).await;
1035        assert_eq!(body["total"], 0);
1036        assert_eq!(body["returned"], 0);
1037        assert!(body["runs"].as_array().unwrap().is_empty());
1038    }
1039
1040    #[tokio::test]
1041    async fn list_runs_returns_a_seeded_run() {
1042        let state = crate::test_support::in_memory_state().await;
1043        let run_id = seed_one_run(&state).await;
1044        let app = router().with_state(state);
1045        let response = app
1046            .oneshot(Request::get("/api/runs").body(Body::empty()).unwrap())
1047            .await
1048            .unwrap();
1049        assert_eq!(response.status(), StatusCode::OK);
1050        let body = body_json(response).await;
1051        assert_eq!(body["total"], 1);
1052        assert_eq!(body["runs"][0]["id"], run_id);
1053        assert_eq!(body["runs"][0]["process_type"], "flow");
1054    }
1055
1056    #[tokio::test]
1057    async fn list_runs_filters_by_outcome() {
1058        let state = crate::test_support::in_memory_state().await;
1059        seed_one_run(&state).await;
1060        let app = router().with_state(state);
1061        let response = app
1062            .oneshot(
1063                Request::get("/api/runs?outcome=completed")
1064                    .body(Body::empty())
1065                    .unwrap(),
1066            )
1067            .await
1068            .unwrap();
1069        assert_eq!(response.status(), StatusCode::OK);
1070        let body = body_json(response).await;
1071        // The seeded run is still `running` (never completed), so filtering for `completed`
1072        // must exclude it.
1073        assert_eq!(body["total"], 0);
1074    }
1075
1076    #[tokio::test]
1077    async fn show_run_returns_full_detail() {
1078        let state = crate::test_support::in_memory_state().await;
1079        let run_id = seed_one_run(&state).await;
1080        let app = router().with_state(state);
1081        let response = app
1082            .oneshot(
1083                Request::get(format!("/api/runs/{run_id}"))
1084                    .body(Body::empty())
1085                    .unwrap(),
1086            )
1087            .await
1088            .unwrap();
1089        assert_eq!(response.status(), StatusCode::OK);
1090        let body = body_json(response).await;
1091        assert_eq!(body["id"], run_id);
1092        assert_eq!(body["template_name"], "Yokogawa CentumVP");
1093        assert!(body["samples"].as_array().unwrap().is_empty());
1094        assert!(body["results"].as_array().unwrap().is_empty());
1095        assert!(body["writes"].as_array().unwrap().is_empty());
1096        assert!(body["timing_metrics"].is_null());
1097        // Yokogawa CentumVP always defines P/I/D suffixes, so a run derived from it always
1098        // has all three PID constant tags -- see `pid_constant_tags`'s doc comment.
1099        assert_eq!(body["pid_constant_tags"]["proportional"], "Loop1.P");
1100        assert_eq!(body["pid_constant_tags"]["integral"], "Loop1.I");
1101        assert_eq!(body["pid_constant_tags"]["derivative"], "Loop1.D");
1102        assert_eq!(body["pid_parameter_labels"]["proportional"], "P");
1103        assert_eq!(body["pid_parameter_labels"]["integral"], "I");
1104        assert_eq!(body["pid_parameter_labels"]["derivative"], "D");
1105        // `seed_one_run` never calls `record_connection`, so `request_json` is left at the
1106        // column default `"{}"` -- not a valid `StartRunRequest`, so `original_request` must
1107        // gracefully read `null` rather than the request failing (see
1108        // `parse_stored_request`'s doc comment).
1109        assert!(body["original_request"].is_null());
1110    }
1111
1112    #[test]
1113    fn pid_parameter_labels_fall_back_for_blank_template_suffixes() {
1114        let mut template = bhtune_core::built_in_templates().remove(0);
1115        template.proportional_constant_suffix.clear();
1116        template.integral_constant_suffix.clear();
1117        template.derivative_constant_suffix.clear();
1118
1119        let labels = PidParameterLabelsResponse::from(&template);
1120
1121        assert_eq!(labels.proportional, "P");
1122        assert_eq!(labels.integral, "I");
1123        assert_eq!(labels.derivative, "D");
1124    }
1125
1126    /// `pid_constant_tags` must be `null`, not merely three `null` fields, when the run's
1127    /// snapshotted tags lack any of the three PID constants -- exactly the case
1128    /// `routes::runs::require_writable_run` refuses a post-hoc write/revert for (see that
1129    /// module's own `write_run_returns_400_when_run_has_no_pid_constant_tags` test).
1130    #[tokio::test]
1131    async fn show_run_reports_no_pid_constant_tags_as_a_null_pid_constant_tags_field() {
1132        let state = crate::test_support::in_memory_state().await;
1133        let template_row =
1134            bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1135                .await
1136                .unwrap()
1137                .unwrap();
1138        let template = template_row.template;
1139        let config = LoopConfig {
1140            process_type: ProcessType::Flow,
1141            controller_type: ControllerType::Pi,
1142            relay_amp_percent: 5.0,
1143            num_cycles_skip: 1,
1144            num_cycles_count: 3,
1145            noise_protection_secs: 0,
1146            mrft_delay_secs: 0,
1147        };
1148        let mut tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop3.PV", &template);
1149        tags.proportional_constant = None;
1150        tags.integral_constant = None;
1151        tags.derivative_constant = None;
1152        let run = TuneRunRow::start(
1153            &state.pool,
1154            None,
1155            "Loop3",
1156            TuneDriver::Simulator,
1157            config,
1158            template_row.origin,
1159            &template,
1160            &tags,
1161            Utc::now(),
1162        )
1163        .await
1164        .unwrap();
1165        let app = router().with_state(state);
1166        let response = app
1167            .oneshot(
1168                Request::get(format!("/api/runs/{}", run.id))
1169                    .body(Body::empty())
1170                    .unwrap(),
1171            )
1172            .await
1173            .unwrap();
1174        assert_eq!(response.status(), StatusCode::OK);
1175        let body = body_json(response).await;
1176        assert!(body["pid_constant_tags"].is_null());
1177    }
1178
1179    #[tokio::test]
1180    async fn show_run_returns_full_detail_with_samples_results_and_writes() {
1181        let state = crate::test_support::in_memory_state().await;
1182        let (run_id, _loop_id) = seed_full_run(&state).await;
1183        let app = router().with_state(state);
1184        let response = app
1185            .oneshot(
1186                Request::get(format!("/api/runs/{run_id}"))
1187                    .body(Body::empty())
1188                    .unwrap(),
1189            )
1190            .await
1191            .unwrap();
1192        assert_eq!(response.status(), StatusCode::OK);
1193        let body = body_json(response).await;
1194
1195        assert_eq!(body["id"], run_id);
1196        assert_eq!(body["outcome"], "completed");
1197        assert!(body["completed_at"].is_string());
1198        // seed_full_run uses the simulator driver and never calls `record_connection`, so
1199        // both connection fields are null -- see `list_runs_filters_by_opc_server_and_bridge_host`
1200        // below for the opcda-with-a-recorded-connection case.
1201        assert!(body["opc_server"].is_null());
1202        assert!(body["bridge_host"].is_null());
1203        assert_eq!(body["pid_constant_tags"]["proportional"], "Loop2.P");
1204        assert_eq!(body["pid_constant_tags"]["integral"], "Loop2.I");
1205        assert_eq!(body["pid_constant_tags"]["derivative"], "Loop2.D");
1206
1207        let initial_readings = &body["initial_readings"];
1208        assert_eq!(initial_readings["pv_ini"], 50.0);
1209        assert_eq!(initial_readings["mv_ini"], 45.0);
1210        assert_eq!(initial_readings["controller_direction"], "direct");
1211        assert_eq!(initial_readings["mode_raw"], "1");
1212        assert_eq!(initial_readings["mode_attribute_raw"], "2");
1213        assert_eq!(initial_readings["setpoint_ini"], 50.0);
1214
1215        let timing = &body["timing_metrics"];
1216        assert_eq!(timing["basis"], "simulated_fixed_step");
1217        assert_eq!(timing["requested_interval_ms"], 5);
1218        assert_eq!(timing["sample_gap_count"], 9);
1219        assert_eq!(timing["mean_sample_gap_ms"], 5.0);
1220        assert_eq!(timing["max_sample_gap_ms"], 5.0);
1221        assert_eq!(timing["missed_poll_opportunity_count"], 0);
1222        assert_eq!(timing["measured_oscillation_period_ms"], 50.0);
1223        assert_eq!(timing["approximate_samples_per_period"], 10.0);
1224        assert_eq!(timing["sampling_adequacy"], "adequate");
1225        assert_eq!(timing["poll_latency"]["pv_read"]["count"], 10);
1226        assert_eq!(timing["poll_latency"]["pv_read"]["mean_ms"], 1.25);
1227        assert_eq!(timing["poll_latency"]["pv_read"]["max_ms"], 2.5);
1228        assert_eq!(timing["poll_latency"]["mv_write"]["count"], 3);
1229        assert_eq!(timing["poll_latency"]["mv_verification"]["count"], 3);
1230        assert_eq!(timing["poll_latency"]["sample_persist"]["count"], 10);
1231        assert_eq!(timing["poll_latency"]["tick_work"]["count"], 10);
1232
1233        let samples = body["samples"].as_array().unwrap();
1234        assert_eq!(samples.len(), 1);
1235        assert_eq!(samples[0]["tick_index"], 0);
1236        assert_eq!(samples[0]["sample"]["pv"], 50.0);
1237        assert_eq!(samples[0]["state"]["mv_value_current"], 45.0);
1238        assert_eq!(samples[0]["pv_quality"], "good");
1239
1240        let results = body["results"].as_array().unwrap();
1241        assert_eq!(results.len(), 1);
1242        assert_eq!(results[0]["response_level"], "moderate");
1243        assert_eq!(results[0]["kp"], 1.5);
1244        assert_eq!(results[0]["proportional"], 66.7);
1245
1246        let writes = body["writes"].as_array().unwrap();
1247        assert_eq!(writes.len(), 2);
1248        let successful = writes
1249            .iter()
1250            .find(|w| w["response_level"] == "moderate")
1251            .unwrap();
1252        assert_eq!(successful["success"], true);
1253        assert_eq!(successful["proportional_previous"], 60.0);
1254        assert_eq!(successful["proportional_readback"], 66.7);
1255        assert!(successful["rollback_state"].is_null());
1256        assert!(successful["error_message"].is_null());
1257
1258        let failed = writes
1259            .iter()
1260            .find(|w| w["response_level"] == "aggressive")
1261            .unwrap();
1262        assert_eq!(failed["success"], false);
1263        assert_eq!(failed["proportional_written"], 100.0);
1264        assert!(failed["proportional_readback"].is_null());
1265        assert_eq!(failed["rollback_state"], "succeeded");
1266        assert_eq!(
1267            failed["error_message"],
1268            "write rejected: value out of range"
1269        );
1270
1271        let actuations = body["mv_actuations"].as_array().unwrap();
1272        assert_eq!(actuations.len(), 2);
1273        let confirmed = actuations
1274            .iter()
1275            .find(|actuation| actuation["kind"] == "relay")
1276            .unwrap();
1277        assert_eq!(confirmed["sequence"], 0);
1278        assert_eq!(confirmed["target_mv"], 55.0);
1279        assert_eq!(confirmed["previous_commanded_mv"], 50.0);
1280        assert_eq!(confirmed["tolerance"], 0.5);
1281        assert!(confirmed["commanded_at"].is_string());
1282        assert!(confirmed["confirmation_due_at"].is_string());
1283        assert!(confirmed["last_checked_at"].is_string());
1284        assert_eq!(confirmed["readback_mv"], 55.0);
1285        assert_eq!(confirmed["readback_quality"], "good");
1286        assert_eq!(confirmed["attempt_count"], 1);
1287        assert_eq!(confirmed["status"], "confirmed");
1288        assert!(confirmed["detail"].is_null());
1289
1290        let superseded = actuations
1291            .iter()
1292            .find(|actuation| actuation["kind"] == "restore")
1293            .unwrap();
1294        assert_eq!(superseded["sequence"], 1);
1295        assert_eq!(superseded["target_mv"], 50.0);
1296        assert_eq!(superseded["previous_commanded_mv"], 55.0);
1297        assert!(superseded["last_checked_at"].is_null());
1298        assert!(superseded["readback_mv"].is_null());
1299        assert!(superseded["readback_quality"].is_null());
1300        assert_eq!(superseded["attempt_count"], 0);
1301        assert_eq!(superseded["status"], "superseded");
1302        assert_eq!(superseded["detail"], "restore took over confirmation");
1303    }
1304
1305    #[tokio::test]
1306    async fn list_runs_filters_by_every_supported_query_parameter_simultaneously() {
1307        let state = crate::test_support::in_memory_state().await;
1308        let (run_id, loop_id) = seed_full_run(&state).await;
1309        // A second, unrelated run proves the filters actually narrow the result set rather
1310        // than just happening to match everything in an otherwise-empty database.
1311        seed_one_run(&state).await;
1312        let app = router().with_state(state);
1313
1314        let started_after = (Utc::now() - chrono::Duration::hours(1))
1315            .to_rfc3339()
1316            .replace('+', "%2B");
1317        let started_before = (Utc::now() + chrono::Duration::hours(1))
1318            .to_rfc3339()
1319            .replace('+', "%2B");
1320        let uri = format!(
1321            "/api/runs?loop_id={loop_id}&process_type=flow&controller_type=pi\
1322             &outcome=completed&driver=simulator&started_after={started_after}\
1323             &started_before={started_before}&template_name=Yokogawa+CentumVP\
1324             &template_origin=builtin"
1325        );
1326
1327        let response = app
1328            .oneshot(Request::get(uri).body(Body::empty()).unwrap())
1329            .await
1330            .unwrap();
1331        assert_eq!(response.status(), StatusCode::OK);
1332        let body = body_json(response).await;
1333        assert_eq!(body["total"], 1);
1334        assert_eq!(body["runs"][0]["id"], run_id);
1335    }
1336
1337    #[tokio::test]
1338    async fn list_runs_filters_by_opc_server_and_bridge_host() {
1339        let state = crate::test_support::in_memory_state().await;
1340        // A run whose recorded connection this test filters for.
1341        let (run_id, _loop_id) = seed_full_run(&state).await;
1342        TuneRunRow::record_connection(
1343            &state.pool,
1344            run_id,
1345            Some("Kepware.KEPServerEX.V6"),
1346            Some("gateway-a:7600"),
1347            "{}",
1348        )
1349        .await
1350        .unwrap();
1351        // A second run with no recorded connection at all (mirrors a real simulator run),
1352        // proving the filter actually narrows rather than matching everything.
1353        seed_one_run(&state).await;
1354        let app = router().with_state(state);
1355
1356        let response = app
1357            .clone()
1358            .oneshot(
1359                Request::get(
1360                    "/api/runs?opc_server=Kepware.KEPServerEX.V6&bridge_host=gateway-a:7600",
1361                )
1362                .body(Body::empty())
1363                .unwrap(),
1364            )
1365            .await
1366            .unwrap();
1367        assert_eq!(response.status(), StatusCode::OK);
1368        let body = body_json(response).await;
1369        assert_eq!(body["total"], 1);
1370        assert_eq!(body["runs"][0]["id"], run_id);
1371
1372        // The full detail view surfaces both fields too.
1373        let detail_response = app
1374            .oneshot(
1375                Request::get(format!("/api/runs/{run_id}"))
1376                    .body(Body::empty())
1377                    .unwrap(),
1378            )
1379            .await
1380            .unwrap();
1381        assert_eq!(detail_response.status(), StatusCode::OK);
1382        let detail_body = body_json(detail_response).await;
1383        assert_eq!(detail_body["opc_server"], "Kepware.KEPServerEX.V6");
1384        assert_eq!(detail_body["bridge_host"], "gateway-a:7600");
1385    }
1386
1387    /// A simulator-driven [`StartRunRequest`] JSON body, built the same way
1388    /// `runs::tests::fast_simulator_request_json` is but parsed through the real
1389    /// `StartRunRequest` `Deserialize` impl and re-serialized -- so the resulting string is
1390    /// guaranteed well-formed per that type's actual schema (defaults filled in for every
1391    /// field this literal omits) rather than a hand-typed guess that could silently drift
1392    /// from it.
1393    fn fast_simulator_request_json_string() -> String {
1394        let value = serde_json::json!({
1395            "tagname": "ignored-for-simulator",
1396            "template": "Yokogawa CentumVP",
1397            "process_type": "flow",
1398            "controller_type": "pi",
1399            "relay_amp": 10.0,
1400            "cycles_skip": 1,
1401            "cycles_count": 2,
1402            "noise_protection_secs": 0,
1403            "driver": "simulator",
1404            "sim_gain": 1.0,
1405            "sim_tau": 0.01,
1406            "sim_dead_time": 0.025,
1407            "pv_range_high": 100.0,
1408            "pv_range_low": 0.0,
1409            "mv_range_high": 100.0,
1410            "mv_range_low": 0.0,
1411            "direction": "reverse",
1412            "poll_interval_ms": 5,
1413            "notes": "http-test-loop",
1414        });
1415        let request: StartRunRequest = serde_json::from_value(value).unwrap();
1416        serde_json::to_string(&request).unwrap()
1417    }
1418
1419    #[tokio::test]
1420    async fn last_request_returns_null_when_the_newest_runs_request_json_does_not_parse() {
1421        // The column default `"{}"` (what `seed_one_run` leaves behind, since it never
1422        // calls `record_connection`) isn't a valid `StartRunRequest` -- proving this
1423        // gracefully reads `null` rather than 500ing is what actually justifies
1424        // `parse_stored_request` existing instead of just propagating `?`.
1425        let state = crate::test_support::in_memory_state().await;
1426        seed_one_run(&state).await;
1427        let app = router().with_state(state);
1428        let response = app
1429            .oneshot(
1430                Request::get("/api/runs/last-request")
1431                    .body(Body::empty())
1432                    .unwrap(),
1433            )
1434            .await
1435            .unwrap();
1436        assert_eq!(response.status(), StatusCode::OK);
1437        assert!(body_json(response).await.is_null());
1438    }
1439
1440    /// `show_run`'s `original_request` field is the mechanism `ui-prefill-last-run`'s
1441    /// "Duplicate this run" action relies on: unlike `last_request`, which only ever answers
1442    /// for the single newest run, this must round-trip a *specific*, non-newest run's own
1443    /// stored request correctly.
1444    #[tokio::test]
1445    async fn show_run_returns_the_runs_own_original_request() {
1446        let state = crate::test_support::in_memory_state().await;
1447        let run_id = seed_one_run(&state).await;
1448        let request_json = fast_simulator_request_json_string();
1449        TuneRunRow::record_connection(&state.pool, run_id, None, None, &request_json)
1450            .await
1451            .unwrap();
1452        // A newer run exists too, proving `show_run` returns *this* run's request rather
1453        // than always answering with the newest one the way `last_request` does.
1454        seed_one_run(&state).await;
1455
1456        let app = router().with_state(state);
1457        let response = app
1458            .oneshot(
1459                Request::get(format!("/api/runs/{run_id}"))
1460                    .body(Body::empty())
1461                    .unwrap(),
1462            )
1463            .await
1464            .unwrap();
1465        assert_eq!(response.status(), StatusCode::OK);
1466        let body = body_json(response).await;
1467        assert_eq!(body["original_request"]["tagname"], "ignored-for-simulator");
1468        assert_eq!(body["original_request"]["driver"], "simulator");
1469        assert_eq!(body["original_request"]["notes"], "http-test-loop");
1470    }
1471
1472    #[tokio::test]
1473    async fn last_request_returns_null_when_no_runs_exist() {
1474        let app = router().with_state(crate::test_support::in_memory_state().await);
1475        let response = app
1476            .oneshot(
1477                Request::get("/api/runs/last-request")
1478                    .body(Body::empty())
1479                    .unwrap(),
1480            )
1481            .await
1482            .unwrap();
1483        assert_eq!(response.status(), StatusCode::OK);
1484        assert!(body_json(response).await.is_null());
1485    }
1486
1487    #[tokio::test]
1488    async fn last_request_returns_the_newest_runs_request_not_the_first() {
1489        let state = crate::test_support::in_memory_state().await;
1490        let older_run_id = seed_one_run(&state).await;
1491        TuneRunRow::record_connection(&state.pool, older_run_id, None, None, "{}")
1492            .await
1493            .unwrap();
1494
1495        // A second, newer run carrying a real request body -- `seed_one_run` itself only
1496        // ever inserts `request_json = "{}"` (the column default), so this is also what
1497        // proves the endpoint parses a *real* snapshot correctly, not just an empty one.
1498        let template_row =
1499            bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1500                .await
1501                .unwrap()
1502                .unwrap();
1503        let template = template_row.template;
1504        let config = LoopConfig {
1505            process_type: ProcessType::Flow,
1506            controller_type: ControllerType::Pi,
1507            relay_amp_percent: 10.0,
1508            num_cycles_skip: 1,
1509            num_cycles_count: 2,
1510            noise_protection_secs: 0,
1511            mrft_delay_secs: 0,
1512        };
1513        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Sim.PV", &template);
1514        let newer_run = TuneRunRow::start(
1515            &state.pool,
1516            None,
1517            "http-test-loop",
1518            TuneDriver::Simulator,
1519            config,
1520            template_row.origin,
1521            &template,
1522            &tags,
1523            Utc::now() + chrono::Duration::seconds(1),
1524        )
1525        .await
1526        .unwrap();
1527        let request_json = fast_simulator_request_json_string();
1528        TuneRunRow::record_connection(&state.pool, newer_run.id, None, None, &request_json)
1529            .await
1530            .unwrap();
1531
1532        let app = router().with_state(state);
1533        let response = app
1534            .oneshot(
1535                Request::get("/api/runs/last-request")
1536                    .body(Body::empty())
1537                    .unwrap(),
1538            )
1539            .await
1540            .unwrap();
1541        assert_eq!(response.status(), StatusCode::OK);
1542        let body = body_json(response).await;
1543        assert_eq!(body["tagname"], "ignored-for-simulator");
1544        assert_eq!(body["template"], "Yokogawa CentumVP");
1545        assert_eq!(body["process_type"], "flow");
1546        assert_eq!(body["controller_type"], "pi");
1547        assert_eq!(body["driver"], "simulator");
1548        assert_eq!(body["notes"], "http-test-loop");
1549        assert_eq!(body["relay_amp"], 10.0);
1550    }
1551
1552    #[tokio::test]
1553    async fn show_run_404s_for_an_unknown_id() {
1554        let app = router().with_state(crate::test_support::in_memory_state().await);
1555        let response = app
1556            .oneshot(
1557                Request::get("/api/runs/999999")
1558                    .body(Body::empty())
1559                    .unwrap(),
1560            )
1561            .await
1562            .unwrap();
1563        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1564    }
1565
1566    #[tokio::test]
1567    async fn show_and_delete_propagate_database_failures_as_500() {
1568        let state = crate::test_support::in_memory_state().await;
1569        let app = router().with_state(state.clone());
1570        state.pool.close().await;
1571
1572        let show = app
1573            .clone()
1574            .oneshot(Request::get("/api/runs/1").body(Body::empty()).unwrap())
1575            .await
1576            .unwrap();
1577        assert_eq!(show.status(), StatusCode::INTERNAL_SERVER_ERROR);
1578
1579        let delete = app
1580            .oneshot(Request::delete("/api/runs/1").body(Body::empty()).unwrap())
1581            .await
1582            .unwrap();
1583        assert_eq!(delete.status(), StatusCode::INTERNAL_SERVER_ERROR);
1584    }
1585
1586    #[tokio::test]
1587    async fn export_run_defaults_to_csv_with_the_expected_headers_and_body() {
1588        let state = crate::test_support::in_memory_state().await;
1589        let (run_id, _loop_id) = seed_full_run(&state).await;
1590        let app = router().with_state(state);
1591
1592        let response = app
1593            .oneshot(
1594                Request::get(format!("/api/runs/{run_id}/export"))
1595                    .body(Body::empty())
1596                    .unwrap(),
1597            )
1598            .await
1599            .unwrap();
1600        assert_eq!(response.status(), StatusCode::OK);
1601        assert_eq!(
1602            response.headers().get(header::CONTENT_TYPE).unwrap(),
1603            "text/csv"
1604        );
1605        assert_eq!(
1606            response.headers().get(header::CONTENT_DISPOSITION).unwrap(),
1607            &format!("attachment; filename=\"run-{run_id}.csv\"")
1608        );
1609        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1610        let text = String::from_utf8(bytes.to_vec()).unwrap();
1611        let mut lines = text.lines();
1612        assert_eq!(
1613            lines.next().unwrap(),
1614            "tick,time,pv,pv_quality,hysteresis,mv_value_current,mv_sign_next_step,counter_all_switches,cycles_completed,cycles_remaining"
1615        );
1616        assert!(lines.next().unwrap().starts_with("0,"));
1617    }
1618
1619    #[tokio::test]
1620    async fn export_run_supports_the_json_format() {
1621        let state = crate::test_support::in_memory_state().await;
1622        let (run_id, _loop_id) = seed_full_run(&state).await;
1623        let app = router().with_state(state);
1624
1625        let response = app
1626            .oneshot(
1627                Request::get(format!("/api/runs/{run_id}/export?format=json"))
1628                    .body(Body::empty())
1629                    .unwrap(),
1630            )
1631            .await
1632            .unwrap();
1633        assert_eq!(response.status(), StatusCode::OK);
1634        assert_eq!(
1635            response.headers().get(header::CONTENT_TYPE).unwrap(),
1636            "application/json"
1637        );
1638        assert_eq!(
1639            response.headers().get(header::CONTENT_DISPOSITION).unwrap(),
1640            &format!("attachment; filename=\"run-{run_id}.json\"")
1641        );
1642        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1643        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1644        assert_eq!(parsed[0]["tick"], 0);
1645        assert_eq!(parsed[0]["pv"], 50.0);
1646    }
1647
1648    #[tokio::test]
1649    async fn export_run_404s_for_an_unknown_id() {
1650        let app = router().with_state(crate::test_support::in_memory_state().await);
1651        let response = app
1652            .oneshot(
1653                Request::get("/api/runs/999999/export")
1654                    .body(Body::empty())
1655                    .unwrap(),
1656            )
1657            .await
1658            .unwrap();
1659        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1660    }
1661
1662    #[tokio::test]
1663    async fn export_run_404s_for_a_run_with_no_recorded_samples() {
1664        let state = crate::test_support::in_memory_state().await;
1665        let run_id = seed_one_run(&state).await;
1666        let app = router().with_state(state);
1667
1668        let response = app
1669            .oneshot(
1670                Request::get(format!("/api/runs/{run_id}/export"))
1671                    .body(Body::empty())
1672                    .unwrap(),
1673            )
1674            .await
1675            .unwrap();
1676        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1677    }
1678
1679    #[tokio::test]
1680    async fn delete_run_removes_the_run_and_returns_204() {
1681        let state = crate::test_support::in_memory_state().await;
1682        let run_id = seed_one_run(&state).await;
1683        // `seed_one_run` leaves `outcome = running` (it never calls `complete`/`fail`/
1684        // `abort`); a real deletable run must have finished first.
1685        TuneRunRow::complete(&state.pool, run_id, Utc::now())
1686            .await
1687            .unwrap();
1688        let pool = state.pool.clone();
1689        let app = router().with_state(state);
1690
1691        let response = app
1692            .clone()
1693            .oneshot(
1694                Request::delete(format!("/api/runs/{run_id}"))
1695                    .body(Body::empty())
1696                    .unwrap(),
1697            )
1698            .await
1699            .unwrap();
1700        assert_eq!(response.status(), StatusCode::NO_CONTENT);
1701        assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_none());
1702
1703        // A follow-up GET for the same id now 404s -- proves the row is really gone, not
1704        // just hidden.
1705        let follow_up = app
1706            .oneshot(
1707                Request::get(format!("/api/runs/{run_id}"))
1708                    .body(Body::empty())
1709                    .unwrap(),
1710            )
1711            .await
1712            .unwrap();
1713        assert_eq!(follow_up.status(), StatusCode::NOT_FOUND);
1714    }
1715
1716    #[tokio::test]
1717    async fn delete_run_404s_for_an_unknown_id() {
1718        let app = router().with_state(crate::test_support::in_memory_state().await);
1719        let response = app
1720            .oneshot(
1721                Request::delete("/api/runs/999999")
1722                    .body(Body::empty())
1723                    .unwrap(),
1724            )
1725            .await
1726            .unwrap();
1727        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1728    }
1729
1730    #[tokio::test]
1731    async fn delete_run_409s_when_the_run_has_not_finished_yet() {
1732        let state = crate::test_support::in_memory_state().await;
1733        // `seed_one_run` leaves `outcome = running` -- proves `delete_run` rejects a run
1734        // based on its own durable DB outcome, with no `ActiveRun` bookkeeping involved at
1735        // all (nothing here ever reserves a slot).
1736        let run_id = seed_one_run(&state).await;
1737        let pool = state.pool.clone();
1738        let app = router().with_state(state);
1739
1740        let response = app
1741            .oneshot(
1742                Request::delete(format!("/api/runs/{run_id}"))
1743                    .body(Body::empty())
1744                    .unwrap(),
1745            )
1746            .await
1747            .unwrap();
1748        assert_eq!(response.status(), StatusCode::CONFLICT);
1749        // Still present -- the conflict must short-circuit before any delete is attempted.
1750        assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_some());
1751    }
1752
1753    #[tokio::test]
1754    async fn delete_run_succeeds_for_a_completed_run_even_if_active_run_has_not_released_it_yet() {
1755        // Regression test for the race this guard was rewritten to close: `drive()` persists
1756        // a run's terminal outcome to the DB *before* returning, and `ActiveRun::release` only
1757        // runs strictly after `drive()` returns (see `routes::runs::start_run`), so there is a
1758        // real window where a run is already durably `completed` but `ActiveRun` still reports
1759        // it as the active run. `delete_run` must succeed here regardless, since it checks the
1760        // run's own DB outcome rather than `ActiveRun`.
1761        let state = crate::test_support::in_memory_state().await;
1762        let run_id = seed_one_run(&state).await;
1763        TuneRunRow::complete(&state.pool, run_id, Utc::now())
1764            .await
1765            .unwrap();
1766        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
1767        state
1768            .active_run
1769            .start(run_id, handle, std::future::pending())
1770            .await
1771            .unwrap();
1772        let pool = state.pool.clone();
1773        let app = router().with_state(state);
1774
1775        let response = app
1776            .oneshot(
1777                Request::delete(format!("/api/runs/{run_id}"))
1778                    .body(Body::empty())
1779                    .unwrap(),
1780            )
1781            .await
1782            .unwrap();
1783        assert_eq!(response.status(), StatusCode::NO_CONTENT);
1784        assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_none());
1785    }
1786
1787    #[tokio::test]
1788    async fn delete_run_returns_404_when_the_row_vanishes_after_lookup() {
1789        let state = crate::test_support::in_memory_state().await;
1790        let run_id = seed_one_run(&state).await;
1791        TuneRunRow::complete(&state.pool, run_id, Utc::now())
1792            .await
1793            .unwrap();
1794
1795        let result = delete_run_with_hook(state.clone(), run_id, |state| {
1796            let pool = state.pool.clone();
1797            async move {
1798                assert!(TuneRunRow::delete(&pool, run_id).await.unwrap());
1799            }
1800        })
1801        .await;
1802
1803        assert!(
1804            matches!(result, Err(ApiError::NotFound(message)) if message.contains(&run_id.to_string()))
1805        );
1806    }
1807}