Skip to main content

bhtune_cli/commands/
history.rs

1//! `bhtune history list/show/revert`.
2
3use crate::args::HistoryCommand;
4use crate::output::OutputFormat;
5use bhtune_db::SqlitePool;
6use bhtune_db::models::{
7    MvActuationKind, MvActuationStatus, Pagination, SampleQuality, SamplingAdequacy, TimingSummary,
8    TuneMvActuationRow, TuneResultRow, TuneRunFilter, TuneRunRow, TuneSampleRow, TuneWriteRow,
9    WriteKind,
10};
11use bhtune_runtime::tune::pid_write_preview;
12
13pub async fn run(
14    pool: &SqlitePool,
15    command: HistoryCommand,
16    config: &crate::config::BhtuneConfig,
17) -> anyhow::Result<()> {
18    match command {
19        HistoryCommand::List {
20            outcome,
21            limit,
22            offset,
23            output,
24        } => list(pool, outcome.map(Into::into), limit, offset, output).await,
25        HistoryCommand::Show { run_id, output } => show(pool, run_id, output).await,
26        HistoryCommand::Revert {
27            run_id,
28            bridge_host,
29            server,
30            yes,
31            output,
32        } => revert(pool, run_id, bridge_host, server, yes, output, config).await,
33        HistoryCommand::Prune {
34            older_than_days,
35            dry_run,
36            output,
37        } => prune(pool, config, older_than_days, dry_run, output).await,
38    }
39}
40
41/// The fields of one run shown in `history list`'s `--output json` array -- deliberately a
42/// subset matching the plain-text table's own columns exactly, not the full run detail
43/// (that's `RunDetailJson`, for `history show`).
44#[derive(serde::Serialize)]
45struct RunSummaryJson {
46    id: i64,
47    tag_name: String,
48    notes: Option<String>,
49    driver: bhtune_db::models::TuneDriver,
50    outcome: bhtune_db::models::TuneOutcome,
51    process_type: bhtune_core::ProcessType,
52    started_at: chrono::DateTime<chrono::Utc>,
53}
54
55#[derive(serde::Serialize)]
56struct RunListJson {
57    runs: Vec<RunSummaryJson>,
58    /// How many rows are in `runs` (i.e. this page) -- distinct from `total`, the count of
59    /// every run matching the filter across all pages.
60    returned: usize,
61    total: i64,
62}
63
64/// Local projection of [`bhtune_db::models::TuneRunInitialReadings`] for JSON output: that
65/// type deliberately doesn't derive `Serialize` itself (DB row shape stays decoupled from
66/// any API/CLI JSON shape), so `history show --output json` needs its own copy.
67#[derive(serde::Serialize)]
68struct InitialReadingsJson {
69    pv_ini: f32,
70    mv_ini: f32,
71    mv_range_low: f32,
72    mv_range_high: f32,
73    pv_range_high: f32,
74    pv_range_low: f32,
75    controller_direction: bhtune_core::ControllerDirection,
76    mode_raw: Option<String>,
77    mode_attribute_raw: Option<String>,
78    setpoint_ini: Option<f32>,
79}
80
81impl From<bhtune_db::models::TuneRunInitialReadings> for InitialReadingsJson {
82    fn from(r: bhtune_db::models::TuneRunInitialReadings) -> Self {
83        Self {
84            pv_ini: r.pv_ini,
85            mv_ini: r.mv_ini,
86            mv_range_low: r.mv_range_low,
87            mv_range_high: r.mv_range_high,
88            pv_range_high: r.pv_range_high,
89            pv_range_low: r.pv_range_low,
90            controller_direction: r.controller_direction,
91            mode_raw: r.mode_raw,
92            mode_attribute_raw: r.mode_attribute_raw,
93            setpoint_ini: r.setpoint_ini,
94        }
95    }
96}
97
98/// Local projection of [`bhtune_db::models::TuneResultRow`] for JSON output (see
99/// [`InitialReadingsJson`] for why this can't just derive `Serialize` on the DB type
100/// directly).
101#[derive(serde::Serialize)]
102struct ResultJson {
103    response_level: bhtune_core::ResponseLevel,
104    kp: Option<f32>,
105    ti_minutes: Option<f32>,
106    td_minutes: Option<f32>,
107    proportional: Option<f32>,
108    integral: Option<f32>,
109    derivative: Option<f32>,
110    status: bhtune_core::TuningResultStatus,
111    invalid_reason: Option<bhtune_core::TuningResultInvalidReason>,
112    controller_values: Option<bhtune_core::ControllerPidValues>,
113    controller_target_error: Option<String>,
114}
115
116impl ResultJson {
117    fn from_recorded(r: &TuneResultRow, run: &TuneRunRow) -> Self {
118        let preview = pid_write_preview(r, run.config.controller_type, &run.template);
119        Self {
120            response_level: r.response_level,
121            kp: r.kp,
122            ti_minutes: r.ti_minutes,
123            td_minutes: r.td_minutes,
124            proportional: r.proportional,
125            integral: r.integral,
126            derivative: r.derivative,
127            status: r.status,
128            invalid_reason: r.invalid_reason,
129            controller_values: preview.values,
130            controller_target_error: preview.unavailable_reason,
131        }
132    }
133}
134
135/// Local projection of [`bhtune_db::models::TuneWriteRow`] for JSON output (see
136/// [`InitialReadingsJson`] for why this can't just derive `Serialize` on the DB type
137/// directly).
138#[derive(serde::Serialize)]
139struct WriteJson {
140    /// Whether this is an original write-back of freshly calculated PID parameters, or a
141    /// `bhtune history revert` undoing one (see [`WriteKind`]).
142    kind: WriteKind,
143    response_level: bhtune_core::ResponseLevel,
144    written_at: chrono::DateTime<chrono::Utc>,
145    /// The P/I/D values read from the driver before any write was attempted. `None` only
146    /// when the pre-read itself failed, in which case every field below is also `None`.
147    proportional_previous: Option<f32>,
148    integral_previous: Option<f32>,
149    derivative_previous: Option<f32>,
150    /// `None` for a constant that was never attempted because an earlier one in the
151    /// P/I/D write-and-verify sequence had already failed.
152    proportional_written: Option<f32>,
153    integral_written: Option<f32>,
154    derivative_written: Option<f32>,
155    proportional_readback: Option<f32>,
156    integral_readback: Option<f32>,
157    derivative_readback: Option<f32>,
158    success: bool,
159    error_message: Option<String>,
160    /// `None` means no rollback was applicable -- either every constant wrote successfully
161    /// or the pre-read failed before any write was attempted; always `None` for a `Revert`
162    /// row. See `rollback_error` for what went wrong when this is
163    /// `Some(RollbackState::Failed)`.
164    rollback_state: Option<bhtune_db::models::RollbackState>,
165    rollback_error: Option<String>,
166}
167
168impl From<&TuneWriteRow> for WriteJson {
169    fn from(w: &TuneWriteRow) -> Self {
170        Self {
171            kind: w.kind,
172            response_level: w.response_level,
173            written_at: w.written_at,
174            proportional_previous: w.previous.map(|p| p.proportional),
175            integral_previous: w.previous.map(|p| p.integral),
176            derivative_previous: w.previous.map(|p| p.derivative),
177            proportional_written: w.proportional_written,
178            integral_written: w.integral_written,
179            derivative_written: w.derivative_written,
180            proportional_readback: w.proportional_readback,
181            integral_readback: w.integral_readback,
182            derivative_readback: w.derivative_readback,
183            success: w.success,
184            error_message: w.error_message.clone(),
185            rollback_state: w.rollback_state,
186            rollback_error: w.rollback_error.clone(),
187        }
188    }
189}
190
191#[derive(serde::Serialize)]
192struct MvActuationJson {
193    id: i64,
194    sequence: i64,
195    kind: MvActuationKind,
196    commanded_at: chrono::DateTime<chrono::Utc>,
197    target_mv: f32,
198    previous_commanded_mv: Option<f32>,
199    tolerance: f32,
200    confirmation_due_at: chrono::DateTime<chrono::Utc>,
201    last_checked_at: Option<chrono::DateTime<chrono::Utc>>,
202    readback_mv: Option<f32>,
203    readback_quality: Option<SampleQuality>,
204    attempt_count: i64,
205    status: MvActuationStatus,
206    detail: Option<String>,
207}
208
209impl From<&TuneMvActuationRow> for MvActuationJson {
210    fn from(row: &TuneMvActuationRow) -> Self {
211        Self {
212            id: row.id,
213            sequence: row.sequence,
214            kind: row.kind,
215            commanded_at: row.commanded_at,
216            target_mv: row.target_mv,
217            previous_commanded_mv: row.previous_commanded_mv,
218            tolerance: row.tolerance,
219            confirmation_due_at: row.confirmation_due_at,
220            last_checked_at: row.last_checked_at,
221            readback_mv: row.readback_mv,
222            readback_quality: row.readback_quality,
223            attempt_count: row.attempt_count,
224            status: row.status,
225            detail: row.detail.clone(),
226        }
227    }
228}
229
230#[derive(serde::Serialize)]
231struct RunDetailJson {
232    id: i64,
233    tag_name: String,
234    notes: Option<String>,
235    driver: bhtune_db::models::TuneDriver,
236    outcome: bhtune_db::models::TuneOutcome,
237    failure_reason: Option<String>,
238    started_at: chrono::DateTime<chrono::Utc>,
239    completed_at: Option<chrono::DateTime<chrono::Utc>>,
240    /// Name of the template snapshotted onto this run at start time -- not necessarily the
241    /// template `template_name` currently resolves to in the catalog, since templates can be
242    /// edited or re-versioned after a run is recorded.
243    template_name: String,
244    template_origin: bhtune_db::models::TemplateOrigin,
245    config: bhtune_core::LoopConfig,
246    pid_rounding: bhtune_core::PidRounding,
247    /// The resolved OPC DA server ProgID this run actually used, or `None` for a
248    /// simulator/replay run. This is what `history revert`
249    /// trusts over any `--server` flag, rather than re-resolving one.
250    opc_server: Option<String>,
251    /// The resolved bridge host this run actually used, matching `opc_server` above.
252    bridge_host: Option<String>,
253    /// The gateway compatibility snapshot recorded before this run mutated the loop.
254    gateway_compatibility: Option<bhtune_driver::OpcDaGatewayCompatibility>,
255    initial_readings: Option<InitialReadingsJson>,
256    timing_metrics: Option<bhtune_db::models::TimingMetrics>,
257    samples_recorded: usize,
258    results: Vec<ResultJson>,
259    writes: Vec<WriteJson>,
260    mv_actuations: Vec<MvActuationJson>,
261    /// Outcome of the best-effort restore attempted after this run ended -- `None` if the
262    /// run never mutated the loop, or hasn't ended yet.
263    restore_status: Option<bhtune_db::models::RestoreStatus>,
264    restore_detail: Option<String>,
265}
266
267async fn list(
268    pool: &SqlitePool,
269    outcome: Option<bhtune_db::models::TuneOutcome>,
270    limit: i64,
271    offset: i64,
272    output: OutputFormat,
273) -> anyhow::Result<()> {
274    let mut filter = TuneRunFilter::default();
275    if let Some(outcome) = outcome {
276        filter = filter.with_outcome(outcome);
277    }
278    let pagination = Pagination::new(limit, offset);
279    let runs = TuneRunRow::list(pool, &filter, pagination).await?;
280    let total = TuneRunRow::count(pool, &filter).await?;
281
282    match output {
283        OutputFormat::Table => {
284            if runs.is_empty() {
285                println!("No runs found.");
286                return Ok(());
287            }
288
289            println!(
290                "{:<5} {:<30} {:<10} {:<10} {:<10} {:<25}",
291                "ID", "TAG NAME", "DRIVER", "OUTCOME", "PROCESS", "STARTED"
292            );
293            for run in &runs {
294                println!(
295                    "{:<5} {:<30} {:<10} {:<10} {:<10} {:<25}",
296                    run.id,
297                    run.loop_name,
298                    format!("{:?}", run.driver),
299                    format!("{:?}", run.outcome),
300                    format!("{:?}", run.config.process_type),
301                    run.started_at.to_rfc3339(),
302                );
303            }
304            println!("Showing {} of {total} total run(s).", runs.len());
305        }
306        OutputFormat::Json => {
307            let json = RunListJson {
308                returned: runs.len(),
309                total,
310                runs: runs
311                    .iter()
312                    .map(|run| RunSummaryJson {
313                        id: run.id,
314                        tag_name: run.loop_name.clone(),
315                        notes: run.notes.clone(),
316                        driver: run.driver,
317                        outcome: run.outcome,
318                        process_type: run.config.process_type,
319                        started_at: run.started_at,
320                    })
321                    .collect(),
322            };
323            println!("{}", serde_json::to_string_pretty(&json)?);
324        }
325    }
326    Ok(())
327}
328
329fn has_rows<T>(rows: &[T]) -> bool {
330    !rows.is_empty()
331}
332
333fn has_recorded_connection(opc_server: Option<&str>, bridge_host: Option<&str>) -> bool {
334    opc_server.is_some() || bridge_host.is_some()
335}
336
337fn is_table_output(output: OutputFormat) -> bool {
338    output == OutputFormat::Table
339}
340
341/// `bhtune history prune` -- applies the age-based retention policy on demand,
342/// instead of waiting for the next startup or (for `bhtune-server`) the next periodic sweep.
343///
344/// `older_than_days` overrides the configured `retention_days` policy for this invocation
345/// only, matching this project's usual per-invocation-flag-overrides-persistent-config
346/// pattern (mirroring `--db`/`--templates`); if neither is given there is nothing to prune
347/// against, and that's a plain error rather than silently doing nothing -- an operator who
348/// runs `bhtune history prune` clearly wants *something* deleted.
349///
350/// `--dry-run` reports a count and the exact cutoff timestamp that would be used, without
351/// deleting anything, via [`TuneRunRow::count_deletable_matching`] against the same
352/// [`TuneRunFilter`] shape [`crate::retention::sweep_retention`] would delete against. Runs
353/// with an eligible, active, or incomplete live recovery are excluded from both the preview
354/// and the actual delete. It deliberately doesn't itemize every matching run (unlike
355/// `history list`, which paginates): the history table is allowed to be large, and a prune
356/// preview only needs to answer "how many, and as of when", matching the automatic sweep's
357/// own INFO log shape.
358async fn prune(
359    pool: &SqlitePool,
360    config: &crate::config::BhtuneConfig,
361    older_than_days: Option<u32>,
362    dry_run: bool,
363    output: OutputFormat,
364) -> anyhow::Result<()> {
365    let days = older_than_days.or(config.retention_days).ok_or_else(|| {
366        anyhow::anyhow!(
367            "no retention policy configured; pass --older-than-days, or set --retention-days \
368             / BHTUNE_RETENTION_DAYS / the config file's retention_days key"
369        )
370    })?;
371    let now = chrono::Utc::now();
372    let cutoff = crate::retention::cutoff_for(days, now);
373
374    if dry_run {
375        let count = TuneRunRow::count_deletable_matching(
376            pool,
377            &TuneRunFilter::default().with_started_before(cutoff),
378        )
379        .await?;
380        match output {
381            OutputFormat::Table => {
382                println!(
383                    "Would delete {count} run(s) started at or before {} ({days} day(s)).",
384                    cutoff.to_rfc3339()
385                );
386            }
387            OutputFormat::Json => {
388                let json = PruneJson {
389                    retention_days: days,
390                    cutoff,
391                    dry_run: true,
392                    deleted: count as u64,
393                };
394                println!("{}", serde_json::to_string_pretty(&json)?);
395            }
396        }
397        return Ok(());
398    }
399
400    let deleted = crate::retention::sweep_retention(pool, days, now).await?;
401    match output {
402        OutputFormat::Table => {
403            println!(
404                "Deleted {deleted} run(s) started at or before {} ({days} day(s)).",
405                cutoff.to_rfc3339()
406            );
407        }
408        OutputFormat::Json => {
409            let json = PruneJson {
410                retention_days: days,
411                cutoff,
412                dry_run: false,
413                deleted,
414            };
415            println!("{}", serde_json::to_string_pretty(&json)?);
416        }
417    }
418    Ok(())
419}
420
421#[derive(serde::Serialize)]
422struct PruneJson {
423    retention_days: u32,
424    cutoff: chrono::DateTime<chrono::Utc>,
425    dry_run: bool,
426    deleted: u64,
427}
428
429/// Renders an optional PID constant reading/write for `history show`'s plain-text table --
430/// `"-"` for `None` (never attempted, or the pre-read failed), 4 decimal places otherwise.
431fn fmt_opt_f32(value: Option<f32>) -> String {
432    match value {
433        Some(v) => format!("{v:.4}"),
434        None => "-".to_string(),
435    }
436}
437
438fn fmt_opt_f64(value: Option<f64>) -> String {
439    match value {
440        Some(v) => format!("{v:.3}"),
441        None => "-".to_string(),
442    }
443}
444
445fn sampling_adequacy_label(adequacy: SamplingAdequacy) -> &'static str {
446    match adequacy {
447        SamplingAdequacy::Adequate => "adequate",
448        SamplingAdequacy::Marginal => "marginal",
449        SamplingAdequacy::NotAssessed => "not assessed",
450    }
451}
452
453fn print_latency_summary(label: &str, summary: TimingSummary) {
454    if summary.count == 0 {
455        println!("    {label:<26} none");
456        return;
457    }
458
459    println!(
460        "    {label:<26} count={} mean={} ms max={} ms",
461        summary.count,
462        fmt_opt_f64(summary.mean_ms),
463        fmt_opt_f64(summary.max_ms),
464    );
465}
466
467fn print_show_table(
468    run: &TuneRunRow,
469    samples: &[TuneSampleRow],
470    results: &[TuneResultRow],
471    writes: &[TuneWriteRow],
472    mv_actuations: &[TuneMvActuationRow],
473) -> anyhow::Result<()> {
474    let gateway_compatibility = run
475        .gateway_compatibility_json
476        .as_deref()
477        .map(crate::gateway::parse_stored_compatibility)
478        .transpose()?;
479
480    println!("Run #{} — Tag name: {}", run.id, run.loop_name);
481    println!("  Notes:           {}", run.notes.as_deref().unwrap_or("—"));
482    println!("  Driver:          {:?}", run.driver);
483    println!("  Outcome:          {:?}", run.outcome);
484    if let Some(reason) = &run.failure_reason {
485        println!("  Failure reason:   {reason}");
486    }
487    println!("  Started at:       {}", run.started_at.to_rfc3339());
488    if let Some(completed_at) = run.completed_at {
489        println!("  Completed at:     {}", completed_at.to_rfc3339());
490    }
491    println!(
492        "  Template:         {} ({:?})",
493        run.template.name, run.template_origin
494    );
495    if has_recorded_connection(run.opc_server.as_deref(), run.bridge_host.as_deref()) {
496        println!(
497            "  Connection:       server={} bridge_host={}",
498            run.opc_server.as_deref().unwrap_or("-"),
499            run.bridge_host.as_deref().unwrap_or("-"),
500        );
501    }
502    if let Some(snapshot) = gateway_compatibility {
503        println!(
504            "  Gateway compatibility: {} ({})",
505            snapshot.status.as_str(),
506            snapshot
507                .gateway_version
508                .as_deref()
509                .unwrap_or("version not reported"),
510        );
511    }
512    println!(
513        "  Process/controller: {:?} / {:?}",
514        run.config.process_type, run.config.controller_type
515    );
516    println!("  Relay amplitude:  {}%", run.config.relay_amp_percent);
517    println!(
518        "  Cycles skip/count: {} / {}",
519        run.config.num_cycles_skip, run.config.num_cycles_count
520    );
521
522    if let Some(readings) = &run.initial_readings {
523        println!(
524            "  Initial PV / MV:  {} / {}",
525            readings.pv_ini, readings.mv_ini
526        );
527        println!(
528            "  MV range:         {} - {}",
529            readings.mv_range_low, readings.mv_range_high
530        );
531        println!(
532            "  PV range:         {} - {}",
533            readings.pv_range_low, readings.pv_range_high
534        );
535        println!("  Direction:        {:?}", readings.controller_direction);
536    }
537
538    println!("  Samples recorded: {}", samples.len());
539
540    if let Some(timing) = run.timing_metrics {
541        println!("  Timing:");
542        println!("    Basis:                    {:?}", timing.basis);
543        println!(
544            "    Requested interval:       {} ms",
545            timing.requested_interval_ms
546        );
547        println!("    Observed sample gaps:     {}", timing.sample_gap_count);
548        println!(
549            "    Mean / max sample gap:    {} / {} ms",
550            fmt_opt_f64(timing.mean_sample_gap_ms),
551            fmt_opt_f64(timing.max_sample_gap_ms)
552        );
553        println!(
554            "    Missed poll opportunities: {}",
555            timing.missed_poll_opportunity_count
556        );
557        println!(
558            "    Oscillation period:       {} ms",
559            fmt_opt_f64(timing.measured_oscillation_period_ms)
560        );
561        println!(
562            "    Approx. samples / period: {}",
563            fmt_opt_f64(timing.approximate_samples_per_period)
564        );
565        println!(
566            "    Sampling adequacy:        {}",
567            sampling_adequacy_label(timing.sampling_adequacy)
568        );
569        if let Some(poll_latency) = timing.poll_latency {
570            println!("    Poll latency:");
571            print_latency_summary("PV reads:", poll_latency.pv_read);
572            print_latency_summary("MV writes:", poll_latency.mv_write);
573            print_latency_summary("MV verification:", poll_latency.mv_verification);
574            print_latency_summary("Sample persistence:", poll_latency.sample_persist);
575            print_latency_summary("Total tick work:", poll_latency.tick_work);
576        }
577    }
578
579    match (run.restore_status, &run.restore_detail) {
580        (Some(bhtune_db::models::RestoreStatus::Confirmed), _) => {
581            println!("  Restore:          confirmed");
582        }
583        (Some(bhtune_db::models::RestoreStatus::Incomplete), detail) => {
584            println!(
585                "  Restore:          INCOMPLETE -- {}",
586                detail.as_deref().unwrap_or("no detail recorded")
587            );
588        }
589        (None, _) => {}
590    }
591
592    print_show_results(run, results);
593    print_show_writes(writes);
594    print_show_mv_actuations(mv_actuations);
595    Ok(())
596}
597
598fn print_show_results(run: &TuneRunRow, results: &[TuneResultRow]) {
599    if !has_rows(results) {
600        return;
601    }
602    println!("  Calculated results:");
603    println!(
604        "    PROP / INTEGRAL / DERIV are controller-ready targets; KP / TI / TD are raw calculations."
605    );
606    println!(
607        "    {:<12} {:<10} {:<10} {:<10} {:<10} {:<12} {:<10} {:<10}  REASON",
608        "LEVEL", "STATUS", "KP", "TI(min)", "TD(min)", "PROP", "INTEGRAL", "DERIV"
609    );
610    for result in results {
611        let preview = pid_write_preview(result, run.config.controller_type, &run.template);
612        let values = preview.values.as_ref();
613        println!(
614            "    {:<12} {:<10} {:<10} {:<10} {:<10} {:<12} {:<10} {:<10}  {}",
615            format!("{:?}", result.response_level),
616            format!("{:?}", result.status),
617            fmt_opt_f32(result.kp),
618            fmt_opt_f32(result.ti_minutes),
619            fmt_opt_f32(result.td_minutes),
620            values
621                .map(|values| values.proportional.display.as_str())
622                .unwrap_or("-"),
623            values
624                .map(|values| values.integral.display.as_str())
625                .unwrap_or("-"),
626            values
627                .map(|values| values.derivative.display.as_str())
628                .unwrap_or("-"),
629            preview.unavailable_reason.as_deref().unwrap_or("-"),
630        );
631    }
632}
633
634fn print_show_writes(writes: &[TuneWriteRow]) {
635    if !has_rows(writes) {
636        return;
637    }
638    println!("  PID write-back audit:");
639    for write in writes {
640        println!(
641            "    [{}] {:?} ({:?} level): success={} previous(P={} I={} D={}) \
642             written(P={} I={} D={}) readback(P={} I={} D={}){}",
643            write.written_at.to_rfc3339(),
644            write.kind,
645            write.response_level,
646            write.success,
647            fmt_pid_recorded(write.previous.map(|p| p.proportional)),
648            fmt_pid_recorded(write.previous.map(|p| p.integral)),
649            fmt_pid_recorded(write.previous.map(|p| p.derivative)),
650            fmt_pid_recorded(write.proportional_written),
651            fmt_pid_recorded(write.integral_written),
652            fmt_pid_recorded(write.derivative_written),
653            fmt_pid_recorded(write.proportional_readback),
654            fmt_pid_recorded(write.integral_readback),
655            fmt_pid_recorded(write.derivative_readback),
656            write
657                .error_message
658                .as_ref()
659                .map(|message| format!(" error={message}"))
660                .unwrap_or_default(),
661        );
662        print_show_rollback(write);
663    }
664}
665
666fn fmt_pid_recorded(value: Option<f32>) -> String {
667    value
668        .map(|value| value.to_string())
669        .unwrap_or_else(|| "-".to_string())
670}
671
672fn print_show_rollback(write: &TuneWriteRow) {
673    let Some(rollback_state) = write.rollback_state else {
674        return;
675    };
676    println!(
677        "        rollback: {rollback_state:?}{}",
678        write
679            .rollback_error
680            .as_ref()
681            .map(|message| format!(" error={message}"))
682            .unwrap_or_default(),
683    );
684}
685
686fn print_show_mv_actuations(actuations: &[TuneMvActuationRow]) {
687    if !has_rows(actuations) {
688        return;
689    }
690    println!("  MV actuation verification:");
691    println!(
692        "    {:<5} {:<8} {:<25} {:<10} {:<10} {:<10} {:<10} {:<12} DETAIL",
693        "SEQ", "KIND", "COMMANDED", "TARGET", "READBACK", "TOLERANCE", "ATTEMPTS", "STATUS"
694    );
695    for actuation in actuations {
696        println!(
697            "    {:<5} {:<8} {:<25} {:<10} {:<10} {:<10} {:<10} {:<12} {}",
698            actuation.sequence,
699            format!("{:?}", actuation.kind),
700            actuation.commanded_at.to_rfc3339(),
701            fmt_opt_f32(Some(actuation.target_mv)),
702            fmt_opt_f32(actuation.readback_mv),
703            fmt_opt_f32(Some(actuation.tolerance)),
704            actuation.attempt_count,
705            format!("{:?}", actuation.status),
706            actuation.detail.as_deref().unwrap_or("-"),
707        );
708        println!(
709            "        confirmation due: {}  last checked: {}  quality: {}",
710            actuation.confirmation_due_at.to_rfc3339(),
711            actuation
712                .last_checked_at
713                .map(|value| value.to_rfc3339())
714                .unwrap_or_else(|| "-".to_string()),
715            actuation
716                .readback_quality
717                .map(|quality| format!("{quality:?}"))
718                .unwrap_or_else(|| "-".to_string()),
719        );
720    }
721}
722
723fn print_show_json(
724    run: &TuneRunRow,
725    samples: &[TuneSampleRow],
726    results: &[TuneResultRow],
727    writes: &[TuneWriteRow],
728    mv_actuations: &[TuneMvActuationRow],
729) -> anyhow::Result<()> {
730    let json = RunDetailJson {
731        id: run.id,
732        tag_name: run.loop_name.clone(),
733        notes: run.notes.clone(),
734        driver: run.driver,
735        outcome: run.outcome,
736        failure_reason: run.failure_reason.clone(),
737        started_at: run.started_at,
738        completed_at: run.completed_at,
739        template_name: run.template.name.clone(),
740        template_origin: run.template_origin,
741        config: run.config,
742        pid_rounding: run.template.pid_rounding,
743        opc_server: run.opc_server.clone(),
744        bridge_host: run.bridge_host.clone(),
745        gateway_compatibility: run
746            .gateway_compatibility_json
747            .as_deref()
748            .map(crate::gateway::parse_stored_compatibility)
749            .transpose()?,
750        initial_readings: run
751            .initial_readings
752            .as_ref()
753            .map(|readings| InitialReadingsJson::from(readings.clone())),
754        timing_metrics: run.timing_metrics,
755        samples_recorded: samples.len(),
756        results: results
757            .iter()
758            .map(|result| ResultJson::from_recorded(result, run))
759            .collect(),
760        writes: writes.iter().map(WriteJson::from).collect(),
761        mv_actuations: mv_actuations.iter().map(MvActuationJson::from).collect(),
762        restore_status: run.restore_status,
763        restore_detail: run.restore_detail.clone(),
764    };
765    println!("{}", serde_json::to_string_pretty(&json)?);
766    Ok(())
767}
768
769async fn show(pool: &SqlitePool, run_id: i64, output: OutputFormat) -> anyhow::Result<()> {
770    let run = TuneRunRow::get(pool, run_id)
771        .await?
772        .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
773    let samples = TuneSampleRow::list_for_run(pool, run_id).await?;
774    let results = TuneResultRow::list_for_run(pool, run_id).await?;
775    let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
776    let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
777
778    match output {
779        OutputFormat::Table => print_show_table(&run, &samples, &results, &writes, &mv_actuations)?,
780        OutputFormat::Json => print_show_json(&run, &samples, &results, &writes, &mv_actuations)?,
781    }
782
783    Ok(())
784}
785
786/// The result of an attempted revert, in `--output json` mode -- `history revert`'s
787/// equivalent of `tune`'s [`WriteBackOutcome`], but never printed as prose ahead of it (see
788/// this function's own doc comment for why).
789#[derive(serde::Serialize)]
790struct RevertJson {
791    run_id: i64,
792    /// The response level of the write-back being undone (recorded on the original `Write`
793    /// row; the revert's own audit row is written under the same response level).
794    response_level: bhtune_core::ResponseLevel,
795    reverted_to: RevertedTargetJson,
796    success: bool,
797    error_message: Option<String>,
798}
799
800#[derive(serde::Serialize)]
801struct RevertedTargetJson {
802    proportional: f32,
803    integral: f32,
804    derivative: f32,
805}
806
807/// Resolves the OPC DA connection a revert should use for `run` -- always its own recorded
808/// `opc_server`/`bridge_host`, never a value re-resolved from
809/// `--server`/`--bridge-host`/config at revert time. Re-resolving at revert time is exactly
810/// unsafe: running `history revert` from a shell whose flags/config point at a *different*
811/// gateway could write the wrong plant's old PID constants under tag names that may happen
812/// to exist on both. An explicit `--server`/`--bridge-host`
813/// flag is still accepted, but purely as a cross-check against the recorded value -- a
814/// contradicting flag is a hard error, never a silent override, and there is no fallback to
815/// config when a flag is omitted (unlike every other command's connection resolution).
816#[cfg(test)]
817fn resolve_revert_connection(
818    run: &TuneRunRow,
819    bridge_host_flag: Option<&str>,
820    server_flag: Option<&str>,
821) -> anyhow::Result<(String, String)> {
822    bhtune_runtime::history::resolve_revert_connection(run, bridge_host_flag, server_flag)
823}
824
825/// `bhtune history revert <run-id>`: adapts the confirmation and output behavior for the
826/// shared runtime's audited revert operation. The runtime owns eligibility checks,
827/// connection provenance, pre-read/write/verify, and audit persistence, including the rule
828/// that a revert never attempts a nested rollback of itself.
829///
830/// The OPC DA connection is never re-resolved from `--server`/`--bridge-host`/config the way
831/// every other command resolves it -- see [`resolve_revert_connection`] for why that would
832/// be a live-plant safety bug, not just an inconsistency. The runtime performs this
833/// cross-check before it connects or writes.
834///
835/// Every rejection that happens *before* anything is attempted (no such run, wrong driver,
836/// no write-back recorded, its pre-read failed so there is nothing to revert to, missing
837/// `--yes`, no PID constant tags, a contradicting/missing recorded connection, or a failed
838/// connection) is a plain `Err`, which `lib.rs`'s existing `fail()` reports through
839/// `--output json`'s own error contract -- exactly the same path `history show`'s "no such
840/// run" error already takes. Only the outcome of an *attempted* revert is reported here, as
841/// prose gated on `output == OutputFormat::Table` or as the one `RevertJson` object printed
842/// on success.
843#[allow(clippy::too_many_arguments)]
844async fn revert(
845    pool: &SqlitePool,
846    run_id: i64,
847    bridge_host: Option<String>,
848    server: Option<String>,
849    yes: bool,
850    output: OutputFormat,
851    config: &crate::config::BhtuneConfig,
852) -> anyhow::Result<()> {
853    let result = bhtune_runtime::history::revert_run_with_progress(
854        pool,
855        bhtune_runtime::history::RevertRequest {
856            run_id,
857            bridge_host,
858            server,
859            confirmed: yes,
860        },
861        config.allow_uncertain_quality,
862        |progress| {
863            if is_table_output(output) {
864                println!(
865                    "Reverting run {}'s {:?} PID write-back on tag '{}' to \
866                     P={} I={} D={}...",
867                    progress.run_id,
868                    progress.response_level,
869                    progress.loop_name,
870                    progress.target.proportional,
871                    progress.target.integral,
872                    progress.target.derivative
873                );
874            }
875        },
876    )
877    .await?;
878    let response_level = result.response_level;
879    let target = result.target;
880
881    match result.outcome {
882        bhtune_runtime::tune::PidWriteOutcome::Written => {
883            tracing::info!(run_id, ?response_level, "PID revert succeeded");
884            match output {
885                OutputFormat::Table => {
886                    println!(
887                        "Reverted and confirmed run {run_id}'s {response_level:?} PID write-back."
888                    );
889                }
890                OutputFormat::Json => {
891                    let json = RevertJson {
892                        run_id,
893                        response_level,
894                        reverted_to: RevertedTargetJson {
895                            proportional: target.proportional,
896                            integral: target.integral,
897                            derivative: target.derivative,
898                        },
899                        success: true,
900                        error_message: None,
901                    };
902                    println!("{}", serde_json::to_string_pretty(&json)?);
903                }
904            }
905            Ok(())
906        }
907        bhtune_runtime::tune::PidWriteOutcome::Failed { detail } => {
908            tracing::error!(run_id, ?response_level, error_message = %detail, "PID revert failed partway through; the loop may hold a mismatched set of PID constants -- see `history show` for the recorded partial state");
909            anyhow::bail!(
910                "revert failed partway through: {detail} (the loop may now hold a mismatched \
911                 set of PID constants -- see `history show {run_id}` for the recorded partial \
912                 state)"
913            );
914        }
915    }
916}
917
918#[cfg(test)]
919mod tests;