Skip to main content

bhtune_server/routes/
runs.rs

1//! `POST /api/runs` (start a new tune run) and `POST /api/runs/{id}/cancel` (request its
2//! cancellation) -- the write side of the run-history API `routes::history` reads from.
3//!
4//! Reuses `bhtune-cli`'s own [`bhtune_cli::commands::tune::prepare`]/[`bhtune_cli::commands::tune::drive`]
5//! split unchanged, so a run started over HTTP goes through exactly the same template
6//! lookup, tag derivation, driver connection, quality checks, restore-on-abort, and
7//! write-back rollback as a run started by the CLI -- only the setup/reporting differs (see
8//! those functions' own doc comments for the full rationale). `crate::active_run` tracks
9//! every in-flight run so each can be cancelled independently.
10
11use axum::extract::{Path, State};
12use axum::http::StatusCode;
13use axum::routing::{post, put};
14use axum::{Json, Router};
15use bhtune_cli::args::{DriverKindArg, TuneArgs};
16use bhtune_cli::cancel::CtrlC;
17use bhtune_cli::commands::tune::{
18    PidWriteOutcome, drive, pid_parameters_for_result, prepare, write_pid_values,
19};
20use bhtune_cli::output::OutputFormat;
21use bhtune_core::{
22    ControllerDirection, ControllerType, ProcessType, ResponseLevel, TagOverrides, opc_write_values,
23};
24use bhtune_db::models::{
25    TuneDriver, TuneOutcome, TuneResultRow, TuneRunRow, TuneWriteRow, WriteKind, WriteReadback,
26};
27use bhtune_driver::OpcDaDriver;
28use chrono::Utc;
29use serde::{Deserialize, Serialize};
30use utoipa::ToSchema;
31
32use crate::active_run::RunAlreadyActive;
33use crate::error::{ApiError, ErrorBody};
34use crate::routes::history::{RunDetailResponse, build_run_detail};
35use crate::state::AppState;
36
37fn default_sim_gain() -> f32 {
38    1.0
39}
40fn default_sim_tau() -> f32 {
41    2.0
42}
43fn default_sim_dead_time() -> f32 {
44    5.0
45}
46fn default_sim_initial_value() -> f32 {
47    50.0
48}
49
50/// The body of `POST /api/runs` contains the per-run tune inputs. Operational timing values
51/// are intentionally absent: they are resolved from the global `[tuning]` configuration by
52/// `prepare()`, just as they are for a CLI invocation. Every field that has a CLI default
53/// (`--sim-gain`, etc.) repeats that exact default here via `#[serde(default = "...")]`, so an
54/// HTTP caller that omits a field gets identical behavior to a CLI invocation that omits the
55/// matching flag. `Option<T>` fields need no `#[serde(default)]` of their own -- serde already
56/// treats a missing key as `None` for an `Option` field.
57///
58/// Also derives `Serialize` so the exact same type can serve as `GET /api/runs/last-request`'s
59/// response (`ui-prefill-last-run`, in `routes::history::last_request`): that endpoint parses
60/// a run's stored `request_json` straight into a `StartRunRequest` rather than duplicating
61/// its ~30 fields into a second struct, giving a "what you `GET` is exactly what you'd `POST`
62/// to repeat it" symmetry in both the Rust types and the generated OpenAPI schema. This is
63/// safe precisely because `request_json` is *already* built to this exact shape --
64/// `bhtune-cli`'s `RequestSnapshot` doc comment describes the two as kept in sync by
65/// convention.
66#[derive(Debug, Serialize, Deserialize, ToSchema)]
67pub struct StartRunRequest {
68    /// PV tag prefix; ignored for `driver: "simulator"`. See [`TuneArgs::tagname`].
69    pub tagname: String,
70    /// DCS/PLC template name (see `GET /api/templates`).
71    pub template: String,
72    pub process_type: ProcessType,
73    pub controller_type: ControllerType,
74    /// Relay amplitude, as a percentage of the MV range.
75    pub relay_amp: f32,
76    /// Relay cycles to skip before counting begins (default: looked up per `process_type`).
77    pub cycles_skip: Option<u32>,
78    /// Relay cycles to count once the skip period ends (default: looked up per
79    /// `process_type`).
80    pub cycles_count: Option<u32>,
81    /// Seconds a switch must persist before it's accepted (default: looked up per
82    /// `process_type`).
83    pub noise_protection_secs: Option<u32>,
84    /// Which driver drives this tune. `"replay"` is rejected -- that driver exists only
85    /// for offline golden-trace validation, not for starting a live/simulated run.
86    pub driver: TuneDriver,
87    /// opcda-bridge gateway address. Only meaningful with `driver: "opcda"` (default:
88    /// resolved the same way the CLI resolves `--bridge-host`, via this process's own
89    /// config/env).
90    pub bridge_host: Option<String>,
91    /// OPC DA server ProgID. Required with `driver: "opcda"`.
92    pub server: Option<String>,
93    /// Simulator process gain (`driver: "simulator"` only).
94    #[serde(default = "default_sim_gain")]
95    pub sim_gain: f32,
96    /// Simulator process time constant, in seconds (`driver: "simulator"` only).
97    #[serde(default = "default_sim_tau")]
98    pub sim_tau: f32,
99    /// Simulator dead time, in seconds (`driver: "simulator"` only).
100    #[serde(default = "default_sim_dead_time")]
101    pub sim_dead_time: f32,
102    /// Simulator measurement noise amplitude (`driver: "simulator"` only).
103    #[serde(default)]
104    pub sim_noise: f32,
105    /// Simulator RNG seed, for reproducible noise (`driver: "simulator"` only).
106    #[serde(default)]
107    pub sim_seed: u64,
108    /// Simulator initial PV (`driver: "simulator"` only).
109    #[serde(default = "default_sim_initial_value")]
110    pub sim_initial_pv: f32,
111    /// Simulator initial MV (`driver: "simulator"` only).
112    #[serde(default = "default_sim_initial_value")]
113    pub sim_initial_mv: f32,
114    /// Fixed PV range high, overriding a live tag read. Required for `driver: "simulator"`,
115    /// which has no range tags at all.
116    pub pv_range_high: Option<f32>,
117    /// Fixed PV range low, overriding a live tag read.
118    pub pv_range_low: Option<f32>,
119    /// Fixed MV range high, overriding a live tag read.
120    pub mv_range_high: Option<f32>,
121    /// Fixed MV range low, overriding a live tag read.
122    pub mv_range_low: Option<f32>,
123    /// Fixed controller direction, overriding a live tag read.
124    pub direction: Option<ControllerDirection>,
125    /// Per-tune replacements for template-derived OPC tag names. Blank or missing fields use
126    /// the template-derived tag.
127    pub tag_overrides: Option<TagOverrides>,
128    /// Operator notes to attach to this run. Notes can be edited or cleared later through
129    /// the run-history endpoints.
130    #[serde(default)]
131    pub notes: Option<String>,
132    /// Confirm an unattended PID write-back. Required alongside `write_pid` -- the request
133    /// is rejected otherwise, identically to `--write-pid` without `--yes` on the CLI.
134    #[serde(default)]
135    pub yes: bool,
136    /// Non-interactively write this response level's calculated PID parameters back to the
137    /// DCS. Requires `yes: true`.
138    pub write_pid: Option<ResponseLevel>,
139}
140
141/// `value.is_finite()`, as an [`ApiError::BadRequest`] on failure -- the HTTP-path
142/// equivalent of `bhtune-cli`'s `finite_f32` clap `value_parser`, which never runs for a
143/// [`TuneArgs`] built directly in Rust code rather than parsed from `std::env::args()`. Well-
144/// formed JSON can still produce a non-finite `f32` here: a numeric literal within JSON's own
145/// unbounded range (e.g. `1e40`) silently saturates to `f32::INFINITY` on conversion, with no
146/// parse error from `serde_json` -- so this check is a real gap this DTO must close, not
147/// belt-and-suspenders.
148fn require_finite(field: &str, value: f32) -> Result<(), ApiError> {
149    if value.is_finite() {
150        Ok(())
151    } else {
152        Err(ApiError::BadRequest(format!(
153            "'{field}' must be a finite number (not NaN or infinite), got {value}"
154        )))
155    }
156}
157
158fn require_finite_if_some(field: &str, value: Option<f32>) -> Result<(), ApiError> {
159    match value {
160        Some(v) => require_finite(field, v),
161        None => Ok(()),
162    }
163}
164
165impl StartRunRequest {
166    /// Validates and converts this request into a [`TuneArgs`], ready for
167    /// [`bhtune_cli::commands::tune::prepare`].
168    ///
169    /// Only validates numeric fields whose clap `value_parser`s are bypassed when a
170    /// [`TuneArgs`] is built directly in Rust code. Operational timing values are not
171    /// request fields; `prepare()` resolves and validates the global configuration before
172    /// connecting to a driver or mutating the database/live loop.
173    pub(crate) fn into_tune_args(self) -> Result<TuneArgs, ApiError> {
174        require_finite("relay_amp", self.relay_amp)?;
175        require_finite("sim_gain", self.sim_gain)?;
176        require_finite("sim_tau", self.sim_tau)?;
177        require_finite("sim_dead_time", self.sim_dead_time)?;
178        require_finite("sim_noise", self.sim_noise)?;
179        require_finite("sim_initial_pv", self.sim_initial_pv)?;
180        require_finite("sim_initial_mv", self.sim_initial_mv)?;
181        require_finite_if_some("pv_range_high", self.pv_range_high)?;
182        require_finite_if_some("pv_range_low", self.pv_range_low)?;
183        require_finite_if_some("mv_range_high", self.mv_range_high)?;
184        require_finite_if_some("mv_range_low", self.mv_range_low)?;
185        if let Some(tag_overrides) = &self.tag_overrides {
186            tag_overrides
187                .validate()
188                .map_err(|error| ApiError::BadRequest(error.to_string()))?;
189        }
190
191        let driver = DriverKindArg::try_from(self.driver)
192            .map_err(|e| ApiError::BadRequest(e.to_string()))?;
193
194        Ok(TuneArgs {
195            tagname: self.tagname,
196            template: self.template,
197            process_type: self.process_type.into(),
198            controller_type: self.controller_type.into(),
199            relay_amp: self.relay_amp,
200            cycles_skip: self.cycles_skip,
201            cycles_count: self.cycles_count,
202            noise_protection_secs: self.noise_protection_secs,
203            driver,
204            bridge_host: self.bridge_host,
205            server: self.server,
206            sim_gain: self.sim_gain,
207            sim_tau: self.sim_tau,
208            sim_dead_time: self.sim_dead_time,
209            sim_noise: self.sim_noise,
210            sim_seed: self.sim_seed,
211            sim_initial_pv: self.sim_initial_pv,
212            sim_initial_mv: self.sim_initial_mv,
213            pv_range_high: self.pv_range_high,
214            pv_range_low: self.pv_range_low,
215            mv_range_high: self.mv_range_high,
216            mv_range_low: self.mv_range_low,
217            direction: self.direction.map(Into::into),
218            tag_overrides: self.tag_overrides,
219            notes: self.notes,
220            yes: self.yes,
221            write_pid: self.write_pid.map(Into::into),
222            // `drive()`'s doc comment requires `Json` for every HTTP-started run: `execute`'s
223            // interactive write-back prompt (`maybe_write_back`) only skips reading stdin
224            // when `output == OutputFormat::Json`, and this background task has no stdin to
225            // read from at all.
226            output: OutputFormat::Json,
227        })
228    }
229}
230
231/// Start a new tune run.
232///
233/// `POST /api/runs` -- runs `prepare()` (template lookup, tag derivation, driver connect,
234/// and the `tune_runs` insert) inline and returns as soon as that succeeds, having already
235/// `tokio::spawn`ed the actual polling/tuning phase in the background. `201 Created` carries
236/// the same [`RunDetailResponse`] `GET /api/runs/{id}` would show for this run at this
237/// instant (almost certainly still `outcome: "running"`) -- poll that endpoint, or use
238/// `POST /api/runs/{id}/cancel`, to follow the run to completion.
239///
240/// `409 Conflict` if an exclusive post-hoc PID write/revert is active; independent tune runs
241/// may execute concurrently.
242#[utoipa::path(
243    post,
244    path = "/api/runs",
245    tag = "runs",
246    request_body = StartRunRequest,
247    responses(
248        (status = 201, description = "The run was started; detail reflects its state right now.", body = RunDetailResponse),
249        (status = 400, description = "The request failed validation, or `prepare()` itself failed (unknown template, invalid flag combination, unreachable driver).", body = ErrorBody),
250        (status = 409, description = "An exclusive PID write/revert is already active.", body = ErrorBody),
251    ),
252)]
253pub(crate) async fn start_run(
254    State(state): State<AppState>,
255    Json(request): Json<StartRunRequest>,
256) -> Result<(StatusCode, Json<RunDetailResponse>), ApiError> {
257    start_run_with_hook(state, request, |_| async {}).await
258}
259
260async fn start_run_with_hook<F, Fut>(
261    state: AppState,
262    request: StartRunRequest,
263    after_prepare: F,
264) -> Result<(StatusCode, Json<RunDetailResponse>), ApiError>
265where
266    F: FnOnce(&AppState) -> Fut,
267    Fut: std::future::Future<Output = ()>,
268{
269    // Optimistic pre-check: avoids a wasted `prepare()` call while a post-hoc PID write/revert
270    // is holding the exclusive live-loop reservation. It deliberately does not reject an
271    // already-running tune: independent tunes are allowed to execute concurrently.
272    if let Some(active_id) = state.active_run.exclusive_id().await {
273        return Err(ApiError::Conflict(format!(
274            "run {active_id} has an exclusive PID write/revert in progress; wait for it to finish before starting another tune"
275        )));
276    }
277
278    let args = request.into_tune_args()?;
279
280    // `prepare()`'s own doc comment: its failures (bad template name, `--write-pid` without
281    // `--yes`, an unreachable driver) are "exactly the kind of problem an HTTP client
282    // expects a synchronous error response for" -- so they map to `400`, not the generic
283    // `500` a bare `?`/`Internal` conversion would give.
284    let app_config = state.config_snapshot()?;
285    let prepared = prepare(&state.pool, args, &app_config)
286        .await
287        .map_err(|e| ApiError::BadRequest(e.to_string()))?;
288    let run_id = prepared.run_id();
289    after_prepare(&state).await;
290
291    let (ctrl_c, cancel_handle) = CtrlC::manual();
292    let pool_for_task = state.pool.clone();
293    let task = async move {
294        let mut ctrl_c = ctrl_c;
295        // `drive()` already records every outcome (completion, abort, failure) to the
296        // `tune_runs` row itself; this task has no caller left to report a `Result` to, so
297        // its own `Err` is intentionally discarded here.
298        let _ = drive(&pool_for_task, prepared, &mut ctrl_c).await;
299    };
300
301    // The authoritative check: if this loses the race (a post-hoc write/revert reserved the
302    // live-loop operation between the pre-check above and here), the just-inserted row is
303    // marked `failed` rather than left forever showing an outcome it never actually reached.
304    if let Err(RunAlreadyActive { run_id: existing }) =
305        state.active_run.start(run_id, cancel_handle, task).await
306    {
307        let failure_reason = format!(
308            "run {existing} has an exclusive PID write/revert in progress; no tune task was started"
309        );
310        TuneRunRow::fail(&state.pool, run_id, Utc::now(), &failure_reason).await?;
311        return Err(ApiError::Conflict(failure_reason));
312    }
313
314    let detail = build_run_detail(&state.pool, run_id).await?.expect(
315        "the tune_runs row this handler just inserted via prepare() must exist immediately \
316         afterward",
317    );
318    Ok((StatusCode::CREATED, Json(detail)))
319}
320
321/// Request cancellation of a run, exactly as if Ctrl+C had been pressed against an
322/// equivalent CLI-driven run.
323///
324/// `POST /api/runs/{id}/cancel` -- `404` if no run has that id; otherwise always `204`,
325/// whether or not the run was actually active at the moment this was called (a run that
326/// already finished simply has nothing left to cancel). Cancellation is asynchronous: the
327/// run's background task still has to observe it, stop polling, and run its restore --
328/// `GET /api/runs/{id}` shows the eventual outcome.
329#[utoipa::path(
330    post,
331    path = "/api/runs/{id}/cancel",
332    tag = "runs",
333    params(
334        ("id" = i64, Path, description = "Run id"),
335    ),
336    responses(
337        (status = 204, description = "Cancellation requested (or the run was already inactive)."),
338        (status = 404, description = "No run with that id.", body = ErrorBody),
339    ),
340)]
341pub(crate) async fn cancel_run(
342    State(state): State<AppState>,
343    Path(run_id): Path<i64>,
344) -> Result<StatusCode, ApiError> {
345    if TuneRunRow::get(&state.pool, run_id).await?.is_none() {
346        return Err(ApiError::NotFound(format!("no run with id {run_id}")));
347    }
348    state.active_run.cancel(run_id).await;
349    Ok(StatusCode::NO_CONTENT)
350}
351
352/// The body of `PUT /api/runs/{id}/notes`.
353#[derive(Debug, Deserialize, ToSchema)]
354pub struct UpdateNotesRequest {
355    /// Replacement note text. Blank or whitespace-only text clears the note.
356    pub notes: String,
357}
358
359fn normalized_notes(notes: String) -> Option<String> {
360    let trimmed = notes.trim();
361    (!trimmed.is_empty()).then(|| trimmed.to_string())
362}
363
364/// Replace the operator notes attached to a run.
365///
366/// `PUT /api/runs/{id}/notes` deliberately works for both running and terminal runs. Notes
367/// are metadata, not a plant mutation, so they do not take the active-run registry reservation.
368#[utoipa::path(
369    put,
370    path = "/api/runs/{id}/notes",
371    tag = "runs",
372    params(
373        ("id" = i64, Path, description = "Run id"),
374    ),
375    request_body = UpdateNotesRequest,
376    responses(
377        (status = 200, description = "The run with its updated notes.", body = RunDetailResponse),
378        (status = 404, description = "No run with that id.", body = ErrorBody),
379    ),
380)]
381pub(crate) async fn update_notes(
382    State(state): State<AppState>,
383    Path(run_id): Path<i64>,
384    Json(request): Json<UpdateNotesRequest>,
385) -> Result<Json<RunDetailResponse>, ApiError> {
386    update_notes_with_hook(state, run_id, request, |_| async {}).await
387}
388
389#[derive(Clone, Copy, PartialEq, Eq)]
390enum NotesHookStage {
391    AfterLookup,
392    AfterUpdate,
393}
394
395async fn update_notes_with_hook<F, Fut>(
396    state: AppState,
397    run_id: i64,
398    request: UpdateNotesRequest,
399    mut hook: F,
400) -> Result<Json<RunDetailResponse>, ApiError>
401where
402    F: FnMut(NotesHookStage) -> Fut,
403    Fut: std::future::Future<Output = ()>,
404{
405    TuneRunRow::get(&state.pool, run_id)
406        .await?
407        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
408    hook(NotesHookStage::AfterLookup).await;
409    TuneRunRow::update_notes(
410        &state.pool,
411        run_id,
412        normalized_notes(request.notes).as_deref(),
413    )
414    .await?;
415    hook(NotesHookStage::AfterUpdate).await;
416    build_run_detail(&state.pool, run_id)
417        .await?
418        .map(Json)
419        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
420}
421
422/// Clear a run's operator notes.
423///
424/// `DELETE /api/runs/{id}/notes` is idempotent and works while a run is active or after it
425/// finishes.
426#[utoipa::path(
427    delete,
428    path = "/api/runs/{id}/notes",
429    tag = "runs",
430    params(
431        ("id" = i64, Path, description = "Run id"),
432    ),
433    responses(
434        (status = 200, description = "The run with its notes cleared.", body = RunDetailResponse),
435        (status = 404, description = "No run with that id.", body = ErrorBody),
436    ),
437)]
438pub(crate) async fn delete_notes(
439    State(state): State<AppState>,
440    Path(run_id): Path<i64>,
441) -> Result<Json<RunDetailResponse>, ApiError> {
442    delete_notes_with_hook(state, run_id, |_| async {}).await
443}
444
445async fn delete_notes_with_hook<F, Fut>(
446    state: AppState,
447    run_id: i64,
448    after_update: F,
449) -> Result<Json<RunDetailResponse>, ApiError>
450where
451    F: FnOnce(&AppState) -> Fut,
452    Fut: std::future::Future<Output = ()>,
453{
454    TuneRunRow::get(&state.pool, run_id)
455        .await?
456        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
457    TuneRunRow::update_notes(&state.pool, run_id, None).await?;
458    after_update(&state).await;
459    build_run_detail(&state.pool, run_id)
460        .await?
461        .map(Json)
462        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
463}
464
465/// The body of `POST /api/runs/{id}/write`.
466#[derive(Debug, Deserialize, ToSchema)]
467pub struct WriteRunRequest {
468    /// Which of the run's three calculated candidate result sets to write.
469    pub response_level: ResponseLevel,
470}
471
472/// Checks that `run` is eligible for a post-hoc PID write or revert (`api-post-run-write`):
473/// finished (not still running its own test), used the `opcda` driver, has PID constant
474/// tags in its snapshotted [`bhtune_core::LoopTags`], and recorded the OPC server/bridge
475/// host it actually connected through. Shared by [`write_run`] and [`revert_run`] -- both
476/// need exactly the same eligibility, only the *target* values to write differ.
477fn require_writable_run(run: &TuneRunRow) -> Result<(), ApiError> {
478    if run.outcome == TuneOutcome::Running {
479        return Err(ApiError::BadRequest(format!(
480            "run {} is still running; wait for it to finish before writing or reverting PID \
481             constants",
482            run.id
483        )));
484    }
485    if run.driver != TuneDriver::Opcda {
486        return Err(ApiError::BadRequest(format!(
487            "run {} used the {:?} driver, which has no live loop to write PID constants to",
488            run.id, run.driver
489        )));
490    }
491    if run.tags.proportional_constant.is_none()
492        || run.tags.integral_constant.is_none()
493        || run.tags.derivative_constant.is_none()
494    {
495        return Err(ApiError::BadRequest(format!(
496            "run {}'s snapshotted tags have no PID constant tags configured",
497            run.id
498        )));
499    }
500    if run.opc_server.is_none() || run.bridge_host.is_none() {
501        return Err(ApiError::BadRequest(format!(
502            "run {} has no recorded OPC server/bridge host; refusing to guess which \
503             connection to use",
504            run.id
505        )));
506    }
507    Ok(())
508}
509
510/// Connects an [`OpcDaDriver`] using `run`'s own recorded `opc_server`/`bridge_host` --
511/// never re-resolved from this process's own config/flags, for exactly the reason
512/// `bhtune-cli`'s `commands::history::resolve_revert_connection` documents: a value
513/// re-resolved at write/revert time could silently point at a different gateway than the
514/// run itself actually used. [`require_writable_run`] must already have confirmed both
515/// fields are present.
516async fn connect_to_runs_recorded_driver(run: &TuneRunRow) -> Result<OpcDaDriver, ApiError> {
517    let opc_server = run
518        .opc_server
519        .as_deref()
520        .expect("require_writable_run already checked opc_server is Some");
521    let bridge_host = run
522        .bridge_host
523        .as_deref()
524        .expect("require_writable_run already checked bridge_host is Some");
525    OpcDaDriver::connect(bridge_host, opc_server)
526        .await
527        .map_err(|e| {
528            ApiError::BadRequest(format!(
529                "failed to connect to OPC server '{opc_server}' via bridge '{bridge_host}': {e}"
530            ))
531        })
532}
533
534/// Reserves the [`crate::active_run::ActiveRun`] exclusive write/revert reservation for
535/// `run.id`, connects, and calls [`write_pid_values`] -- releasing the reservation on every
536/// exit path (this project's established "no `Drop`-based cleanup, `Drop` cannot await" rule;
537/// see
538/// `crate::active_run::ActiveRun::reserve`'s own doc comment) -- then rebuilds and returns
539/// the run's fresh [`RunDetailResponse`] regardless of whether the write/revert itself
540/// succeeded. A [`PidWriteOutcome::Failed`] is not an HTTP error: the request was processed
541/// successfully and its result -- including the failure -- is recorded in the returned
542/// `writes[]` array's `success`/`error_message` fields, exactly how a client already reads a
543/// write-back outcome from `GET /api/runs/{id}`. Only [`ApiError::Conflict`] (a tune or another
544/// write/revert operation holds the exclusive reservation), [`ApiError::BadRequest`] (the driver connection itself
545/// failed), or [`ApiError::Internal`] (an unexpected database failure inside
546/// [`write_pid_values`]) short-circuit this into an actual error response.
547#[allow(clippy::too_many_arguments)]
548async fn reserve_connect_and_write(
549    state: &AppState,
550    run_id: i64,
551    run: &TuneRunRow,
552    p_tag: &str,
553    i_tag: &str,
554    d_tag: &str,
555    response_level: ResponseLevel,
556    target: WriteReadback,
557    kind: WriteKind,
558    allow_uncertain_quality: bool,
559) -> Result<RunDetailResponse, ApiError> {
560    reserve_connect_and_write_with_hook(
561        state,
562        run_id,
563        run,
564        p_tag,
565        i_tag,
566        d_tag,
567        response_level,
568        target,
569        kind,
570        allow_uncertain_quality,
571        |_| async {},
572    )
573    .await
574}
575
576#[allow(clippy::too_many_arguments)]
577async fn reserve_connect_and_write_with_hook<F, Fut>(
578    state: &AppState,
579    run_id: i64,
580    run: &TuneRunRow,
581    p_tag: &str,
582    i_tag: &str,
583    d_tag: &str,
584    response_level: ResponseLevel,
585    target: WriteReadback,
586    kind: WriteKind,
587    allow_uncertain_quality: bool,
588    after_release: F,
589) -> Result<RunDetailResponse, ApiError>
590where
591    F: FnOnce(&AppState) -> Fut,
592    Fut: std::future::Future<Output = ()>,
593{
594    reserve_connect_and_write_with_hooks(
595        state,
596        run_id,
597        run,
598        p_tag,
599        i_tag,
600        d_tag,
601        response_level,
602        target,
603        kind,
604        allow_uncertain_quality,
605        |_| async {},
606        after_release,
607    )
608    .await
609}
610
611#[allow(clippy::too_many_arguments)]
612async fn reserve_connect_and_write_with_hooks<F, Fut, G, Gut>(
613    state: &AppState,
614    run_id: i64,
615    run: &TuneRunRow,
616    p_tag: &str,
617    i_tag: &str,
618    d_tag: &str,
619    response_level: ResponseLevel,
620    target: WriteReadback,
621    kind: WriteKind,
622    allow_uncertain_quality: bool,
623    before_write: F,
624    after_release: G,
625) -> Result<RunDetailResponse, ApiError>
626where
627    F: FnOnce(&AppState) -> Fut,
628    Fut: std::future::Future<Output = ()>,
629    G: FnOnce(&AppState) -> Gut,
630    Gut: std::future::Future<Output = ()>,
631{
632    state
633        .active_run
634        .reserve(run_id)
635        .await
636        .map_err(|RunAlreadyActive { run_id: existing }| {
637            ApiError::Conflict(format!(
638                "run {existing} or another PID write/revert is active; try again once it finishes"
639            ))
640        })?;
641
642    let result: Result<PidWriteOutcome, ApiError> = async {
643        let driver = connect_to_runs_recorded_driver(run).await?;
644        before_write(state).await;
645        let outcome = write_pid_values(
646            &state.pool,
647            run_id,
648            &driver,
649            p_tag,
650            i_tag,
651            d_tag,
652            response_level,
653            target,
654            kind,
655            allow_uncertain_quality,
656        )
657        .await?;
658        Ok(outcome)
659    }
660    .await;
661
662    state.active_run.release(run_id).await;
663    after_release(state).await;
664    result?;
665
666    build_run_detail(&state.pool, run_id).await?.ok_or_else(|| {
667        ApiError::Internal(anyhow::anyhow!(
668            "run {run_id} vanished while its write/revert was being processed"
669        ))
670    })
671}
672
673/// Write one of a run's calculated candidate PID parameter sets back to the live loop.
674///
675/// `POST /api/runs/{id}/write` -- unlike the CLI's `--write-pid`, which can only fire once
676/// at the end of the run it belongs to, this can be called at any time after the run has
677/// finished, letting an engineer compare Sluggish/Moderate/Aggressive on screen before
678/// picking one. Pre-reads the selected tag's current P/I/D, writes and verifies each constant in
679/// turn, and rolls back to the pre-read values if a later constant is rejected
680/// (`safety-writeback-rollback`) -- recorded as a new write-back audit row exactly like an
681/// in-run write.
682///
683/// Always `200` once the request itself is valid and no conflicting operation is active,
684/// whether or not the write actually succeeded -- see [`reserve_connect_and_write`]'s doc
685/// comment for why a physical write failure is not a `4xx`/`5xx`.
686#[utoipa::path(
687    post,
688    path = "/api/runs/{id}/write",
689    tag = "runs",
690    params(
691        ("id" = i64, Path, description = "Run id"),
692    ),
693    request_body = WriteRunRequest,
694    responses(
695        (status = 200, description = "The write was attempted; see `writes[]` in the body for its outcome.", body = RunDetailResponse),
696        (status = 400, description = "The run isn't eligible for a post-hoc write (still running, wrong driver, no PID tags/connection recorded, or no calculated result for the requested response level), or the driver connection itself failed.", body = ErrorBody),
697        (status = 404, description = "No run with that id.", body = ErrorBody),
698        (status = 409, description = "A tune or another PID write/revert is already active.", body = ErrorBody),
699    ),
700)]
701pub(crate) async fn write_run(
702    State(state): State<AppState>,
703    Path(run_id): Path<i64>,
704    Json(request): Json<WriteRunRequest>,
705) -> Result<Json<RunDetailResponse>, ApiError> {
706    let run = TuneRunRow::get(&state.pool, run_id)
707        .await?
708        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
709    require_writable_run(&run)?;
710    let allow_uncertain_quality = state.config_snapshot()?.allow_uncertain_quality;
711
712    let results = TuneResultRow::list_for_run(&state.pool, run_id).await?;
713    let selected = results
714        .iter()
715        .find(|r| r.response_level == request.response_level)
716        .ok_or_else(|| {
717            ApiError::BadRequest(format!(
718                "run {run_id} has no calculated {:?} result to write",
719                request.response_level
720            ))
721        })?;
722
723    let pid = pid_parameters_for_result(selected)
724        .map_err(|error| ApiError::BadRequest(error.to_string()))?;
725    let written = opc_write_values(pid, run.config.controller_type, run.template.integral_type);
726    let target = WriteReadback {
727        proportional: written.proportional,
728        integral: written.integral,
729        derivative: written.derivative,
730    };
731
732    // `require_writable_run` already confirmed all three tags are `Some`.
733    let p_tag = run.tags.proportional_constant.clone().unwrap();
734    let i_tag = run.tags.integral_constant.clone().unwrap();
735    let d_tag = run.tags.derivative_constant.clone().unwrap();
736
737    let detail = reserve_connect_and_write(
738        &state,
739        run_id,
740        &run,
741        &p_tag,
742        &i_tag,
743        &d_tag,
744        request.response_level,
745        target,
746        WriteKind::Write,
747        allow_uncertain_quality,
748    )
749    .await?;
750    Ok(Json(detail))
751}
752
753/// Revert a run's most recent PID write-back, restoring the pre-write values it recorded.
754///
755/// `POST /api/runs/{id}/revert` -- no request body: like `POST /api/runs/{id}/cancel`, the
756/// GUI's own confirmation dialog (naming the tag, the tags, and the exact values from
757/// `writes[]`) is the human confirmation step, not a body field. Finds the run's last
758/// [`WriteKind::Write`] row regardless of whether it succeeded (matching
759/// `bhtune history revert`'s own semantics exactly), requiring it to have recorded pre-write
760/// values to revert to. A revert never attempts a nested rollback of itself if it fails
761/// partway through -- see [`WriteKind`]'s doc comment.
762///
763/// Always `200` once the request itself is valid and no conflicting operation is active; see
764/// [`reserve_connect_and_write`]'s doc comment for why a physical revert failure is not a
765/// `4xx`/`5xx`.
766#[utoipa::path(
767    post,
768    path = "/api/runs/{id}/revert",
769    tag = "runs",
770    params(
771        ("id" = i64, Path, description = "Run id"),
772    ),
773    responses(
774        (status = 200, description = "The revert was attempted; see `writes[]` in the body for its outcome.", body = RunDetailResponse),
775        (status = 400, description = "The run isn't eligible for a post-hoc revert (still running, wrong driver, no PID tags/connection recorded, no recorded write-back to revert, or its pre-write values were never recorded), or the driver connection itself failed.", body = ErrorBody),
776        (status = 404, description = "No run with that id.", body = ErrorBody),
777        (status = 409, description = "A tune or another PID write/revert is already active.", body = ErrorBody),
778    ),
779)]
780pub(crate) async fn revert_run(
781    State(state): State<AppState>,
782    Path(run_id): Path<i64>,
783) -> Result<Json<RunDetailResponse>, ApiError> {
784    let run = TuneRunRow::get(&state.pool, run_id)
785        .await?
786        .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
787    require_writable_run(&run)?;
788    let allow_uncertain_quality = state.config_snapshot()?.allow_uncertain_quality;
789
790    let writes = TuneWriteRow::list_for_run(&state.pool, run_id).await?;
791    let last_write = writes
792        .iter()
793        .rev()
794        .find(|w| w.kind == WriteKind::Write)
795        .ok_or_else(|| {
796            ApiError::BadRequest(format!(
797                "run {run_id} has no recorded PID write-back to revert"
798            ))
799        })?;
800    let response_level = last_write.response_level;
801    let target = last_write.previous.ok_or_else(|| {
802        ApiError::BadRequest(format!(
803            "run {run_id}'s {response_level:?} PID write-back never recorded pre-write values; \
804             nothing to revert to"
805        ))
806    })?;
807
808    // `require_writable_run` already confirmed all three tags are `Some`.
809    let p_tag = run.tags.proportional_constant.clone().unwrap();
810    let i_tag = run.tags.integral_constant.clone().unwrap();
811    let d_tag = run.tags.derivative_constant.clone().unwrap();
812
813    let detail = reserve_connect_and_write(
814        &state,
815        run_id,
816        &run,
817        &p_tag,
818        &i_tag,
819        &d_tag,
820        response_level,
821        target,
822        WriteKind::Revert,
823        allow_uncertain_quality,
824    )
825    .await?;
826    Ok(Json(detail))
827}
828
829pub fn router() -> Router<AppState> {
830    Router::new()
831        .route("/api/runs", post(start_run))
832        .route("/api/runs/{id}/cancel", post(cancel_run))
833        .route(
834            "/api/runs/{id}/notes",
835            put(update_notes).delete(delete_notes),
836        )
837        .route("/api/runs/{id}/write", post(write_run))
838        .route("/api/runs/{id}/revert", post(revert_run))
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use axum::body::{Body, to_bytes};
845    use axum::http::Request;
846    use bhtune_core::LoopConfig;
847    use bhtune_db::models::{Pagination, TuneRunFilter};
848    use tokio::sync::oneshot;
849    use tower::ServiceExt;
850
851    /// A fast-converging simulator-backed request body, mirroring `bhtune-cli`'s own
852    /// `fast_simulator_args()` test fixture for the per-run inputs. Global timing is supplied
853    /// by `in_memory_state()`'s fast test configuration.
854    fn fast_simulator_request_json() -> serde_json::Value {
855        serde_json::json!({
856            "tagname": "ignored-for-simulator",
857            "template": "Yokogawa CentumVP",
858            "process_type": "flow",
859            "controller_type": "pi",
860            "relay_amp": 10.0,
861            "cycles_skip": 1,
862            "cycles_count": 2,
863            "noise_protection_secs": 0,
864            "driver": "simulator",
865            "sim_gain": 1.0,
866            "sim_tau": 0.01,
867            "sim_dead_time": 0.025,
868            "pv_range_high": 100.0,
869            "pv_range_low": 0.0,
870            "mv_range_high": 100.0,
871            "mv_range_low": 0.0,
872            "direction": "reverse",
873            "notes": "http test note",
874        })
875    }
876
877    async fn post_json(
878        app: axum::Router,
879        path: &str,
880        body: serde_json::Value,
881    ) -> axum::http::Response<Body> {
882        app.oneshot(
883            Request::post(path)
884                .header(axum::http::header::CONTENT_TYPE, "application/json")
885                .body(Body::from(serde_json::to_vec(&body).unwrap()))
886                .unwrap(),
887        )
888        .await
889        .unwrap()
890    }
891
892    async fn body_json(response: axum::http::Response<Body>) -> serde_json::Value {
893        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
894        serde_json::from_slice(&bytes).unwrap()
895    }
896
897    /// Polls `GET /api/runs/{id}` (via the merged `history` router) until `outcome` is no
898    /// longer `"running"`, bounded so a real bug can't hang the test suite forever.
899    async fn wait_for_outcome(state: &AppState, run_id: i64) -> serde_json::Value {
900        wait_for_outcome_with_timeout(state, run_id, std::time::Duration::from_secs(10)).await
901    }
902
903    async fn wait_for_outcome_with_timeout(
904        state: &AppState,
905        run_id: i64,
906        timeout: std::time::Duration,
907    ) -> serde_json::Value {
908        let deadline = tokio::time::Instant::now() + timeout;
909        loop {
910            let app = crate::build_router(state.clone());
911            let response = app
912                .oneshot(
913                    Request::get(format!("/api/runs/{run_id}"))
914                        .body(Body::empty())
915                        .unwrap(),
916                )
917                .await
918                .unwrap();
919            let detail = body_json(response).await;
920            if detail["outcome"] != "running" {
921                return detail;
922            }
923            if tokio::time::Instant::now() >= deadline {
924                panic!("run {run_id} did not leave 'running' within {timeout:?}: {detail:?}");
925            }
926            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
927        }
928    }
929
930    async fn wait_until_inactive(state: &AppState, run_id: i64) {
931        tokio::time::timeout(std::time::Duration::from_secs(1), async {
932            while state.active_run.active_run_ids().await.contains(&run_id) {
933                tokio::task::yield_now().await;
934            }
935        })
936        .await
937        .expect("a terminal run should release its active-run registration");
938    }
939
940    #[tokio::test]
941    async fn wait_for_outcome_panics_after_an_injected_deadline() {
942        let state = crate::test_support::in_memory_state().await;
943        let run_id = start_opcda_run(&state).await;
944        let state_for_task = state.clone();
945        let join = tokio::spawn(async move {
946            wait_for_outcome_with_timeout(&state_for_task, run_id, std::time::Duration::ZERO).await
947        });
948
949        let panic = join.await.expect_err("a zero deadline should panic");
950        assert!(panic.is_panic());
951    }
952
953    #[tokio::test]
954    async fn wait_until_inactive_observes_an_active_registration_before_release() {
955        let state = crate::test_support::in_memory_state().await;
956        let run_id = 42;
957        let (_ctrl_c, cancel) = bhtune_cli::cancel::CtrlC::manual();
958        let (finish_tx, finish_rx) = oneshot::channel();
959        state
960            .active_run
961            .start(run_id, cancel, async move {
962                finish_rx.await.unwrap();
963            })
964            .await
965            .unwrap();
966
967        let release = tokio::spawn(async move {
968            tokio::task::yield_now().await;
969            finish_tx.send(()).unwrap();
970        });
971        wait_until_inactive(&state, run_id).await;
972        release.await.unwrap();
973    }
974
975    #[tokio::test]
976    async fn starting_a_simulator_run_returns_201_and_it_eventually_completes() {
977        let state = crate::test_support::in_memory_state().await;
978        let app = crate::build_router(state.clone());
979
980        let response = post_json(app, "/api/runs", fast_simulator_request_json()).await;
981        assert_eq!(response.status(), StatusCode::CREATED);
982        let detail = body_json(response).await;
983        let run_id = detail["id"].as_i64().expect("response must carry an id");
984        assert_eq!(detail["tag_name"], "ignored-for-simulator");
985
986        let final_detail = wait_for_outcome(&state, run_id).await;
987        assert_eq!(final_detail["outcome"], "completed");
988        assert_eq!(final_detail["results"].as_array().unwrap().len(), 3);
989        wait_until_inactive(&state, run_id).await;
990        assert!(state.active_run.reserve(999).await.is_ok());
991        state.active_run.release(999).await;
992    }
993
994    #[tokio::test]
995    async fn notes_can_be_edited_while_running_and_after_completion_then_deleted() {
996        let state = crate::test_support::in_memory_state().await;
997        let mut request = fast_simulator_request_json();
998        request["cycles_count"] = serde_json::json!(50);
999
1000        let response = post_json(crate::build_router(state.clone()), "/api/runs", request).await;
1001        assert_eq!(response.status(), StatusCode::CREATED);
1002        let run_id = body_json(response).await["id"].as_i64().unwrap();
1003
1004        let updated = crate::build_router(state.clone())
1005            .oneshot(
1006                Request::put(format!("/api/runs/{run_id}/notes"))
1007                    .header(axum::http::header::CONTENT_TYPE, "application/json")
1008                    .body(Body::from(
1009                        serde_json::to_vec(&serde_json::json!({
1010                            "notes": "edited while running"
1011                        }))
1012                        .unwrap(),
1013                    ))
1014                    .unwrap(),
1015            )
1016            .await
1017            .unwrap();
1018        assert_eq!(updated.status(), StatusCode::OK);
1019        assert_eq!(body_json(updated).await["notes"], "edited while running");
1020
1021        state.active_run.cancel(run_id).await;
1022        let final_detail = wait_for_outcome(&state, run_id).await;
1023        assert_eq!(final_detail["outcome"], "aborted");
1024
1025        let replaced = crate::build_router(state.clone())
1026            .oneshot(
1027                Request::put(format!("/api/runs/{run_id}/notes"))
1028                    .header(axum::http::header::CONTENT_TYPE, "application/json")
1029                    .body(Body::from(
1030                        serde_json::to_vec(&serde_json::json!({
1031                            "notes": "edited after completion"
1032                        }))
1033                        .unwrap(),
1034                    ))
1035                    .unwrap(),
1036            )
1037            .await
1038            .unwrap();
1039        assert_eq!(replaced.status(), StatusCode::OK);
1040        assert_eq!(
1041            body_json(replaced).await["notes"],
1042            "edited after completion"
1043        );
1044
1045        let cleared = crate::build_router(state.clone())
1046            .oneshot(
1047                Request::delete(format!("/api/runs/{run_id}/notes"))
1048                    .body(Body::empty())
1049                    .unwrap(),
1050            )
1051            .await
1052            .unwrap();
1053        assert_eq!(cleared.status(), StatusCode::OK);
1054        assert!(body_json(cleared).await["notes"].is_null());
1055    }
1056
1057    #[tokio::test]
1058    async fn run_route_lookups_propagate_database_failures_as_500() {
1059        let state = crate::test_support::in_memory_state().await;
1060        let app = crate::build_router(state.clone());
1061        state.pool.close().await;
1062
1063        let update = app
1064            .clone()
1065            .oneshot(
1066                Request::put("/api/runs/1/notes")
1067                    .header(axum::http::header::CONTENT_TYPE, "application/json")
1068                    .body(Body::from(r#"{"notes":"updated"}"#))
1069                    .unwrap(),
1070            )
1071            .await
1072            .unwrap();
1073        assert_eq!(update.status(), StatusCode::INTERNAL_SERVER_ERROR);
1074
1075        let delete = app
1076            .clone()
1077            .oneshot(
1078                Request::delete("/api/runs/1/notes")
1079                    .body(Body::empty())
1080                    .unwrap(),
1081            )
1082            .await
1083            .unwrap();
1084        assert_eq!(delete.status(), StatusCode::INTERNAL_SERVER_ERROR);
1085
1086        let write = post_json(
1087            app.clone(),
1088            "/api/runs/1/write",
1089            serde_json::json!({ "response_level": "moderate" }),
1090        )
1091        .await;
1092        assert_eq!(write.status(), StatusCode::INTERNAL_SERVER_ERROR);
1093
1094        let revert = post_empty(app, "/api/runs/1/revert").await;
1095        assert_eq!(revert.status(), StatusCode::INTERNAL_SERVER_ERROR);
1096    }
1097
1098    #[tokio::test]
1099    async fn note_routes_propagate_failures_after_each_successful_database_step() {
1100        let update_state = crate::test_support::in_memory_state().await;
1101        let update_run_id = start_opcda_run(&update_state).await;
1102        let update_pool = update_state.pool.clone();
1103        let update_result = update_notes_with_hook(
1104            update_state.clone(),
1105            update_run_id,
1106            UpdateNotesRequest {
1107                notes: "updated".to_string(),
1108            },
1109            move |_| {
1110                let pool = update_pool.clone();
1111                async move { pool.close().await }
1112            },
1113        )
1114        .await;
1115        assert!(matches!(update_result, Err(ApiError::Internal(_))));
1116
1117        let detail_state = crate::test_support::in_memory_state().await;
1118        let detail_run_id = start_opcda_run(&detail_state).await;
1119        let detail_pool = detail_state.pool.clone();
1120        let detail_result = update_notes_with_hook(
1121            detail_state.clone(),
1122            detail_run_id,
1123            UpdateNotesRequest {
1124                notes: "updated".to_string(),
1125            },
1126            move |stage| {
1127                let pool = detail_pool.clone();
1128                async move {
1129                    if stage == NotesHookStage::AfterUpdate {
1130                        pool.close().await;
1131                    }
1132                }
1133            },
1134        )
1135        .await;
1136        assert!(matches!(detail_result, Err(ApiError::Internal(_))));
1137
1138        let delete_state = crate::test_support::in_memory_state().await;
1139        let delete_run_id = start_opcda_run(&delete_state).await;
1140        let delete_result = delete_notes_with_hook(delete_state.clone(), delete_run_id, |state| {
1141            let pool = state.pool.clone();
1142            async move { pool.close().await }
1143        })
1144        .await;
1145        assert!(matches!(delete_result, Err(ApiError::Internal(_))));
1146    }
1147
1148    #[tokio::test]
1149    async fn starting_a_second_run_while_one_is_active_succeeds() {
1150        let state = crate::test_support::in_memory_state().await;
1151
1152        // Many cycles keep the first run active by the time the second request below is issued.
1153        let mut slow_request = fast_simulator_request_json();
1154        slow_request["cycles_count"] = serde_json::json!(50);
1155
1156        let first = post_json(
1157            crate::build_router(state.clone()),
1158            "/api/runs",
1159            slow_request,
1160        )
1161        .await;
1162        assert_eq!(first.status(), StatusCode::CREATED);
1163        let first_id = body_json(first).await["id"].as_i64().unwrap();
1164        assert_eq!(state.active_run.active_run_ids().await, vec![first_id]);
1165
1166        let second = post_json(
1167            crate::build_router(state.clone()),
1168            "/api/runs",
1169            fast_simulator_request_json(),
1170        )
1171        .await;
1172        assert_eq!(second.status(), StatusCode::CREATED);
1173        let second_id = body_json(second).await["id"].as_i64().unwrap();
1174        assert_ne!(second_id, first_id);
1175
1176        // Clean up rather than leaving the slow run to finish on its own 50-cycle schedule.
1177        state.active_run.cancel(first_id).await;
1178        wait_for_outcome(&state, first_id).await;
1179        wait_for_outcome(&state, second_id).await;
1180    }
1181
1182    /// Calling the handler directly (bypassing the router/tower stack) and racing two
1183    /// invocations with `tokio::join!` proves concurrent starts both survive their overlapping
1184    /// `prepare()` calls and register independent background tasks.
1185    #[tokio::test]
1186    async fn a_genuine_race_between_two_starts_creates_two_runs() {
1187        let state = crate::test_support::in_memory_state().await;
1188
1189        let mut request_a = fast_simulator_request_json();
1190        request_a["notes"] = serde_json::json!("racer-a");
1191        let mut request_b = fast_simulator_request_json();
1192        request_b["notes"] = serde_json::json!("racer-b");
1193
1194        let (result_a, result_b) = tokio::join!(
1195            start_run(
1196                State(state.clone()),
1197                Json(serde_json::from_value(request_a).unwrap())
1198            ),
1199            start_run(
1200                State(state.clone()),
1201                Json(serde_json::from_value(request_b).unwrap())
1202            ),
1203        );
1204
1205        let outcomes = [result_a, result_b];
1206        assert!(
1207            outcomes.iter().all(Result::is_ok),
1208            "both concurrent starts should succeed: {outcomes:?}"
1209        );
1210
1211        // Clean up both runs, whether either one finished before the other handler returned.
1212        for outcome in outcomes {
1213            let (_, Json(detail)) = outcome.unwrap();
1214            state.active_run.cancel(detail.id).await;
1215            wait_for_outcome(&state, detail.id).await;
1216        }
1217    }
1218
1219    #[tokio::test]
1220    async fn a_reservation_starting_after_prepare_marks_the_new_run_failed() {
1221        let state = crate::test_support::in_memory_state().await;
1222        let reservation_id = 9_999;
1223        let result = start_run_with_hook(
1224            state.clone(),
1225            serde_json::from_value(fast_simulator_request_json()).unwrap(),
1226            |state| {
1227                let active_run = state.active_run.clone();
1228                async move {
1229                    active_run.reserve(reservation_id).await.unwrap();
1230                }
1231            },
1232        )
1233        .await;
1234
1235        let error = result.unwrap_err();
1236        assert!(matches!(error, ApiError::Conflict(_)));
1237        let filter = TuneRunFilter::default();
1238        let runs = TuneRunRow::list(&state.pool, &filter, Pagination::default())
1239            .await
1240            .unwrap();
1241        assert_eq!(runs.len(), 1);
1242        assert_eq!(runs[0].outcome, TuneOutcome::Failed);
1243        assert!(
1244            runs[0]
1245                .failure_reason
1246                .as_deref()
1247                .unwrap()
1248                .contains("no tune task was started")
1249        );
1250        state.active_run.release(reservation_id).await;
1251    }
1252
1253    #[tokio::test]
1254    async fn starting_a_run_while_a_write_reservation_is_active_returns_409() {
1255        let state = crate::test_support::in_memory_state().await;
1256        let reservation_id = 9_998;
1257        state.active_run.reserve(reservation_id).await.unwrap();
1258
1259        let response = post_json(
1260            crate::build_router(state.clone()),
1261            "/api/runs",
1262            fast_simulator_request_json(),
1263        )
1264        .await;
1265        assert_eq!(response.status(), StatusCode::CONFLICT);
1266        assert!(
1267            body_json(response).await["error"]
1268                .as_str()
1269                .unwrap()
1270                .contains("exclusive PID write/revert")
1271        );
1272        state.active_run.release(reservation_id).await;
1273    }
1274
1275    #[tokio::test]
1276    async fn unknown_template_name_returns_400() {
1277        let app = crate::build_router(crate::test_support::in_memory_state().await);
1278        let mut request = fast_simulator_request_json();
1279        request["template"] = serde_json::json!("Not A Real Template");
1280
1281        let response = post_json(app, "/api/runs", request).await;
1282        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1283        let error = body_json(response).await;
1284        assert!(
1285            error["error"]
1286                .as_str()
1287                .unwrap()
1288                .contains("Not A Real Template")
1289        );
1290    }
1291
1292    #[tokio::test]
1293    async fn write_pid_without_yes_returns_400() {
1294        let app = crate::build_router(crate::test_support::in_memory_state().await);
1295        let mut request = fast_simulator_request_json();
1296        request["write_pid"] = serde_json::json!("aggressive");
1297        // `yes` omitted -- defaults to `false`.
1298
1299        let response = post_json(app, "/api/runs", request).await;
1300        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1301        let error = body_json(response).await;
1302        assert!(error["error"].as_str().unwrap().contains("--yes"));
1303    }
1304
1305    #[test]
1306    fn legacy_http_timing_fields_are_ignored() {
1307        let mut request = fast_simulator_request_json();
1308        request["mrft_delay"] = serde_json::json!(123);
1309        request["poll_interval_ms"] = serde_json::json!(1);
1310        request["timeout_secs"] = serde_json::json!(1);
1311        request["op_timeout_secs"] = serde_json::json!(1);
1312        request["restore_timeout_secs"] = serde_json::json!(1);
1313
1314        let parsed: StartRunRequest = serde_json::from_value(request).unwrap();
1315        let args = parsed
1316            .into_tune_args()
1317            .expect("legacy fields must not affect request parsing");
1318        assert_eq!(args.template, "Yokogawa CentumVP");
1319        assert_eq!(args.relay_amp, 10.0);
1320    }
1321
1322    #[tokio::test]
1323    async fn invalid_tag_override_returns_400_before_starting_a_run() {
1324        let state = crate::test_support::in_memory_state().await;
1325        let app = crate::build_router(state.clone());
1326        let mut request = fast_simulator_request_json();
1327        request["tag_overrides"] = serde_json::json!({
1328            "process_variable": "Loop\u{0000}PV"
1329        });
1330
1331        let response = post_json(app, "/api/runs", request).await;
1332        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1333        assert!(
1334            body_json(response).await["error"]
1335                .as_str()
1336                .unwrap()
1337                .contains("process_variable")
1338        );
1339        assert!(
1340            TuneRunRow::list(
1341                &state.pool,
1342                &TuneRunFilter::default(),
1343                Pagination::default(),
1344            )
1345            .await
1346            .unwrap()
1347            .is_empty()
1348        );
1349    }
1350
1351    #[tokio::test]
1352    async fn a_json_number_that_overflows_f32_to_infinity_is_rejected() {
1353        // `1e40` is well-formed JSON (an ordinary, if large, decimal literal) but silently
1354        // saturates to `f32::INFINITY` on conversion -- serde_json never errors on this, so
1355        // this proves `into_tune_args`'s manual finiteness check is a real gap being closed,
1356        // not redundant with what axum's `Json` extractor already rejects.
1357        let app = crate::build_router(crate::test_support::in_memory_state().await);
1358        let mut request = fast_simulator_request_json();
1359        request["sim_gain"] = serde_json::json!(1e40);
1360
1361        let response = post_json(app, "/api/runs", request).await;
1362        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1363        let error = body_json(response).await;
1364        assert!(error["error"].as_str().unwrap().contains("sim_gain"));
1365    }
1366
1367    /// Covers `require_finite_if_some`'s `Some` arm specifically -- the sibling test above
1368    /// only exercises `require_finite` directly (via `sim_gain`, a plain non-optional
1369    /// `f32`), never a genuinely optional range field.
1370    #[tokio::test]
1371    async fn a_non_finite_optional_range_field_is_also_rejected() {
1372        let app = crate::build_router(crate::test_support::in_memory_state().await);
1373        let mut request = fast_simulator_request_json();
1374        request["pv_range_high"] = serde_json::json!(1e40);
1375
1376        let response = post_json(app, "/api/runs", request).await;
1377        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1378        let error = body_json(response).await;
1379        assert!(error["error"].as_str().unwrap().contains("pv_range_high"));
1380    }
1381
1382    /// Covers `require_finite_if_some`'s `None` arm -- every other test in this module sets
1383    /// `pv_range_high` explicitly, so omitting it entirely (deserializing to `None`, since
1384    /// `Option<f32>` fields default to `None` when missing with no `#[serde(default)]`
1385    /// needed) is the only way to reach it. `prepare()` still rejects the request -- a fixed
1386    /// PV range is mandatory for `driver: "simulator"` -- so this asserts `400`, just from a
1387    /// different validator further down the same handler.
1388    #[tokio::test]
1389    async fn an_omitted_optional_range_field_passes_validation_but_prepare_still_requires_it() {
1390        let app = crate::build_router(crate::test_support::in_memory_state().await);
1391        let mut request = fast_simulator_request_json();
1392        request.as_object_mut().unwrap().remove("pv_range_high");
1393
1394        let response = post_json(app, "/api/runs", request).await;
1395        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1396        let error = body_json(response).await;
1397        assert!(
1398            error["error"]
1399                .as_str()
1400                .unwrap()
1401                .contains("--pv-range-high is required")
1402        );
1403    }
1404
1405    /// Omits every field with a `#[serde(default = "...")]` custom default function. Every
1406    /// other test in this module sets these explicitly, which left the simulator defaults
1407    /// themselves untested. The timing values are global configuration, not request defaults.
1408    #[tokio::test]
1409    async fn omitted_fields_with_custom_defaults_fall_back_to_the_cli_defaults() {
1410        let state = crate::test_support::in_memory_state().await;
1411        let mut request = fast_simulator_request_json();
1412        let object = request.as_object_mut().unwrap();
1413        object.remove("sim_gain");
1414        object.remove("sim_tau");
1415        object.remove("sim_dead_time");
1416        object.remove("sim_initial_pv");
1417        object.remove("sim_initial_mv");
1418
1419        let parsed: StartRunRequest = serde_json::from_value(request.clone()).unwrap();
1420        assert_eq!(parsed.sim_gain, 1.0);
1421        assert_eq!(parsed.sim_tau, 2.0);
1422        assert_eq!(parsed.sim_dead_time, 5.0);
1423        assert_eq!(parsed.sim_initial_pv, 50.0);
1424        assert_eq!(parsed.sim_initial_mv, 50.0);
1425
1426        let response = post_json(crate::build_router(state.clone()), "/api/runs", request).await;
1427        assert_eq!(response.status(), StatusCode::CREATED);
1428        let run_id = body_json(response).await["id"].as_i64().unwrap();
1429
1430        state.active_run.cancel(run_id).await;
1431        wait_for_outcome(&state, run_id).await;
1432    }
1433
1434    #[tokio::test]
1435    async fn the_replay_driver_is_rejected() {
1436        let app = crate::build_router(crate::test_support::in_memory_state().await);
1437        let mut request = fast_simulator_request_json();
1438        request["driver"] = serde_json::json!("replay");
1439
1440        let response = post_json(app, "/api/runs", request).await;
1441        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1442        let error = body_json(response).await;
1443        assert!(error["error"].as_str().unwrap().contains("replay"));
1444    }
1445
1446    #[tokio::test]
1447    async fn cancelling_an_unknown_run_returns_404() {
1448        let app = crate::build_router(crate::test_support::in_memory_state().await);
1449        let response = app
1450            .oneshot(
1451                Request::post("/api/runs/999999/cancel")
1452                    .body(Body::empty())
1453                    .unwrap(),
1454            )
1455            .await
1456            .unwrap();
1457        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1458    }
1459
1460    #[tokio::test]
1461    async fn cancelling_an_active_run_aborts_it_and_returns_204() {
1462        let state = crate::test_support::in_memory_state().await;
1463
1464        let mut slow_request = fast_simulator_request_json();
1465        slow_request["poll_interval_ms"] = serde_json::json!(1000);
1466        slow_request["cycles_count"] = serde_json::json!(50);
1467
1468        let start_response = post_json(
1469            crate::build_router(state.clone()),
1470            "/api/runs",
1471            slow_request,
1472        )
1473        .await;
1474        assert_eq!(start_response.status(), StatusCode::CREATED);
1475        let run_id = body_json(start_response).await["id"].as_i64().unwrap();
1476
1477        let cancel_response = crate::build_router(state.clone())
1478            .oneshot(
1479                Request::post(format!("/api/runs/{run_id}/cancel"))
1480                    .body(Body::empty())
1481                    .unwrap(),
1482            )
1483            .await
1484            .unwrap();
1485        assert_eq!(cancel_response.status(), StatusCode::NO_CONTENT);
1486
1487        let final_detail = wait_for_outcome(&state, run_id).await;
1488        assert_eq!(final_detail["outcome"], "aborted");
1489        wait_until_inactive(&state, run_id).await;
1490    }
1491
1492    #[tokio::test]
1493    async fn cancelling_a_run_that_already_finished_still_returns_204() {
1494        let state = crate::test_support::in_memory_state().await;
1495        let start_response = post_json(
1496            crate::build_router(state.clone()),
1497            "/api/runs",
1498            fast_simulator_request_json(),
1499        )
1500        .await;
1501        let run_id = body_json(start_response).await["id"].as_i64().unwrap();
1502        wait_for_outcome(&state, run_id).await;
1503
1504        let cancel_response = crate::build_router(state.clone())
1505            .oneshot(
1506                Request::post(format!("/api/runs/{run_id}/cancel"))
1507                    .body(Body::empty())
1508                    .unwrap(),
1509            )
1510            .await
1511            .unwrap();
1512        assert_eq!(cancel_response.status(), StatusCode::NO_CONTENT);
1513    }
1514
1515    /// Starts (but does not complete, record a connection for, or attach any result/write
1516    /// to) an `opcda`-driven run with real PID constant tags derived from the Yokogawa
1517    /// CentumVP template -- the common setup shared by every `write_run`/`revert_run`
1518    /// eligibility fixture below. Uses `ControllerType::Pid` and
1519    /// `ProcessType::TemperatureHeatExchange` (the only process types PID is offered for,
1520    /// matching the legacy app's own rule -- see `core-model`) purely so `opc_write_values`
1521    /// never zeroes the derivative constant, keeping every written PID value in these tests
1522    /// exactly `10.0` regardless of which constant is inspected.
1523    async fn start_opcda_run(state: &AppState) -> i64 {
1524        let template_row =
1525            bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1526                .await
1527                .unwrap()
1528                .unwrap();
1529        let template = template_row.template;
1530        let config = LoopConfig {
1531            process_type: ProcessType::TemperatureHeatExchange,
1532            controller_type: ControllerType::Pid,
1533            relay_amp_percent: 5.0,
1534            num_cycles_skip: 1,
1535            num_cycles_count: 3,
1536            noise_protection_secs: 0,
1537            mrft_delay_secs: 0,
1538        };
1539        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop3.PV", &template);
1540        let run = TuneRunRow::start(
1541            &state.pool,
1542            None,
1543            "Loop3",
1544            TuneDriver::Opcda,
1545            config,
1546            template_row.origin,
1547            &template,
1548            &tags,
1549            Utc::now(),
1550        )
1551        .await
1552        .unwrap();
1553        run.id
1554    }
1555
1556    /// Same as `start_opcda_run`, but with all three PID constant tags stripped after
1557    /// derivation -- exercises `require_writable_run`'s "no PID constant tags configured"
1558    /// branch, which a normal built-in template's fixture can never reach (every built-in
1559    /// template always defines these suffixes; see `core-model`).
1560    async fn start_opcda_run_without_pid_tags(state: &AppState) -> i64 {
1561        let template_row =
1562            bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1563                .await
1564                .unwrap()
1565                .unwrap();
1566        let template = template_row.template;
1567        let config = LoopConfig {
1568            process_type: ProcessType::TemperatureHeatExchange,
1569            controller_type: ControllerType::Pid,
1570            relay_amp_percent: 5.0,
1571            num_cycles_skip: 1,
1572            num_cycles_count: 3,
1573            noise_protection_secs: 0,
1574            mrft_delay_secs: 0,
1575        };
1576        let mut tags = bhtune_core::LoopTags::derive_from_pv_tag("Loop3.PV", &template);
1577        tags.proportional_constant = None;
1578        tags.integral_constant = None;
1579        tags.derivative_constant = None;
1580        let run = TuneRunRow::start(
1581            &state.pool,
1582            None,
1583            "Loop3",
1584            TuneDriver::Opcda,
1585            config,
1586            template_row.origin,
1587            &template,
1588            &tags,
1589            Utc::now(),
1590        )
1591        .await
1592        .unwrap();
1593        run.id
1594    }
1595
1596    /// Marks `run_id` completed and attaches a single calculated Moderate result
1597    /// (P = I = D = `10.0`) -- the minimum `write_run` needs to have something to write.
1598    async fn add_moderate_result(state: &AppState, run_id: i64) {
1599        TuneRunRow::complete(&state.pool, run_id, Utc::now())
1600            .await
1601            .unwrap();
1602        TuneResultRow::insert(
1603            &state.pool,
1604            &TuneResultRow {
1605                id: 0,
1606                run_id,
1607                response_level: ResponseLevel::Moderate,
1608                kp: Some(1.5),
1609                ti_minutes: Some(2.0),
1610                td_minutes: Some(1.0),
1611                proportional: Some(10.0),
1612                integral: Some(10.0),
1613                derivative: Some(10.0),
1614                status: bhtune_core::TuningResultStatus::Valid,
1615                invalid_reason: None,
1616            },
1617        )
1618        .await
1619        .unwrap();
1620    }
1621
1622    async fn add_invalid_moderate_result(state: &AppState, run_id: i64) {
1623        TuneRunRow::complete(&state.pool, run_id, Utc::now())
1624            .await
1625            .unwrap();
1626        TuneResultRow::insert(
1627            &state.pool,
1628            &TuneResultRow {
1629                id: 0,
1630                run_id,
1631                response_level: ResponseLevel::Moderate,
1632                kp: None,
1633                ti_minutes: None,
1634                td_minutes: None,
1635                proportional: None,
1636                integral: None,
1637                derivative: None,
1638                status: bhtune_core::TuningResultStatus::Invalid,
1639                invalid_reason: Some(
1640                    bhtune_core::TuningResultInvalidReason::NonPositivePvAmplitude,
1641                ),
1642            },
1643        )
1644        .await
1645        .unwrap();
1646    }
1647
1648    /// The full happy-path fixture: a completed `opcda` run with a recorded connection to
1649    /// `bridge_host`/`opc_server` and a calculated Moderate result ready to write.
1650    async fn seed_writable_opcda_run(state: &AppState, bridge_host: &str, opc_server: &str) -> i64 {
1651        let run_id = start_opcda_run(state).await;
1652        TuneRunRow::record_connection(
1653            &state.pool,
1654            run_id,
1655            Some(opc_server),
1656            Some(bridge_host),
1657            "{}",
1658        )
1659        .await
1660        .unwrap();
1661        add_moderate_result(state, run_id).await;
1662        run_id
1663    }
1664
1665    async fn post_empty(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1666        app.oneshot(Request::post(path).body(Body::empty()).unwrap())
1667            .await
1668            .unwrap()
1669    }
1670
1671    #[tokio::test]
1672    async fn write_run_succeeds_and_records_a_write_kind_row() {
1673        use crate::test_support::mock_bridge::{
1674            MockBridgeService, good_reading, start_mock_server,
1675        };
1676
1677        let host = start_mock_server(MockBridgeService {
1678            read_response: good_reading("10.0"),
1679            write_response: opcda_bridge_proto::bridge::WriteResponse {
1680                tag_id: "ignored".to_string(),
1681                success: true,
1682                error: None,
1683            },
1684            ..Default::default()
1685        })
1686        .await;
1687
1688        let state = crate::test_support::in_memory_state().await;
1689        let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1690
1691        let response = post_json(
1692            crate::build_router(state.clone()),
1693            &format!("/api/runs/{run_id}/write"),
1694            serde_json::json!({ "response_level": "moderate" }),
1695        )
1696        .await;
1697        assert_eq!(response.status(), StatusCode::OK);
1698        let detail = body_json(response).await;
1699
1700        let writes = detail["writes"].as_array().unwrap();
1701        let write_row = writes.iter().find(|w| w["kind"] == "write").unwrap();
1702        assert_eq!(write_row["response_level"], "moderate");
1703        assert_eq!(write_row["success"], true);
1704        assert_eq!(write_row["proportional_written"], 10.0);
1705        assert_eq!(write_row["integral_written"], 10.0);
1706        assert_eq!(write_row["derivative_written"], 10.0);
1707        assert_eq!(write_row["proportional_readback"], 10.0);
1708        assert!(write_row["rollback_state"].is_null());
1709
1710        // The exclusive reservation must be free again for a later request, not left held by this one.
1711        assert!(state.active_run.reserve(999).await.is_ok());
1712        state.active_run.release(999).await;
1713    }
1714
1715    #[tokio::test]
1716    async fn write_reports_an_internal_error_when_the_run_vanishes_after_the_write() {
1717        use crate::test_support::mock_bridge::{
1718            MockBridgeService, good_reading, start_mock_server,
1719        };
1720
1721        let host = start_mock_server(MockBridgeService {
1722            read_response: good_reading("10.0"),
1723            write_response: opcda_bridge_proto::bridge::WriteResponse {
1724                tag_id: "ignored".to_string(),
1725                success: true,
1726                error: None,
1727            },
1728            ..Default::default()
1729        })
1730        .await;
1731        let state = crate::test_support::in_memory_state().await;
1732        let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1733        let run = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
1734        let p_tag = run.tags.proportional_constant.clone().unwrap();
1735        let i_tag = run.tags.integral_constant.clone().unwrap();
1736        let d_tag = run.tags.derivative_constant.clone().unwrap();
1737        let pool = state.pool.clone();
1738
1739        let error = reserve_connect_and_write_with_hook(
1740            &state,
1741            run_id,
1742            &run,
1743            &p_tag,
1744            &i_tag,
1745            &d_tag,
1746            ResponseLevel::Moderate,
1747            WriteReadback {
1748                proportional: 10.0,
1749                integral: 10.0,
1750                derivative: 10.0,
1751            },
1752            WriteKind::Write,
1753            true,
1754            move |_| async move {
1755                assert!(TuneRunRow::delete(&pool, run_id).await.unwrap());
1756            },
1757        )
1758        .await
1759        .unwrap_err();
1760
1761        assert!(matches!(error, ApiError::Internal(_)));
1762    }
1763
1764    #[tokio::test]
1765    async fn write_propagates_an_unexpected_database_failure_and_releases_its_reservation() {
1766        use crate::test_support::mock_bridge::{
1767            MockBridgeService, good_reading, start_mock_server,
1768        };
1769
1770        let host = start_mock_server(MockBridgeService {
1771            read_response: good_reading("10.0"),
1772            write_response: opcda_bridge_proto::bridge::WriteResponse {
1773                tag_id: "ignored".to_string(),
1774                success: true,
1775                error: None,
1776            },
1777            ..Default::default()
1778        })
1779        .await;
1780        let state = crate::test_support::in_memory_state().await;
1781        let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1782        let run = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
1783        let p_tag = run.tags.proportional_constant.clone().unwrap();
1784        let i_tag = run.tags.integral_constant.clone().unwrap();
1785        let d_tag = run.tags.derivative_constant.clone().unwrap();
1786
1787        let error = reserve_connect_and_write_with_hooks(
1788            &state,
1789            run_id,
1790            &run,
1791            &p_tag,
1792            &i_tag,
1793            &d_tag,
1794            ResponseLevel::Moderate,
1795            WriteReadback {
1796                proportional: 10.0,
1797                integral: 10.0,
1798                derivative: 10.0,
1799            },
1800            WriteKind::Write,
1801            true,
1802            |state| {
1803                let pool = state.pool.clone();
1804                async move { pool.close().await }
1805            },
1806            |_| async {},
1807        )
1808        .await
1809        .unwrap_err();
1810
1811        assert!(matches!(error, ApiError::Internal(_)));
1812        assert!(state.active_run.reserve(999).await.is_ok());
1813        state.active_run.release(999).await;
1814    }
1815
1816    #[tokio::test]
1817    async fn write_run_reports_a_failed_write_as_200_not_an_http_error() {
1818        use crate::test_support::mock_bridge::{
1819            MockBridgeService, good_reading, start_mock_server,
1820        };
1821
1822        let host = start_mock_server(MockBridgeService {
1823            // The pre-read (all three constants) still succeeds; every subsequent *write*
1824            // is rejected at the transport level, so the very first write attempted
1825            // (Proportional) fails before anything is confirmed -- no rollback is even
1826            // attempted, matching `write_pid_values`'s documented "nothing yet to roll
1827            // back" short-circuit.
1828            read_response: good_reading("10.0"),
1829            write_error: Some(tonic::Status::invalid_argument("nope")),
1830            ..Default::default()
1831        })
1832        .await;
1833
1834        let state = crate::test_support::in_memory_state().await;
1835        let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1836
1837        let response = post_json(
1838            crate::build_router(state.clone()),
1839            &format!("/api/runs/{run_id}/write"),
1840            serde_json::json!({ "response_level": "moderate" }),
1841        )
1842        .await;
1843        // A physical write failure is not an HTTP error -- see `reserve_connect_and_write`'s
1844        // doc comment.
1845        assert_eq!(response.status(), StatusCode::OK);
1846        let detail = body_json(response).await;
1847
1848        let writes = detail["writes"].as_array().unwrap();
1849        let write_row = writes.iter().find(|w| w["kind"] == "write").unwrap();
1850        assert_eq!(write_row["success"], false);
1851        // `DriverError::Operation`'s `Display` is the fixed message "driver operation
1852        // failed" (thiserror doesn't interpolate the boxed source's own text unless the
1853        // format string names it), so this asserts on that fixed wording rather than the
1854        // mock's "nope" status message, which never surfaces here.
1855        assert!(
1856            write_row["error_message"]
1857                .as_str()
1858                .unwrap()
1859                .contains("driver operation failed")
1860        );
1861        assert!(write_row["rollback_state"].is_null());
1862    }
1863
1864    #[tokio::test]
1865    async fn write_run_reports_a_failed_pre_read_as_200_not_an_http_error() {
1866        use crate::test_support::mock_bridge::{MockBridgeService, start_mock_server};
1867
1868        let host = start_mock_server(MockBridgeService {
1869            // Every `read` (including the Proportional pre-read, the very first driver call
1870            // `write_pid_values` makes) is rejected at the transport level -- no `write` is
1871            // ever attempted, and the resulting row's `previous` stays entirely unset.
1872            read_error: Some(tonic::Status::unavailable("gateway unreachable")),
1873            ..Default::default()
1874        })
1875        .await;
1876
1877        let state = crate::test_support::in_memory_state().await;
1878        let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
1879
1880        let response = post_json(
1881            crate::build_router(state),
1882            &format!("/api/runs/{run_id}/write"),
1883            serde_json::json!({ "response_level": "moderate" }),
1884        )
1885        .await;
1886        // A failed pre-read is not an HTTP error either -- same rationale as a failed write.
1887        assert_eq!(response.status(), StatusCode::OK);
1888        let detail = body_json(response).await;
1889
1890        let writes = detail["writes"].as_array().unwrap();
1891        let write_row = writes.iter().find(|w| w["kind"] == "write").unwrap();
1892        assert_eq!(write_row["success"], false);
1893        assert!(write_row["proportional_previous"].is_null());
1894        assert!(write_row["proportional_written"].is_null());
1895        assert!(write_row["rollback_state"].is_null());
1896        assert!(
1897            write_row["error_message"]
1898                .as_str()
1899                .unwrap()
1900                .contains("pre-read")
1901        );
1902    }
1903
1904    #[tokio::test]
1905    async fn write_run_returns_404_for_unknown_run() {
1906        let app = crate::build_router(crate::test_support::in_memory_state().await);
1907        let response = post_json(
1908            app,
1909            "/api/runs/999999/write",
1910            serde_json::json!({ "response_level": "moderate" }),
1911        )
1912        .await;
1913        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1914    }
1915
1916    #[tokio::test]
1917    async fn write_run_returns_400_when_run_is_still_running() {
1918        let state = crate::test_support::in_memory_state().await;
1919        // Never completed -- `require_writable_run` checks this before the driver, tags, or
1920        // connection, so no result/connection needs to be attached for this fixture.
1921        let run_id = start_opcda_run(&state).await;
1922
1923        let response = post_json(
1924            crate::build_router(state),
1925            &format!("/api/runs/{run_id}/write"),
1926            serde_json::json!({ "response_level": "moderate" }),
1927        )
1928        .await;
1929        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1930        let error = body_json(response).await;
1931        assert!(error["error"].as_str().unwrap().contains("still running"));
1932    }
1933
1934    #[tokio::test]
1935    async fn write_run_returns_400_for_simulator_driver() {
1936        let state = crate::test_support::in_memory_state().await;
1937        let template_row =
1938            bhtune_db::models::DcsTemplateRow::get_by_name(&state.pool, "Yokogawa CentumVP")
1939                .await
1940                .unwrap()
1941                .unwrap();
1942        let template = template_row.template;
1943        let config = LoopConfig {
1944            process_type: ProcessType::Flow,
1945            controller_type: ControllerType::Pi,
1946            relay_amp_percent: 5.0,
1947            num_cycles_skip: 1,
1948            num_cycles_count: 3,
1949            noise_protection_secs: 0,
1950            mrft_delay_secs: 0,
1951        };
1952        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Sim.PV", &template);
1953        let run = TuneRunRow::start(
1954            &state.pool,
1955            None,
1956            "SimLoop",
1957            TuneDriver::Simulator,
1958            config,
1959            template_row.origin,
1960            &template,
1961            &tags,
1962            Utc::now(),
1963        )
1964        .await
1965        .unwrap();
1966        add_moderate_result(&state, run.id).await;
1967
1968        let response = post_json(
1969            crate::build_router(state),
1970            &format!("/api/runs/{}/write", run.id),
1971            serde_json::json!({ "response_level": "moderate" }),
1972        )
1973        .await;
1974        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1975        let error = body_json(response).await;
1976        assert!(error["error"].as_str().unwrap().contains("Simulator"));
1977    }
1978
1979    #[tokio::test]
1980    async fn write_run_returns_400_when_run_has_no_pid_constant_tags() {
1981        let state = crate::test_support::in_memory_state().await;
1982        let run_id = start_opcda_run_without_pid_tags(&state).await;
1983        TuneRunRow::complete(&state.pool, run_id, Utc::now())
1984            .await
1985            .unwrap();
1986
1987        let response = post_json(
1988            crate::build_router(state),
1989            &format!("/api/runs/{run_id}/write"),
1990            serde_json::json!({ "response_level": "moderate" }),
1991        )
1992        .await;
1993        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1994        let error = body_json(response).await;
1995        assert!(
1996            error["error"]
1997                .as_str()
1998                .unwrap()
1999                .contains("no PID constant tags configured")
2000        );
2001    }
2002
2003    #[tokio::test]
2004    async fn each_missing_pid_constant_tag_is_rejected_individually() {
2005        let state = crate::test_support::in_memory_state().await;
2006        let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2007        let base = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
2008
2009        let mut missing_proportional = base.clone();
2010        missing_proportional.tags.proportional_constant = None;
2011        let mut missing_integral = base.clone();
2012        missing_integral.tags.integral_constant = None;
2013        let mut missing_derivative = base;
2014        missing_derivative.tags.derivative_constant = None;
2015
2016        for (missing_tag, run) in [
2017            ("proportional", missing_proportional),
2018            ("integral", missing_integral),
2019            ("derivative", missing_derivative),
2020        ] {
2021            let error = require_writable_run(&run).unwrap_err();
2022            assert!(
2023                matches!(
2024                    error,
2025                    ApiError::BadRequest(ref message)
2026                        if message.contains("no PID constant tags configured")
2027                ),
2028                "missing {missing_tag} tag should be rejected: {error:?}"
2029            );
2030        }
2031    }
2032
2033    #[tokio::test]
2034    async fn each_missing_recorded_connection_field_is_rejected_individually() {
2035        let state = crate::test_support::in_memory_state().await;
2036        let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2037        let base = TuneRunRow::get(&state.pool, run_id).await.unwrap().unwrap();
2038
2039        let mut missing_server = base.clone();
2040        missing_server.opc_server = None;
2041        let mut missing_bridge = base;
2042        missing_bridge.bridge_host = None;
2043
2044        for (missing_field, run) in [
2045            ("opc_server", missing_server),
2046            ("bridge_host", missing_bridge),
2047        ] {
2048            let error = require_writable_run(&run).unwrap_err();
2049            assert!(
2050                matches!(
2051                    error,
2052                    ApiError::BadRequest(ref message)
2053                        if message.contains("no recorded OPC server")
2054                ),
2055                "missing {missing_field} should be rejected: {error:?}"
2056            );
2057        }
2058    }
2059
2060    #[tokio::test]
2061    async fn write_run_returns_400_when_no_connection_was_recorded() {
2062        let state = crate::test_support::in_memory_state().await;
2063        let run_id = start_opcda_run(&state).await;
2064        add_moderate_result(&state, run_id).await;
2065        // `record_connection` deliberately never called -- mirrors a run that somehow
2066        // never recorded its connection (should not happen in practice, since `start_run`
2067        // always records it for an `opcda` run, but `require_writable_run` must still
2068        // refuse to guess).
2069
2070        let response = post_json(
2071            crate::build_router(state),
2072            &format!("/api/runs/{run_id}/write"),
2073            serde_json::json!({ "response_level": "moderate" }),
2074        )
2075        .await;
2076        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2077        let error = body_json(response).await;
2078        assert!(
2079            error["error"]
2080                .as_str()
2081                .unwrap()
2082                .contains("no recorded OPC server")
2083        );
2084    }
2085
2086    #[tokio::test]
2087    async fn write_run_returns_400_when_no_result_for_the_requested_level() {
2088        let state = crate::test_support::in_memory_state().await;
2089        // Only a Moderate result is attached -- requesting Sluggish must fail cleanly.
2090        let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2091
2092        let response = post_json(
2093            crate::build_router(state),
2094            &format!("/api/runs/{run_id}/write"),
2095            serde_json::json!({ "response_level": "sluggish" }),
2096        )
2097        .await;
2098        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2099        let error = body_json(response).await;
2100        assert!(error["error"].as_str().unwrap().contains("Sluggish"));
2101    }
2102
2103    #[tokio::test]
2104    async fn write_run_rejects_an_invalid_result_before_connecting_to_the_driver() {
2105        let state = crate::test_support::in_memory_state().await;
2106        let run_id = start_opcda_run(&state).await;
2107        TuneRunRow::record_connection(
2108            &state.pool,
2109            run_id,
2110            Some("Sim.Server"),
2111            Some("127.0.0.1:1"),
2112            "{}",
2113        )
2114        .await
2115        .unwrap();
2116        add_invalid_moderate_result(&state, run_id).await;
2117
2118        let response = post_json(
2119            crate::build_router(state),
2120            &format!("/api/runs/{run_id}/write"),
2121            serde_json::json!({ "response_level": "moderate" }),
2122        )
2123        .await;
2124        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2125        let error = body_json(response).await;
2126        let message = error["error"].as_str().unwrap();
2127        assert!(message.contains("Moderate"));
2128        assert!(message.contains("invalid"));
2129        assert!(message.contains("PV amplitude is not positive"));
2130    }
2131
2132    #[tokio::test]
2133    async fn write_run_returns_400_when_the_driver_connection_fails() {
2134        let state = crate::test_support::in_memory_state().await;
2135        // Nothing is listening on this port, so `OpcDaDriver::connect` fails at the
2136        // transport level -- mirrors `bhtune-driver`'s own
2137        // `connect_failure_maps_to_driver_error_connect` test.
2138        let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2139
2140        let response = post_json(
2141            crate::build_router(state),
2142            &format!("/api/runs/{run_id}/write"),
2143            serde_json::json!({ "response_level": "moderate" }),
2144        )
2145        .await;
2146        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2147        let error = body_json(response).await;
2148        assert!(
2149            error["error"]
2150                .as_str()
2151                .unwrap()
2152                .contains("failed to connect")
2153        );
2154    }
2155
2156    #[tokio::test]
2157    async fn write_run_returns_409_when_another_operation_is_active() {
2158        use crate::test_support::mock_bridge::{
2159            MockBridgeService, good_reading, start_mock_server,
2160        };
2161
2162        let host = start_mock_server(MockBridgeService {
2163            read_response: good_reading("10.0"),
2164            write_response: opcda_bridge_proto::bridge::WriteResponse {
2165                tag_id: "ignored".to_string(),
2166                success: true,
2167                error: None,
2168            },
2169            ..Default::default()
2170        })
2171        .await;
2172
2173        let state = crate::test_support::in_memory_state().await;
2174        let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
2175        state.active_run.reserve(424242).await.unwrap();
2176
2177        let response = post_json(
2178            crate::build_router(state.clone()),
2179            &format!("/api/runs/{run_id}/write"),
2180            serde_json::json!({ "response_level": "moderate" }),
2181        )
2182        .await;
2183        assert_eq!(response.status(), StatusCode::CONFLICT);
2184        let error = body_json(response).await;
2185        assert!(error["error"].as_str().unwrap().contains("424242"));
2186
2187        state.active_run.release(424242).await;
2188    }
2189
2190    #[tokio::test]
2191    async fn revert_run_succeeds_and_records_a_revert_kind_row() {
2192        use crate::test_support::mock_bridge::{
2193            MockBridgeService, good_reading, start_mock_server,
2194        };
2195
2196        let host = start_mock_server(MockBridgeService {
2197            read_response: good_reading("10.0"),
2198            write_response: opcda_bridge_proto::bridge::WriteResponse {
2199                tag_id: "ignored".to_string(),
2200                success: true,
2201                error: None,
2202            },
2203            ..Default::default()
2204        })
2205        .await;
2206
2207        let state = crate::test_support::in_memory_state().await;
2208        let run_id = seed_writable_opcda_run(&state, &host, "Sim.Server").await;
2209
2210        let mut previous_write =
2211            bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2212        previous_write.previous = Some(WriteReadback {
2213            proportional: 10.0,
2214            integral: 10.0,
2215            derivative: 10.0,
2216        });
2217        previous_write.proportional_written = Some(66.7);
2218        previous_write.integral_written = Some(2.0);
2219        previous_write.derivative_written = Some(0.5);
2220        previous_write.proportional_readback = Some(66.7);
2221        previous_write.integral_readback = Some(2.0);
2222        previous_write.derivative_readback = Some(0.5);
2223        previous_write.success = true;
2224        TuneWriteRow::insert(&state.pool, run_id, previous_write)
2225            .await
2226            .unwrap();
2227
2228        let response = post_empty(
2229            crate::build_router(state.clone()),
2230            &format!("/api/runs/{run_id}/revert"),
2231        )
2232        .await;
2233        assert_eq!(response.status(), StatusCode::OK);
2234        let detail = body_json(response).await;
2235
2236        let writes = detail["writes"].as_array().unwrap();
2237        assert_eq!(writes.len(), 2);
2238        let revert_row = writes.iter().find(|w| w["kind"] == "revert").unwrap();
2239        assert_eq!(revert_row["response_level"], "moderate");
2240        assert_eq!(revert_row["success"], true);
2241        assert_eq!(revert_row["proportional_written"], 10.0);
2242        assert_eq!(revert_row["integral_written"], 10.0);
2243        assert_eq!(revert_row["derivative_written"], 10.0);
2244        // Reverts never chain a nested rollback of themselves.
2245        assert!(revert_row["rollback_state"].is_null());
2246    }
2247
2248    #[tokio::test]
2249    async fn revert_run_can_restore_recorded_values_when_calculated_result_is_invalid() {
2250        use crate::test_support::mock_bridge::{
2251            MockBridgeService, good_reading, start_mock_server,
2252        };
2253
2254        let host = start_mock_server(MockBridgeService {
2255            read_response: good_reading("10.0"),
2256            write_response: opcda_bridge_proto::bridge::WriteResponse {
2257                tag_id: "ignored".to_string(),
2258                success: true,
2259                error: None,
2260            },
2261            ..Default::default()
2262        })
2263        .await;
2264
2265        let state = crate::test_support::in_memory_state().await;
2266        let run_id = start_opcda_run(&state).await;
2267        TuneRunRow::record_connection(&state.pool, run_id, Some("Sim.Server"), Some(&host), "{}")
2268            .await
2269            .unwrap();
2270        add_invalid_moderate_result(&state, run_id).await;
2271
2272        let mut previous_write =
2273            bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2274        previous_write.previous = Some(WriteReadback {
2275            proportional: 10.0,
2276            integral: 10.0,
2277            derivative: 10.0,
2278        });
2279        previous_write.proportional_written = Some(66.7);
2280        previous_write.integral_written = Some(2.0);
2281        previous_write.derivative_written = Some(0.5);
2282        previous_write.proportional_readback = Some(66.7);
2283        previous_write.integral_readback = Some(2.0);
2284        previous_write.derivative_readback = Some(0.5);
2285        previous_write.success = true;
2286        TuneWriteRow::insert(&state.pool, run_id, previous_write)
2287            .await
2288            .unwrap();
2289
2290        let response = post_empty(
2291            crate::build_router(state),
2292            &format!("/api/runs/{run_id}/revert"),
2293        )
2294        .await;
2295        assert_eq!(response.status(), StatusCode::OK);
2296        let detail = body_json(response).await;
2297        assert_eq!(detail["results"][0]["status"], "invalid");
2298        let revert_row = detail["writes"]
2299            .as_array()
2300            .unwrap()
2301            .iter()
2302            .find(|write| write["kind"] == "revert")
2303            .unwrap();
2304        assert_eq!(revert_row["success"], true);
2305        assert_eq!(revert_row["derivative_written"], 10.0);
2306    }
2307
2308    #[tokio::test]
2309    async fn revert_run_returns_400_when_there_is_no_write_to_revert() {
2310        let state = crate::test_support::in_memory_state().await;
2311        let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2312        // No `TuneWriteRow` attached at all.
2313
2314        let response = post_empty(
2315            crate::build_router(state),
2316            &format!("/api/runs/{run_id}/revert"),
2317        )
2318        .await;
2319        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2320        let error = body_json(response).await;
2321        assert!(
2322            error["error"]
2323                .as_str()
2324                .unwrap()
2325                .contains("no recorded PID write-back to revert")
2326        );
2327    }
2328
2329    #[tokio::test]
2330    async fn revert_run_returns_400_when_the_last_write_has_no_previous_values() {
2331        let state = crate::test_support::in_memory_state().await;
2332        let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2333
2334        // A `Write`-kind row whose pre-read itself failed -- `previous` stays `None` and
2335        // nothing else on the row was ever attempted (mirrors `write_pid_values`'s
2336        // pre-read-failure short-circuit). No driver connection is needed to prove this:
2337        // `revert_run` must refuse before ever trying to connect.
2338        let mut failed_write =
2339            bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2340        failed_write.success = false;
2341        failed_write.error_message =
2342            Some("pre-read of Proportional tag failed: unavailable".to_string());
2343        TuneWriteRow::insert(&state.pool, run_id, failed_write)
2344            .await
2345            .unwrap();
2346
2347        let response = post_empty(
2348            crate::build_router(state),
2349            &format!("/api/runs/{run_id}/revert"),
2350        )
2351        .await;
2352        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2353        let error = body_json(response).await;
2354        assert!(
2355            error["error"]
2356                .as_str()
2357                .unwrap()
2358                .contains("never recorded pre-write values")
2359        );
2360    }
2361
2362    #[tokio::test]
2363    async fn revert_run_returns_400_when_the_driver_connection_fails() {
2364        let state = crate::test_support::in_memory_state().await;
2365        let run_id = seed_writable_opcda_run(&state, "127.0.0.1:1", "Sim.Server").await;
2366        let mut previous_write =
2367            bhtune_db::models::NewTuneWrite::new(ResponseLevel::Moderate, Utc::now());
2368        previous_write.previous = Some(WriteReadback {
2369            proportional: 10.0,
2370            integral: 20.0,
2371            derivative: 30.0,
2372        });
2373        previous_write.success = true;
2374        TuneWriteRow::insert(&state.pool, run_id, previous_write)
2375            .await
2376            .unwrap();
2377
2378        let response = post_empty(
2379            crate::build_router(state),
2380            &format!("/api/runs/{run_id}/revert"),
2381        )
2382        .await;
2383        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2384        assert!(
2385            body_json(response).await["error"]
2386                .as_str()
2387                .unwrap()
2388                .contains("failed to connect")
2389        );
2390    }
2391
2392    #[tokio::test]
2393    async fn revert_run_returns_404_for_unknown_run() {
2394        let app = crate::build_router(crate::test_support::in_memory_state().await);
2395        let response = post_empty(app, "/api/runs/999999/revert").await;
2396        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2397    }
2398}