Skip to main content

bhtune_runtime/tune/
recovery.rs

1use std::fs::File;
2use std::{
3    fs::{self, OpenOptions},
4    io::Write,
5    path::Path,
6    time::Duration,
7};
8
9use crate::{
10    cancel::CtrlC,
11    live_ownership::{AuditedDriver, LiveOperationGuard, claim_key, resource_key},
12};
13use bhtune_db::{
14    SqlitePool, database_path,
15    models::{
16        CompleteRecoveryUpdate, LiveMutationStepRow, LiveOperationKind, LiveOwnershipRow,
17        LiveOwnershipState, MutationStepStatus, MvActuationKind, MvActuationStatus,
18        NewLiveOwnership, OrphanOwnerUpdate, RecoveryAttemptStatus, RestartRecoveryUpdate,
19        TuneDriver, TuneMvActuationRow, TuneOutcome, TuneRecoveryAttemptRow,
20        TuneRunInitialReadings, TuneRunRow, UnrecoverableOwnerUpdate,
21    },
22};
23use bhtune_driver::OpcDaDriver;
24use chrono::{DateTime, Utc};
25use serde::Serialize;
26use serde_json::{Value, json};
27
28use super::{
29    actuation::{ActuationAuditPolicy, MvActuationTracker},
30    config::{EffectiveTiming, validate_restore_timeout_secs},
31    prepare::{InitialState, MutationGuard, validate_initial_state},
32    request::{
33        DEFAULT_SIM_DEAD_TIME, DEFAULT_SIM_GAIN, DEFAULT_SIM_INITIAL_VALUE, DEFAULT_SIM_NOISE,
34        DEFAULT_SIM_SEED, DEFAULT_SIM_TAU, DriverKind, TuneRequest,
35    },
36    restore::{
37        RestoreAttempt, attempt_restore_with_actuation_with_timing_and_audit_policy,
38        should_settle_before_auto_release,
39    },
40};
41
42const ORPHAN_STALE_AFTER: Duration = Duration::from_secs(30);
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct RestoreLoopRequest {
46    pub run_id: i64,
47    pub yes: bool,
48    pub bridge_host: Option<String>,
49    pub server: Option<String>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub enum RecoveryStepStatus {
55    NotNeeded,
56    Succeeded,
57    Failed,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize)]
61pub struct RecoveryStepReport {
62    pub step: String,
63    pub status: RecoveryStepStatus,
64    pub target: Option<String>,
65    pub detail: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize)]
69pub struct RestoreLoopReport {
70    pub run_id: i64,
71    pub attempt_id: i64,
72    pub status: RecoveryAttemptStatus,
73    pub persisted: bool,
74    pub export_path: String,
75    pub steps: Vec<RecoveryStepReport>,
76    pub detail: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Default)]
80pub struct StartupOrphanSweepReport {
81    pub skipped_for_live_owner: bool,
82    pub examined: usize,
83    pub marked_orphaned: usize,
84    pub recoverable: usize,
85    pub interrupted_recoveries: usize,
86    pub retired_unrecoverable: usize,
87    pub exports: Vec<String>,
88}
89
90#[derive(Debug, Serialize)]
91struct RecoveryExport<'a> {
92    version: u32,
93    exported_at: DateTime<Utc>,
94    reason: &'a str,
95    candidate_owner_id: i64,
96    run_id: Option<i64>,
97    run: Option<&'a TuneRunRow>,
98    owners: &'a [LiveOwnershipRow],
99    mutation_steps: &'a [LiveMutationStepRow],
100    attempts: &'a [TuneRecoveryAttemptRow],
101    mv_actuations: &'a [TuneMvActuationRow],
102}
103
104/// Full-mode startup records every affected row before retiring a dead live owner. Holding
105/// the process-shared database lock distinguishes a killed process from a paused one whose
106/// heartbeat may be stale but whose controller operation is still live.
107pub async fn recover_startup_orphans(
108    pool: &SqlitePool,
109) -> anyhow::Result<StartupOrphanSweepReport> {
110    let Some(file_guard) = LiveOperationGuard::try_acquire_database_file(pool).await? else {
111        tracing::warn!(
112            "skipping live-orphan startup sweep because another process holds the controller ownership lock"
113        );
114        return Ok(StartupOrphanSweepReport {
115            skipped_for_live_owner: true,
116            ..StartupOrphanSweepReport::default()
117        });
118    };
119
120    let stale_before = Utc::now()
121        - chrono::Duration::from_std(ORPHAN_STALE_AFTER)
122            .map_err(|error| anyhow::anyhow!("invalid orphan heartbeat age: {error}"))?;
123    let candidates = LiveOwnershipRow::stale_active_owners(pool, stale_before).await?;
124    let db_path = database_path(pool).await?;
125    let mut report = StartupOrphanSweepReport {
126        examined: candidates.len(),
127        ..StartupOrphanSweepReport::default()
128    };
129
130    for owner in candidates {
131        let recorded_claim_key = claim_key(&owner.database_key, &owner.resource_key)?;
132        if owner.database_key != file_guard.database_key()
133            || !LiveOwnershipRow::has_claim(pool, owner.id, &recorded_claim_key).await?
134        {
135            anyhow::bail!(
136                "stale owner {} has a mismatched database identity or claim; refusing to alter its ownership record",
137                owner.id
138            );
139        }
140
141        if owner.operation_kind == LiveOperationKind::Recovery {
142            let export_path = abandon_interrupted_recovery(
143                pool,
144                &db_path,
145                &owner,
146                stale_before,
147                &recorded_claim_key,
148            )
149            .await?;
150            report.interrupted_recoveries += 1;
151            report.exports.push(export_path);
152            continue;
153        }
154
155        let run = match owner.run_id {
156            Some(run_id) => TuneRunRow::get(pool, run_id)
157                .await?
158                .ok_or_else(|| {
159                    anyhow::anyhow!("stale owner {} references missing run {run_id}", owner.id)
160                })?
161                .into(),
162            None => None,
163        };
164        match run.as_ref() {
165            Some(run)
166                if owner.operation_kind == LiveOperationKind::Tune
167                    && run.driver == TuneDriver::Opcda
168                    && run.outcome == TuneOutcome::Running =>
169            {
170                let (export_path, eligible) = mark_stale_tune_orphan(
171                    pool,
172                    &db_path,
173                    run,
174                    &owner,
175                    stale_before,
176                    &recorded_claim_key,
177                )
178                .await?;
179                report.marked_orphaned += 1;
180                report.recoverable += usize::from(eligible);
181                report.exports.push(export_path);
182            }
183            run => {
184                let export_path = retire_stale_unrecoverable_owner(
185                    pool,
186                    &db_path,
187                    &owner,
188                    run,
189                    stale_before,
190                    &recorded_claim_key,
191                )
192                .await?;
193                report.retired_unrecoverable += 1;
194                report.exports.push(export_path);
195            }
196        }
197    }
198    Ok(report)
199}
200
201async fn retire_stale_unrecoverable_owner(
202    pool: &SqlitePool,
203    db_path: &Path,
204    owner: &LiveOwnershipRow,
205    run: Option<&TuneRunRow>,
206    stale_before: DateTime<Utc>,
207    recorded_claim_key: &str,
208) -> anyhow::Result<String> {
209    let export_path = if let Some(run) = run {
210        export_run_evidence(pool, db_path, run, owner.id, "stale_owner_retirement").await?
211    } else {
212        export_owner_evidence(pool, db_path, owner, "stale_owner_retirement").await?
213    };
214    let evidence = serde_json::to_string(&json!({
215        "version": 1,
216        "event": "stale_owner_retirement",
217        "owner_id": owner.id,
218        "run_id": owner.run_id,
219        "operation_kind": owner.operation_kind,
220        "database_key": owner.database_key,
221        "resource_key": owner.resource_key,
222        "export_path": export_path,
223        "recovery_eligible": false,
224        "eligibility_detail": "the owner is stale but its operation/run state does not qualify for automated live-loop recovery",
225        "heartbeat_at": owner.heartbeat_at,
226        "stale_before": stale_before,
227        "observed_at": Utc::now(),
228        "controller_contacted": false,
229    }))?;
230    let detail = "a live-operation owner became stale without sufficient evidence for automatic recovery; inspect the exported evidence and verify the controller manually";
231    let fail_running_run_id = run
232        .filter(|run| run.outcome == TuneOutcome::Running)
233        .map(|run| run.id);
234    let retired = LiveOwnershipRow::mark_orphaned_unrecoverable(
235        pool,
236        UnrecoverableOwnerUpdate {
237            owner_id: owner.id,
238            expected_heartbeat: owner.heartbeat_at,
239            expected_claim_key: recorded_claim_key,
240            now: Utc::now(),
241            evidence_json: &evidence,
242            fail_running_run_id,
243            detail,
244        },
245    )
246    .await?;
247    if !retired {
248        anyhow::bail!(
249            "stale owner {} changed after its evidence was exported to {export_path}; no ownership rows were changed",
250            owner.id
251        );
252    }
253    Ok(export_path)
254}
255
256async fn mark_stale_tune_orphan(
257    pool: &SqlitePool,
258    db_path: &Path,
259    run: &TuneRunRow,
260    owner: &LiveOwnershipRow,
261    stale_before: DateTime<Utc>,
262    recorded_claim_key: &str,
263) -> anyhow::Result<(String, bool)> {
264    let run_id = run.id;
265    let owners = LiveOwnershipRow::list_for_run(pool, run_id).await?;
266    let mutation_steps = LiveMutationStepRow::list_for_run(pool, run_id).await?;
267    let attempts = TuneRecoveryAttemptRow::list_for_run(pool, run_id).await?;
268    let owner_steps: Vec<_> = mutation_steps
269        .iter()
270        .filter(|step| step.owner_id == owner.id)
271        .cloned()
272        .collect();
273    let (mut eligible, mut eligibility_detail) = recovery_eligibility(run, owner, &owner_steps);
274    let resource_matches = match (run.bridge_host.as_deref(), run.opc_server.as_deref()) {
275        (Some(host), Some(server)) => resource_key(host, server, &run.tags.manipulated_variable)
276            .is_ok_and(|expected| expected == owner.resource_key),
277        _ => false,
278    };
279    if eligible && !resource_matches {
280        eligible = false;
281        eligibility_detail =
282            "recorded database/controller/MV identity does not match the owner's canonical resource key"
283                .to_string();
284    }
285
286    let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
287    let export_path = write_recovery_export(
288        db_path,
289        &RecoveryExport {
290            version: 1,
291            exported_at: Utc::now(),
292            reason: "full_mode_startup_orphan_sweep",
293            candidate_owner_id: owner.id,
294            run_id: Some(run_id),
295            run: Some(run),
296            owners: &owners,
297            mutation_steps: &mutation_steps,
298            attempts: &attempts,
299            mv_actuations: &mv_actuations,
300        },
301    )?;
302    let evidence = serde_json::to_string(&json!({
303        "version": 1,
304        "event": "startup_orphan_sweep",
305        "run_id": run_id,
306        "owner_id": owner.id,
307        "database_key": owner.database_key,
308        "resource_key": owner.resource_key,
309        "resource_matches_run": resource_matches,
310        "export_path": export_path,
311        "recovery_eligible": eligible,
312        "eligibility_detail": eligibility_detail,
313        "heartbeat_at": owner.heartbeat_at,
314        "stale_before": stale_before,
315        "observed_at": Utc::now(),
316        "controller_contacted": false,
317    }))?;
318    let detail = if eligible {
319        "the live tune process ended with a durably recorded restore intent and controller mutation; use `bhtune restore-loop --yes` after reviewing the exported evidence"
320    } else {
321        "the live tune process ended, but its persisted ownership or mutation evidence is insufficient for automated recovery; inspect the exported evidence and restore the loop manually"
322    };
323    let marked = LiveOwnershipRow::mark_orphaned(
324        pool,
325        OrphanOwnerUpdate {
326            owner_id: owner.id,
327            expected_heartbeat: owner.heartbeat_at,
328            expected_claim_key: recorded_claim_key,
329            now: Utc::now(),
330            evidence_json: &evidence,
331            eligible,
332            detail,
333        },
334    )
335    .await?;
336    if !marked {
337        anyhow::bail!(
338            "orphan owner {} changed after its evidence was exported to {export_path}; no ownership rows were changed",
339            owner.id
340        );
341    }
342    Ok((export_path, eligible))
343}
344
345async fn abandon_interrupted_recovery(
346    pool: &SqlitePool,
347    db_path: &Path,
348    owner: &LiveOwnershipRow,
349    stale_before: DateTime<Utc>,
350    expected_claim_key: &str,
351) -> anyhow::Result<String> {
352    let run_id = owner.run_id.ok_or_else(|| {
353        anyhow::anyhow!(
354            "stale recovery owner {} has no run ID; refusing to alter its claim",
355            owner.id
356        )
357    })?;
358    let run = TuneRunRow::get(pool, run_id).await?.ok_or_else(|| {
359        anyhow::anyhow!(
360            "stale recovery owner {} references missing run {run_id}",
361            owner.id
362        )
363    })?;
364    if owner.operation_kind != LiveOperationKind::Recovery
365        || run.outcome != TuneOutcome::Failed
366        || run.recovery_state != Some(bhtune_db::models::TuneRecoveryState::Running)
367    {
368        anyhow::bail!(
369            "stale recovery owner {} does not match a failed run with a running recovery attempt; refusing to guess how to release its claim",
370            owner.id
371        );
372    }
373    let source_owners: Vec<_> = LiveOwnershipRow::list_for_run(pool, run_id)
374        .await?
375        .into_iter()
376        .filter(|source| {
377            source.operation_kind == LiveOperationKind::Tune
378                && source.run_id == Some(run_id)
379                && source.state == LiveOwnershipState::Orphaned
380                && source.orphan_eligible
381        })
382        .collect();
383    let source_owner = match source_owners.as_slice() {
384        [source_owner] => source_owner.clone(),
385        _ => anyhow::bail!(
386            "stale recovery owner {} has {} eligible source owners; exactly one is required",
387            owner.id,
388            source_owners.len()
389        ),
390    };
391    let expected_resource = match (run.bridge_host.as_deref(), run.opc_server.as_deref()) {
392        (Some(host), Some(server)) => resource_key(host, server, &run.tags.manipulated_variable)?,
393        _ => anyhow::bail!(
394            "run {run_id} has incomplete recorded connection provenance; stale recovery ownership is not safe to release automatically"
395        ),
396    };
397    if source_owner.run_id != Some(run_id)
398        || source_owner.database_key != owner.database_key
399        || source_owner.resource_key != owner.resource_key
400        || owner.resource_key != expected_resource
401        || !LiveOwnershipRow::has_claim(pool, owner.id, expected_claim_key).await?
402    {
403        anyhow::bail!(
404            "stale recovery owner {} does not match its source owner, recorded controller/MV, or claim; refusing to alter ownership",
405            owner.id
406        );
407    }
408    let attempts = TuneRecoveryAttemptRow::list_for_run(pool, run_id).await?;
409    let mut running_attempts = attempts.iter().filter(|attempt| {
410        attempt.recovery_owner_id == owner.id && attempt.status == RecoveryAttemptStatus::Running
411    });
412    let attempt = running_attempts.next().ok_or_else(|| {
413        anyhow::anyhow!(
414            "stale recovery owner {} has no matching running recovery attempt",
415            owner.id
416        )
417    })?;
418    let export_path = export_run_evidence(
419        pool,
420        db_path,
421        &run,
422        owner.id,
423        "interrupted_recovery_restart",
424    )
425    .await?;
426    let detail = format!(
427        "the recovery process ended without a durable final audit; startup contacted no controller. Review {export_path}, then retry with `bhtune restore-loop --yes`"
428    );
429    let evidence = serde_json::to_string(&json!({
430        "version": 1,
431        "event": "interrupted_recovery_restart",
432        "run_id": run_id,
433        "source_owner_id": source_owner.id,
434        "recovery_owner_id": owner.id,
435        "attempt_id": attempt.id,
436        "database_key": owner.database_key,
437        "resource_key": owner.resource_key,
438        "export_path": export_path,
439        "heartbeat_at": owner.heartbeat_at,
440        "stale_before": stale_before,
441        "observed_at": Utc::now(),
442        "controller_contacted": false,
443        "recovery_eligible": true,
444        "detail": detail,
445    }))?;
446    if !TuneRecoveryAttemptRow::abandon_after_restart(
447        pool,
448        RestartRecoveryUpdate {
449            attempt_id: attempt.id,
450            recovery_owner_id: owner.id,
451            expected_heartbeat: owner.heartbeat_at,
452            source_owner_id: source_owner.id,
453            run_id,
454            claim_key: expected_claim_key,
455            now: Utc::now(),
456            detail: &detail,
457            evidence_json: &evidence,
458        },
459    )
460    .await?
461    {
462        anyhow::bail!(
463            "stale recovery owner {} changed while its restart evidence was being exported; no ownership rows were changed",
464            owner.id
465        );
466    }
467    Ok(export_path)
468}
469
470/// Restores an interrupted live run only after its original database/resource owner is
471/// atomically replaced and a durable evidence export exists.
472pub async fn restore_loop(
473    pool: &SqlitePool,
474    request: RestoreLoopRequest,
475    ctrl_c: &mut CtrlC,
476) -> anyhow::Result<RestoreLoopReport> {
477    anyhow::ensure!(
478        request.yes,
479        "restore-loop requires --yes; no controller connection or mutation was attempted"
480    );
481    load_recovery_run(pool, request.run_id).await?;
482    let Some(file_guard) = LiveOperationGuard::try_acquire_database_file(pool).await? else {
483        anyhow::bail!(
484            "another live controller operation holds the database ownership lock; recovery is blocked"
485        );
486    };
487
488    let run = load_recovery_run(pool, request.run_id).await?;
489    let (bridge_host, server) = crate::history::resolve_revert_connection(
490        &run,
491        request.bridge_host.as_deref(),
492        request.server.as_deref(),
493    )?;
494    let source_owner = recovery_source_owner(pool, &run).await?;
495    let source_steps = LiveMutationStepRow::list_for_owner(pool, source_owner.id).await?;
496    validate_recovery_run(&run, &source_owner, &source_steps)?;
497    let effective_timing = recovery_timing(&run)?;
498    let initial = initial_state(&run)?;
499    let guard = mutation_guard(&run, &source_steps);
500    let args = recovery_request(&run, &bridge_host, &server, effective_timing, &initial);
501    let mut mv_actuations = Some(recovery_mv_tracker(pool, &run, &initial, &args).await?);
502    let current_db_key = file_guard.database_key().to_owned();
503    if source_owner.database_key != current_db_key {
504        anyhow::bail!(
505            "run {}'s recorded database ownership identity does not match the opened database; recovery is blocked",
506            run.id
507        );
508    }
509    let expected_resource_key =
510        resource_key(&bridge_host, &server, &run.tags.manipulated_variable)?;
511    if source_owner.resource_key != expected_resource_key {
512        anyhow::bail!(
513            "run {}'s recorded controller/MV ownership identity does not match its snapshots; recovery is blocked",
514            run.id
515        );
516    }
517    let claim_key = claim_key(&current_db_key, &expected_resource_key)?;
518    if !LiveOwnershipRow::has_claim(pool, source_owner.id, &claim_key).await? {
519        anyhow::bail!(
520            "run {} no longer owns the canonical controller/MV recovery claim; recovery is blocked",
521            run.id
522        );
523    }
524    let db_path = database_path(pool).await?;
525    let export_path = export_run_evidence(
526        pool,
527        &db_path,
528        &run,
529        source_owner.id,
530        "restore_loop_attempt",
531    )
532    .await?;
533    let now = Utc::now();
534    let attempt_evidence = serde_json::to_string(&json!({
535        "version": 1,
536        "event": "restore_loop_attempt",
537        "run_id": run.id,
538        "source_owner_id": source_owner.id,
539        "database_key": current_db_key,
540        "resource_key": expected_resource_key,
541        "recorded_bridge_host": bridge_host,
542        "recorded_server": server,
543        "export_path": export_path,
544        "started_at": now,
545        "controller_mutation_attempted": false,
546    }))?;
547    let new_owner = NewLiveOwnership {
548        run_id: Some(run.id),
549        operation_kind: LiveOperationKind::Recovery,
550        database_key: current_db_key,
551        resource_key: expected_resource_key,
552        owner_pid: i64::from(std::process::id()),
553        acquired_at: now,
554        restore_intent_json: source_owner.restore_intent_json.clone(),
555    };
556    let handoff_result = match TuneRecoveryAttemptRow::start_with_claim(
557        pool,
558        new_owner,
559        &claim_key,
560        source_owner.id,
561        run.id,
562        &export_path,
563        &attempt_evidence,
564    )
565    .await?
566    {
567        Some(ownership) => Ok(ownership),
568        None => Err(anyhow::anyhow!(
569            "run {} lost its orphan recovery claim to another contender; no controller connection was attempted",
570            run.id
571        )),
572    };
573    let (recovery_owner, attempt) = handoff_result?;
574    let mut ownership =
575        LiveOperationGuard::from_database_owner(pool, file_guard, recovery_owner, claim_key);
576
577    let finalization_result = match TuneMvActuationRow::finalize_pending_for_run(
578        pool,
579        run.id,
580        MvActuationStatus::Unverified,
581        Some("an interrupted tune left this accepted MV command unverified; a restore-loop attempt is taking over"),
582    )
583    .await
584    {
585        Ok(_) => Ok(()),
586        Err(error) => {
587            let detail =
588                format!("could not finalize an interrupted MV audit row before recovery: {error}");
589            finish_recovery_setup_failure(
590                pool,
591                &mut ownership,
592                &source_owner,
593                RecoverySetupFailure {
594                    run_id: run.id,
595                    attempt_id: attempt.id,
596                    status: RecoveryAttemptStatus::Failed,
597                    detail: &detail,
598                    evidence_json: &attempt_evidence,
599                },
600            )
601            .await?;
602            Err(anyhow::anyhow!("{detail}"))
603        }
604    };
605    finalization_result?;
606
607    let driver_result = match OpcDaDriver::connect(&bridge_host, &server).await {
608        Ok(driver) => Ok(driver),
609        Err(error) => {
610            let detail = format!(
611                "could not connect to the run's recorded OPC DA gateway/server; no restore write was attempted: {error}"
612            );
613            finish_recovery_setup_failure(
614                pool,
615                &mut ownership,
616                &source_owner,
617                RecoverySetupFailure {
618                    run_id: run.id,
619                    attempt_id: attempt.id,
620                    status: RecoveryAttemptStatus::Failed,
621                    detail: &detail,
622                    evidence_json: &attempt_evidence,
623                },
624            )
625            .await?;
626            Err(anyhow::anyhow!("{detail}"))
627        }
628    };
629    let driver = driver_result?;
630    let compatibility_result = match crate::gateway::require_live_gateway_compatible(
631        &bridge_host,
632        Some(&server),
633    )
634    .await
635    {
636        Ok(report) => Ok(report),
637        Err(error) => {
638            let detail = format!(
639                "the recorded gateway/server failed the live compatibility check; no restore write was attempted: {error}"
640            );
641            finish_recovery_setup_failure(
642                pool,
643                &mut ownership,
644                &source_owner,
645                RecoverySetupFailure {
646                    run_id: run.id,
647                    attempt_id: attempt.id,
648                    status: RecoveryAttemptStatus::Failed,
649                    detail: &detail,
650                    evidence_json: &attempt_evidence,
651                },
652            )
653            .await?;
654            Err(anyhow::anyhow!("{detail}"))
655        }
656    };
657    let compatibility = compatibility_result?;
658    if run.gateway_compatibility_json.is_none() {
659        let compatibility_json = crate::gateway::compatibility_json(&compatibility);
660        let persistence_result =
661            TuneRunRow::record_gateway_compatibility(pool, run.id, &compatibility_json).await;
662        let persistence_result = match persistence_result {
663            Ok(_) => Ok(()),
664            Err(error) => {
665                let detail = format!(
666                    "could not persist gateway compatibility evidence before restoration: {error}"
667                );
668                finish_recovery_setup_failure(
669                    pool,
670                    &mut ownership,
671                    &source_owner,
672                    RecoverySetupFailure {
673                        run_id: run.id,
674                        attempt_id: attempt.id,
675                        status: RecoveryAttemptStatus::Failed,
676                        detail: &detail,
677                        evidence_json: &attempt_evidence,
678                    },
679                )
680                .await?;
681                Err(anyhow::anyhow!("{detail}"))
682            }
683        };
684        persistence_result?;
685    }
686
687    let audited = AuditedDriver::new(&driver, pool, &ownership, Some(run.id));
688    let restore_attempt = attempt_restore_with_actuation_with_timing_and_audit_policy(
689        pool,
690        run.id,
691        &args,
692        effective_timing,
693        &audited,
694        &run.tags,
695        &run.template,
696        &initial,
697        &guard,
698        run.allow_uncertain_quality,
699        ctrl_c,
700        &mut mv_actuations,
701        None,
702        run.timing_metrics
703            .and_then(|metrics| metrics.measured_oscillation_period_ms),
704        None,
705        ActuationAuditPolicy::Required,
706    )
707    .await;
708    let mut detail = match &restore_attempt {
709        RestoreAttempt::Confirmed => None,
710        RestoreAttempt::Incomplete { reason } => Some(reason.clone()),
711    };
712    append_error_detail(&mut detail, ownership.ensure_healthy());
713
714    let (step_reports, audit_error, controller_mutation_attempted) =
715        recovery_step_reports(pool, &run, &initial, &guard, ownership.owner().id, &args).await;
716    let audit_passed = audit_error.is_none();
717    append_optional_detail(&mut detail, audit_error);
718    let all_steps_confirmed = step_reports.iter().all(|step| {
719        matches!(
720            step.status,
721            RecoveryStepStatus::Succeeded | RecoveryStepStatus::NotNeeded
722        )
723    });
724    let status = if matches!(restore_attempt, RestoreAttempt::Confirmed)
725        && ownership.ensure_healthy().is_ok()
726        && audit_passed
727        && all_steps_confirmed
728    {
729        RecoveryAttemptStatus::Confirmed
730    } else {
731        RecoveryAttemptStatus::Incomplete
732    };
733    let evidence = serde_json::to_string(&json!({
734        "version": 1,
735        "event": "restore_loop_result",
736        "run_id": run.id,
737        "source_owner_id": source_owner.id,
738        "recovery_owner_id": ownership.owner().id,
739        "attempt_id": attempt.id,
740        "export_path": export_path,
741        "status": status,
742        "steps": step_reports,
743        "detail": detail,
744        "completed_at": Utc::now(),
745        "controller_mutation_attempted": controller_mutation_attempted,
746    }))?;
747
748    ownership.stop_heartbeat().await;
749    let persisted = TuneRecoveryAttemptRow::complete_with_ownership(
750        pool,
751        CompleteRecoveryUpdate {
752            attempt_id: attempt.id,
753            recovery_owner_id: ownership.owner().id,
754            source_owner_id: source_owner.id,
755            run_id: run.id,
756            claim_key: ownership.claim_key(),
757            status,
758            now: Utc::now(),
759            detail: detail.as_deref(),
760            evidence_json: &evidence,
761        },
762    )
763    .await;
764    drop(ownership);
765    let persisted = match persisted {
766        Ok(()) => true,
767        Err(error) => {
768            detail = Some(final_audit_failure_detail(detail.as_deref(), &error));
769            false
770        }
771    };
772
773    Ok(RestoreLoopReport {
774        run_id: run.id,
775        attempt_id: attempt.id,
776        status: if persisted {
777            status
778        } else {
779            RecoveryAttemptStatus::Incomplete
780        },
781        persisted,
782        export_path,
783        steps: step_reports,
784        detail,
785    })
786}
787
788struct RecoverySetupFailure<'a> {
789    run_id: i64,
790    attempt_id: i64,
791    status: RecoveryAttemptStatus,
792    detail: &'a str,
793    evidence_json: &'a str,
794}
795
796async fn finish_recovery_setup_failure(
797    pool: &SqlitePool,
798    ownership: &mut LiveOperationGuard,
799    source_owner: &LiveOwnershipRow,
800    failure: RecoverySetupFailure<'_>,
801) -> anyhow::Result<()> {
802    ownership.stop_heartbeat().await;
803    TuneRecoveryAttemptRow::complete_with_ownership(
804        pool,
805        CompleteRecoveryUpdate {
806            attempt_id: failure.attempt_id,
807            recovery_owner_id: ownership.owner().id,
808            source_owner_id: source_owner.id,
809            run_id: failure.run_id,
810            claim_key: ownership.claim_key(),
811            status: failure.status,
812            now: Utc::now(),
813            detail: Some(failure.detail),
814            evidence_json: failure.evidence_json,
815        },
816    )
817    .await?;
818    Ok(())
819}
820
821async fn load_recovery_run(pool: &SqlitePool, run_id: i64) -> anyhow::Result<TuneRunRow> {
822    let run = TuneRunRow::get(pool, run_id)
823        .await?
824        .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
825    if run.driver != TuneDriver::Opcda {
826        anyhow::bail!(
827            "run {run_id} used the {:?} driver; only a live OPC DA tune can be restored",
828            run.driver
829        );
830    }
831    let recovery_state_is_eligible = matches!(
832        run.recovery_state,
833        Some(
834            bhtune_db::models::TuneRecoveryState::Eligible
835                | bhtune_db::models::TuneRecoveryState::Incomplete
836        )
837    );
838    if run.outcome != TuneOutcome::Failed || !recovery_state_is_eligible {
839        anyhow::bail!(
840            "run {run_id} is not an explicitly eligible orphaned run; legacy and active rows fail closed"
841        );
842    }
843    Ok(run)
844}
845
846async fn recovery_source_owner(
847    pool: &SqlitePool,
848    run: &TuneRunRow,
849) -> anyhow::Result<LiveOwnershipRow> {
850    let owners = LiveOwnershipRow::list_for_run(pool, run.id).await?;
851    let mut eligible: Vec<_> = owners
852        .into_iter()
853        .filter(|owner| {
854            owner.operation_kind == LiveOperationKind::Tune
855                && owner.run_id == Some(run.id)
856                && owner.state == LiveOwnershipState::Orphaned
857                && owner.orphan_eligible
858        })
859        .collect();
860    if eligible.len() != 1 {
861        anyhow::bail!(
862            "run {} has {} eligible orphan ownership records; exactly one is required",
863            run.id,
864            eligible.len()
865        );
866    }
867    Ok(eligible.remove(0))
868}
869
870fn validate_recovery_run(
871    run: &TuneRunRow,
872    source_owner: &LiveOwnershipRow,
873    source_steps: &[LiveMutationStepRow],
874) -> anyhow::Result<()> {
875    if !source_owner.orphan_eligible
876        || source_owner.state != LiveOwnershipState::Orphaned
877        || source_owner.operation_kind != LiveOperationKind::Tune
878        || source_owner.run_id != Some(run.id)
879    {
880        anyhow::bail!(
881            "run {} has no matching eligible orphaned tune owner; recovery is blocked",
882            run.id
883        );
884    }
885    let (eligible, detail) = recovery_eligibility(run, source_owner, source_steps);
886    if !eligible {
887        anyhow::bail!(
888            "run {} lacks complete structured restore evidence ({detail}); use operator-guided manual restore",
889            run.id
890        );
891    }
892    Ok(())
893}
894
895fn recovery_eligibility(
896    run: &TuneRunRow,
897    owner: &LiveOwnershipRow,
898    steps: &[LiveMutationStepRow],
899) -> (bool, String) {
900    if run.driver != TuneDriver::Opcda
901        || (run.outcome != TuneOutcome::Running
902            && !(run.outcome == TuneOutcome::Failed
903                && matches!(
904                    run.recovery_state,
905                    Some(
906                        bhtune_db::models::TuneRecoveryState::Eligible
907                            | bhtune_db::models::TuneRecoveryState::Incomplete
908                    )
909                )))
910        || owner.operation_kind != LiveOperationKind::Tune
911        || owner.run_id != Some(run.id)
912    {
913        return (
914            false,
915            "run and owner provenance does not identify a running OPC DA tune".to_string(),
916        );
917    }
918    if run.bridge_host.as_deref().is_none_or(str::is_empty)
919        || run.opc_server.as_deref().is_none_or(str::is_empty)
920    {
921        return (
922            false,
923            "recorded bridge host or OPC server is missing".to_string(),
924        );
925    }
926    let Some(initial) = run.initial_readings.as_ref() else {
927        return (false, "initial controller readings are missing".to_string());
928    };
929    let initial_setpoint_is_required = run.template.revert_mode
930        && run.tags.controller_mode.is_some()
931        && initial.mode_raw.as_deref() == Some(run.template.mode_auto_value.as_str())
932        && run.tags.setpoint_variable.is_some();
933    if initial_setpoint_is_required && initial.setpoint_ini.is_none() {
934        return (
935            false,
936            "the configured setpoint tag has no recorded initial value".to_string(),
937        );
938    }
939    let Some(intent_json) = owner.restore_intent_json.as_deref() else {
940        return (false, "durable restore intent is missing".to_string());
941    };
942    if let Err(error) = validate_restore_intent(run, intent_json, initial) {
943        return (false, error.to_string());
944    }
945    let Some(_) = run.effective_tuning else {
946        return (
947            false,
948            "recorded restore timing policy is missing".to_string(),
949        );
950    };
951    if let Err(error) = recovery_timing(run) {
952        return (
953            false,
954            format!("recorded restore timing policy is invalid: {error}"),
955        );
956    }
957    if let Err(error) = run.config.validate() {
958        return (
959            false,
960            format!("recorded loop configuration is invalid: {error}"),
961        );
962    }
963    let initial_state = initial_state_from_readings(initial);
964    if validate_initial_state(&initial_state).is_err()
965        || !initial_state.pv_ini.is_finite()
966        || initial_state
967            .setpoint_ini
968            .is_some_and(|value| !value.is_finite())
969        || (run.tags.controller_mode.is_some() && initial_state.mode_raw.is_none())
970        || (run.tags.mode_attribute.is_some() && initial_state.mode_attribute_raw.is_none())
971    {
972        return (
973            false,
974            "persisted initial readings fail range, finiteness, or tagged-mode validation"
975                .to_string(),
976        );
977    }
978    let allowed_tags = [
979        Some(run.tags.manipulated_variable.as_str()),
980        run.tags.controller_mode.as_deref(),
981        run.tags.mode_attribute.as_deref(),
982        run.tags.setpoint_variable.as_deref(),
983    ];
984    let mut mutation_seen = false;
985    for step in steps.iter().filter(|step| step.step == "controller_write") {
986        if step.status == MutationStepStatus::NotNeeded {
987            continue;
988        }
989        let Some((tag, _)) = parse_write_target(&step.target_json) else {
990            return (
991                false,
992                "a controller-write audit target is malformed".to_string(),
993            );
994        };
995        if allowed_tags.iter().flatten().any(|allowed| *allowed == tag) {
996            mutation_seen = true;
997        }
998    }
999    if !mutation_seen {
1000        return (
1001            false,
1002            "no durable audit records a possible mutation to the loop's MV, mode, setpoint, or mode attribute".to_string(),
1003        );
1004    }
1005    (
1006        true,
1007        "complete structured restore and mutation evidence is present".to_string(),
1008    )
1009}
1010
1011fn validate_restore_intent(
1012    run: &TuneRunRow,
1013    intent_json: &str,
1014    initial: &TuneRunInitialReadings,
1015) -> anyhow::Result<()> {
1016    let intent: Value = serde_json::from_str(intent_json)
1017        .map_err(|_| anyhow::anyhow!("persisted restore intent is not valid JSON"))?;
1018    let initial_json = serde_json::to_value(initial)?;
1019    let expected_policy = json!({
1020        "revert_mode": run.template.revert_mode,
1021        "mode_auto_value": run.template.mode_auto_value,
1022        "mode_manual_value": run.template.mode_manual_value,
1023        "mode_attribute_program_value": run.template.mode_attribute_program_value,
1024    });
1025    if intent.get("version").and_then(Value::as_u64) != Some(1)
1026        || intent.get("kind").and_then(Value::as_str) != Some("tune_restore")
1027        || intent.get("run_id").and_then(Value::as_i64) != Some(run.id)
1028        || intent.get("state").and_then(Value::as_str) != Some("ready_to_restore")
1029        || intent.get("mv_tag").and_then(Value::as_str)
1030            != Some(run.tags.manipulated_variable.as_str())
1031        || intent.get("initial_readings") != Some(&initial_json)
1032        || intent.get("template_policy") != Some(&expected_policy)
1033    {
1034        anyhow::bail!(
1035            "durable restore intent does not exactly match the run's recorded readings, tags, template policy, and version"
1036        );
1037    }
1038    Ok(())
1039}
1040
1041fn initial_state(run: &TuneRunRow) -> anyhow::Result<InitialState> {
1042    let initial = run
1043        .initial_readings
1044        .as_ref()
1045        .ok_or_else(|| anyhow::anyhow!("run {} has no recorded initial readings", run.id))?;
1046    Ok(initial_state_from_readings(initial))
1047}
1048
1049fn initial_state_from_readings(initial: &TuneRunInitialReadings) -> InitialState {
1050    InitialState {
1051        pv_ini: initial.pv_ini,
1052        mv_ini: initial.mv_ini,
1053        pv_range_high: initial.pv_range_high,
1054        pv_range_low: initial.pv_range_low,
1055        mv_range_high: initial.mv_range_high,
1056        mv_range_low: initial.mv_range_low,
1057        direction: initial.controller_direction,
1058        mode_raw: initial.mode_raw.clone(),
1059        mode_attribute_raw: initial.mode_attribute_raw.clone(),
1060        setpoint_ini: initial.setpoint_ini,
1061    }
1062}
1063
1064fn append_error_detail(detail: &mut Option<String>, result: anyhow::Result<()>) {
1065    if let Err(error) = result {
1066        append_optional_detail(detail, Some(error.to_string()));
1067    }
1068}
1069
1070fn append_optional_detail(detail: &mut Option<String>, additional: Option<String>) {
1071    if let Some(additional) = additional {
1072        *detail = Some(match detail.take() {
1073            Some(existing) => format!("{existing}; {additional}"),
1074            None => additional,
1075        });
1076    }
1077}
1078
1079fn final_audit_failure_detail(existing: Option<&str>, error: impl std::fmt::Display) -> String {
1080    match existing {
1081        Some(existing) => format!(
1082            "{existing}; failed to persist the final recovery audit: {error}; inspect the loop manually"
1083        ),
1084        None => format!(
1085            "failed to persist the final recovery audit: {error}; the restore is not confirmed and the loop must be inspected manually"
1086        ),
1087    }
1088}
1089
1090fn mutation_guard(run: &TuneRunRow, steps: &[LiveMutationStepRow]) -> MutationGuard {
1091    let was_written = |tag: Option<&str>, expected: Option<Value>| {
1092        let Some(tag) = tag else {
1093            return false;
1094        };
1095        steps.iter().any(|step| {
1096            if step.step != "controller_write" || step.status == MutationStepStatus::NotNeeded {
1097                return false;
1098            }
1099            let Some((written_tag, written_value)) = parse_write_target(&step.target_json) else {
1100                return false;
1101            };
1102            written_tag == tag
1103                && expected
1104                    .as_ref()
1105                    .is_none_or(|value| value == &written_value)
1106        })
1107    };
1108    MutationGuard {
1109        mode_attribute_written: was_written(
1110            run.tags.mode_attribute.as_deref(),
1111            run.template
1112                .mode_attribute_program_value
1113                .as_ref()
1114                .map(|value| Value::String(value.clone())),
1115        ),
1116        mode_written: was_written(
1117            run.tags.controller_mode.as_deref(),
1118            Some(Value::String(run.template.mode_manual_value.clone())),
1119        ),
1120        mv_written: was_written(Some(run.tags.manipulated_variable.as_str()), None),
1121    }
1122}
1123
1124fn parse_write_target(target_json: &str) -> Option<(String, Value)> {
1125    let target: Value = serde_json::from_str(target_json).ok()?;
1126    Some((
1127        target.get("tag")?.as_str()?.to_string(),
1128        target.get("value")?.clone(),
1129    ))
1130}
1131
1132fn recovery_timing(run: &TuneRunRow) -> anyhow::Result<EffectiveTiming> {
1133    let tuning = run
1134        .effective_tuning
1135        .ok_or_else(|| anyhow::anyhow!("run {} has no saved timing policy", run.id))?;
1136    if tuning.poll_interval_ms == 0 || tuning.op_timeout_secs == 0 {
1137        anyhow::bail!(
1138            "run {} has invalid saved poll/operation timing; recovery is blocked",
1139            run.id
1140        );
1141    }
1142    validate_restore_timeout_secs(DriverKind::Opcda, tuning.restore_timeout_secs)?;
1143    Ok(EffectiveTiming {
1144        mrft_delay_secs: tuning.mrft_delay_secs,
1145        poll_interval_ms: tuning.poll_interval_ms,
1146        timeout_secs: tuning.timeout_secs,
1147        op_timeout_secs: tuning.op_timeout_secs,
1148        restore_timeout_secs: tuning.restore_timeout_secs,
1149    })
1150}
1151
1152fn recovery_request(
1153    run: &TuneRunRow,
1154    bridge_host: &str,
1155    server: &str,
1156    timing: EffectiveTiming,
1157    initial: &InitialState,
1158) -> TuneRequest {
1159    let _ = timing;
1160    TuneRequest {
1161        tagname: run.loop_name.clone(),
1162        template: run.template.name.clone(),
1163        process_type: run.config.process_type,
1164        controller_type: run.config.controller_type,
1165        relay_amp: run.config.relay_amp_percent,
1166        cycles_skip: Some(run.config.num_cycles_skip),
1167        cycles_count: Some(run.config.num_cycles_count),
1168        noise_protection_secs: Some(run.config.noise_protection_secs),
1169        driver: DriverKind::Opcda,
1170        bridge_host: Some(bridge_host.to_string()),
1171        server: Some(server.to_string()),
1172        sim_gain: DEFAULT_SIM_GAIN,
1173        sim_tau: DEFAULT_SIM_TAU,
1174        sim_dead_time: DEFAULT_SIM_DEAD_TIME,
1175        sim_noise: DEFAULT_SIM_NOISE,
1176        sim_seed: DEFAULT_SIM_SEED,
1177        sim_initial_pv: DEFAULT_SIM_INITIAL_VALUE,
1178        sim_initial_mv: DEFAULT_SIM_INITIAL_VALUE,
1179        pv_range_high: None,
1180        pv_range_low: None,
1181        mv_range_high: None,
1182        mv_range_low: None,
1183        direction: Some(initial.direction),
1184        tag_overrides: None,
1185        notes: None,
1186        yes: true,
1187        write_pid: None,
1188        #[cfg(test)]
1189        mrft_delay: timing.mrft_delay_secs,
1190        #[cfg(test)]
1191        poll_interval_ms: timing.poll_interval_ms,
1192        #[cfg(test)]
1193        timeout_secs: timing.timeout_secs,
1194        #[cfg(test)]
1195        op_timeout_secs: timing.op_timeout_secs,
1196        #[cfg(test)]
1197        restore_timeout_secs: timing.restore_timeout_secs,
1198    }
1199}
1200
1201async fn recovery_mv_tracker(
1202    pool: &SqlitePool,
1203    run: &TuneRunRow,
1204    initial: &InitialState,
1205    args: &TuneRequest,
1206) -> anyhow::Result<MvActuationTracker> {
1207    let rows = TuneMvActuationRow::list_for_run(pool, run.id).await?;
1208    let next_sequence = rows
1209        .iter()
1210        .map(|row| row.sequence)
1211        .max()
1212        .map_or(Some(0), |sequence| sequence.checked_add(1))
1213        .ok_or_else(|| anyhow::anyhow!("run {} MV actuation sequence is exhausted", run.id))?;
1214    let mut tracker = MvActuationTracker::for_run(args, initial).ok_or_else(|| {
1215        anyhow::anyhow!(
1216            "run {} cannot initialize its OPC DA MV audit tracker",
1217            run.id
1218        )
1219    })?;
1220    tracker.next_sequence = next_sequence;
1221    tracker.previous_commanded_mv = rows
1222        .iter()
1223        .max_by_key(|row| row.sequence)
1224        .map_or(initial.mv_ini, |row| row.target_mv);
1225    Ok(tracker)
1226}
1227
1228async fn recovery_step_reports(
1229    pool: &SqlitePool,
1230    run: &TuneRunRow,
1231    initial: &InitialState,
1232    guard: &MutationGuard,
1233    recovery_owner_id: i64,
1234    args: &TuneRequest,
1235) -> (Vec<RecoveryStepReport>, Option<String>, bool) {
1236    let mutations = match LiveMutationStepRow::list_for_owner(pool, recovery_owner_id).await {
1237        Ok(steps) => steps,
1238        Err(error) => {
1239            return (
1240                vec![failed_step(
1241                    "audit",
1242                    None,
1243                    format!("could not read persisted recovery mutation audit: {error}"),
1244                )],
1245                Some(format!(
1246                    "could not read persisted recovery mutation audit: {error}"
1247                )),
1248                true,
1249            );
1250        }
1251    };
1252    let controller_mutation_attempted =
1253        mutations.iter().any(|step| step.step == "controller_write");
1254    let actuations = match TuneMvActuationRow::list_for_run(pool, run.id).await {
1255        Ok(rows) => rows,
1256        Err(error) => {
1257            return (
1258                vec![failed_step(
1259                    "mv",
1260                    Some(initial.mv_ini.to_string()),
1261                    format!("could not read MV confirmation audit: {error}"),
1262                )],
1263                Some(format!("could not read MV confirmation audit: {error}")),
1264                controller_mutation_attempted,
1265            );
1266        }
1267    };
1268
1269    let mut reports = Vec::with_capacity(4);
1270    let mv = actuations
1271        .iter()
1272        .filter(|row| row.kind == MvActuationKind::Restore)
1273        .max_by_key(|row| row.sequence);
1274    let mv_confirmed = mv.is_some_and(|row| row.status == MvActuationStatus::Confirmed);
1275    reports.push(match mv {
1276        Some(row) if row.status == MvActuationStatus::Confirmed => RecoveryStepReport {
1277            step: "mv".to_string(),
1278            status: RecoveryStepStatus::Succeeded,
1279            target: Some(row.target_mv.to_string()),
1280            detail: row.detail.clone(),
1281        },
1282        Some(row) => failed_step(
1283            "mv",
1284            Some(row.target_mv.to_string()),
1285            row.detail
1286                .clone()
1287                .unwrap_or_else(|| format!("MV restore confirmation ended as {:?}", row.status)),
1288        ),
1289        None => failed_step(
1290            "mv",
1291            Some(initial.mv_ini.to_string()),
1292            "no durable MV restore confirmation was recorded".to_string(),
1293        ),
1294    });
1295
1296    let mode_deferred =
1297        should_settle_before_auto_release(args, &run.tags, &run.template, initial, guard)
1298            && !mv_confirmed;
1299    let mode_needed = !mode_deferred
1300        && guard.mode_written
1301        && run.template.revert_mode
1302        && run.tags.controller_mode.is_some()
1303        && initial.mode_raw.as_deref() != Some(run.template.mode_manual_value.as_str());
1304    let mut mode_report = write_step_report(
1305        "mode",
1306        run.tags.controller_mode.as_deref(),
1307        initial
1308            .mode_raw
1309            .as_deref()
1310            .map(|value| Value::String(value.to_string())),
1311        mode_needed,
1312        &mutations,
1313    );
1314    if mode_deferred {
1315        mode_report.detail = Some(
1316            "MV restore was not confirmed, so automatic-mode release was intentionally withheld; inspect the loop before changing modes"
1317                .to_string(),
1318        );
1319    }
1320    reports.push(mode_report);
1321
1322    let setpoint_needed = guard.mode_written
1323        && run.template.revert_mode
1324        && run.tags.setpoint_variable.is_some()
1325        && initial.setpoint_ini.is_some();
1326    reports.push(write_step_report(
1327        "setpoint",
1328        run.tags.setpoint_variable.as_deref(),
1329        initial.setpoint_ini.map(|value| json!(value)),
1330        setpoint_needed,
1331        &mutations,
1332    ));
1333
1334    let mode_attribute_needed = guard.mode_attribute_written
1335        && run.tags.mode_attribute.is_some()
1336        && initial.mode_attribute_raw.as_deref()
1337            != run.template.mode_attribute_program_value.as_deref();
1338    reports.push(write_step_report(
1339        "mode_attribute",
1340        run.tags.mode_attribute.as_deref(),
1341        initial
1342            .mode_attribute_raw
1343            .as_deref()
1344            .map(|value| Value::String(value.to_string())),
1345        mode_attribute_needed,
1346        &mutations,
1347    ));
1348
1349    let audit_error = reports
1350        .iter()
1351        .find(|step| step.status == RecoveryStepStatus::Failed)
1352        .and_then(|step| step.detail.clone());
1353    (reports, audit_error, controller_mutation_attempted)
1354}
1355
1356fn write_step_report(
1357    name: &str,
1358    tag: Option<&str>,
1359    expected: Option<Value>,
1360    needed: bool,
1361    mutation_steps: &[LiveMutationStepRow],
1362) -> RecoveryStepReport {
1363    if !needed {
1364        return RecoveryStepReport {
1365            step: name.to_string(),
1366            status: RecoveryStepStatus::NotNeeded,
1367            target: expected.as_ref().map(value_label),
1368            detail: None,
1369        };
1370    }
1371    let (Some(tag), Some(expected)) = (tag, expected) else {
1372        return failed_step(
1373            name,
1374            None,
1375            "restore preconditions require a recorded tag and target value".to_string(),
1376        );
1377    };
1378    let latest = mutation_steps
1379        .iter()
1380        .filter(|step| step.step == "controller_write")
1381        .filter_map(|step| {
1382            let (written_tag, written_value) = parse_write_target(&step.target_json)?;
1383            (written_tag == tag && json_values_match(&expected, &written_value)).then_some(step)
1384        })
1385        .max_by_key(|step| step.id);
1386    match latest {
1387        Some(step) if step.status == MutationStepStatus::Confirmed => RecoveryStepReport {
1388            step: name.to_string(),
1389            status: RecoveryStepStatus::Succeeded,
1390            target: Some(value_label(&expected)),
1391            detail: step.detail.clone(),
1392        },
1393        Some(step) => failed_step(
1394            name,
1395            Some(value_label(&expected)),
1396            step.detail
1397                .clone()
1398                .unwrap_or_else(|| format!("write audit ended as {:?}", step.status)),
1399        ),
1400        None => failed_step(
1401            name,
1402            Some(value_label(&expected)),
1403            "no durable restore write audit was recorded".to_string(),
1404        ),
1405    }
1406}
1407
1408fn json_values_match(expected: &Value, actual: &Value) -> bool {
1409    match (expected.as_f64(), actual.as_f64()) {
1410        (Some(expected), Some(actual)) => expected == actual,
1411        _ => expected == actual,
1412    }
1413}
1414
1415fn value_label(value: &Value) -> String {
1416    match value {
1417        Value::String(value) => value.clone(),
1418        _ => value.to_string(),
1419    }
1420}
1421
1422fn failed_step(name: &str, target: Option<String>, detail: String) -> RecoveryStepReport {
1423    RecoveryStepReport {
1424        step: name.to_string(),
1425        status: RecoveryStepStatus::Failed,
1426        target,
1427        detail: Some(detail),
1428    }
1429}
1430
1431async fn export_run_evidence(
1432    pool: &SqlitePool,
1433    db_path: &Path,
1434    run: &TuneRunRow,
1435    candidate_owner_id: i64,
1436    reason: &str,
1437) -> anyhow::Result<String> {
1438    let owners = LiveOwnershipRow::list_for_run(pool, run.id).await?;
1439    let mutation_steps = LiveMutationStepRow::list_for_run(pool, run.id).await?;
1440    let attempts = TuneRecoveryAttemptRow::list_for_run(pool, run.id).await?;
1441    let mv_actuations = TuneMvActuationRow::list_for_run(pool, run.id).await?;
1442    write_recovery_export(
1443        db_path,
1444        &RecoveryExport {
1445            version: 1,
1446            exported_at: Utc::now(),
1447            reason,
1448            candidate_owner_id,
1449            run_id: Some(run.id),
1450            run: Some(run),
1451            owners: &owners,
1452            mutation_steps: &mutation_steps,
1453            attempts: &attempts,
1454            mv_actuations: &mv_actuations,
1455        },
1456    )
1457}
1458
1459async fn export_owner_evidence(
1460    pool: &SqlitePool,
1461    db_path: &Path,
1462    owner: &LiveOwnershipRow,
1463    reason: &str,
1464) -> anyhow::Result<String> {
1465    let owners = vec![owner.clone()];
1466    let mutation_steps = LiveMutationStepRow::list_for_owner(pool, owner.id).await?;
1467    let attempts = Vec::new();
1468    let mv_actuations = Vec::new();
1469    write_recovery_export(
1470        db_path,
1471        &RecoveryExport {
1472            version: 1,
1473            exported_at: Utc::now(),
1474            reason,
1475            candidate_owner_id: owner.id,
1476            run_id: None,
1477            run: None,
1478            owners: &owners,
1479            mutation_steps: &mutation_steps,
1480            attempts: &attempts,
1481            mv_actuations: &mv_actuations,
1482        },
1483    )
1484}
1485
1486fn write_recovery_export(db_path: &Path, evidence: &RecoveryExport<'_>) -> anyhow::Result<String> {
1487    write_recovery_export_with_open(db_path, evidence, |path| {
1488        OpenOptions::new().write(true).create_new(true).open(path)
1489    })
1490}
1491
1492fn write_recovery_export_with_open(
1493    db_path: &Path,
1494    evidence: &RecoveryExport<'_>,
1495    mut open: impl FnMut(&Path) -> std::io::Result<File>,
1496) -> anyhow::Result<String> {
1497    let parent = db_path
1498        .parent()
1499        .filter(|parent| !parent.as_os_str().is_empty())
1500        .unwrap_or(Path::new("."));
1501    let database_name = db_path
1502        .file_name()
1503        .and_then(std::ffi::OsStr::to_str)
1504        .ok_or_else(|| anyhow::anyhow!("database filename is not valid UTF-8"))?;
1505    let export_directory = parent.join(format!("{database_name}.recovery"));
1506    fs::create_dir_all(&export_directory).map_err(|error| {
1507        anyhow::anyhow!(
1508            "could not create recovery evidence directory {}: {error}",
1509            export_directory.display()
1510        )
1511    })?;
1512    let timestamp = evidence.exported_at.format("%Y%m%dT%H%M%S%.3fZ");
1513    let run_label = evidence
1514        .run_id
1515        .map_or_else(|| "no-run".to_string(), |run_id| format!("run-{run_id}"));
1516    for suffix in 0..100_u8 {
1517        let path = export_directory.join(format!(
1518            "{timestamp}-{run_label}-owner-{}-{suffix:02}.json",
1519            evidence.candidate_owner_id,
1520        ));
1521        match open(&path) {
1522            Ok(mut file) => {
1523                serde_json::to_writer_pretty(&mut file, evidence)?;
1524                file.write_all(b"\n")?;
1525                file.sync_all()?;
1526                #[cfg(unix)]
1527                File::open(&export_directory)?.sync_all()?;
1528                return Ok(path.to_string_lossy().into_owned());
1529            }
1530            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1531            Err(error) => {
1532                return Err(anyhow::anyhow!(
1533                    "could not write recovery evidence export {}: {error}",
1534                    path.display()
1535                ));
1536            }
1537        }
1538    }
1539    anyhow::bail!(
1540        "could not choose a unique recovery evidence export path under {}",
1541        export_directory.display()
1542    )
1543}
1544
1545#[cfg(test)]
1546mod tests;