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
104pub 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
470pub 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(¤t_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;