1use bhtune_core::ResponseLevel;
4use bhtune_db::SqlitePool;
5use bhtune_db::models::{
6 LiveOperationKind, TuneDriver, TuneRunRow, TuneWriteRow, WriteKind, WriteReadback,
7};
8use bhtune_driver::OpcDaDriver;
9use thiserror::Error;
10
11use crate::{
12 live_ownership::{AuditedDriver, LiveOperationGuard},
13 tune::{PidWriteOutcome, write_pid_values_with_owner},
14};
15
16#[derive(Debug, Error)]
17pub enum RevertRunError {
18 #[error("{0}")]
19 Invalid(anyhow::Error),
20 #[error("{0}")]
21 Connection(anyhow::Error),
22 #[error("{0}")]
23 Gateway(anyhow::Error),
24 #[error(transparent)]
25 Persistence(anyhow::Error),
26}
27
28pub struct RevertRequest {
29 pub run_id: i64,
30 pub bridge_host: Option<String>,
31 pub server: Option<String>,
32 pub confirmed: bool,
33}
34
35pub struct RevertProgress<'a> {
36 pub run_id: i64,
37 pub loop_name: &'a str,
38 pub response_level: ResponseLevel,
39 pub target: WriteReadback,
40}
41
42pub struct RevertRunResult {
43 pub response_level: ResponseLevel,
44 pub target: WriteReadback,
45 pub outcome: PidWriteOutcome,
46}
47
48pub fn resolve_revert_connection(
51 run: &TuneRunRow,
52 bridge_host_flag: Option<&str>,
53 server_flag: Option<&str>,
54) -> anyhow::Result<(String, String)> {
55 let stored_server = run.opc_server.as_deref().ok_or_else(|| {
56 anyhow::anyhow!(
57 "run {}'s recorded OPC server is missing; refusing to guess which server to \
58 revert against",
59 run.id
60 )
61 })?;
62 let stored_bridge_host = run.bridge_host.as_deref().ok_or_else(|| {
63 anyhow::anyhow!(
64 "run {}'s recorded bridge host is missing; refusing to guess which gateway to \
65 revert against",
66 run.id
67 )
68 })?;
69
70 if let Some(server_flag) = server_flag
71 && server_flag != stored_server
72 {
73 anyhow::bail!(
74 "--server {server_flag:?} contradicts run {}'s recorded OPC server \
75 {stored_server:?}; refusing to revert against a different server than the run \
76 actually used -- omit --server to use the recorded value",
77 run.id
78 );
79 }
80 if let Some(bridge_host_flag) = bridge_host_flag
81 && bridge_host_flag != stored_bridge_host
82 {
83 anyhow::bail!(
84 "--bridge-host {bridge_host_flag:?} contradicts run {}'s recorded bridge host \
85 {stored_bridge_host:?}; refusing to revert through a different gateway than the \
86 run actually used -- omit --bridge-host to use the recorded value",
87 run.id
88 );
89 }
90
91 Ok((stored_bridge_host.to_string(), stored_server.to_string()))
92}
93
94pub async fn revert_run(
97 pool: &SqlitePool,
98 request: RevertRequest,
99 allow_uncertain_quality: bool,
100) -> Result<RevertRunResult, RevertRunError> {
101 revert_run_with_progress(pool, request, allow_uncertain_quality, |_| {}).await
102}
103
104pub async fn revert_run_with_progress(
107 pool: &SqlitePool,
108 request: RevertRequest,
109 allow_uncertain_quality: bool,
110 on_ready: impl FnOnce(RevertProgress<'_>),
111) -> Result<RevertRunResult, RevertRunError> {
112 let run_id = request.run_id;
113 let run = TuneRunRow::get(pool, run_id)
114 .await
115 .map_err(|error| RevertRunError::Persistence(error.into()))?
116 .ok_or_else(|| RevertRunError::Invalid(anyhow::anyhow!("no run with id {run_id}")))?;
117
118 if run.driver != TuneDriver::Opcda {
119 return Err(RevertRunError::Invalid(anyhow::anyhow!(
120 "run {run_id} used the {:?} driver, which has no live loop to revert a write \
121 against",
122 run.driver
123 )));
124 }
125
126 let writes = TuneWriteRow::list_for_run(pool, run_id)
127 .await
128 .map_err(|error| RevertRunError::Persistence(error.into()))?;
129 let last_write = writes
130 .iter()
131 .rev()
132 .find(|write| write.kind == WriteKind::Write)
133 .ok_or_else(|| {
134 RevertRunError::Invalid(anyhow::anyhow!(
135 "run {run_id} has no recorded PID write-back to revert"
136 ))
137 })?;
138 let response_level = last_write.response_level;
139 let target = last_write.previous.ok_or_else(|| {
140 RevertRunError::Invalid(anyhow::anyhow!(
141 "run {run_id}'s {response_level:?} PID write-back never recorded pre-write \
142 values (its pre-read failed at the time); nothing to revert to"
143 ))
144 })?;
145
146 if !request.confirmed {
147 return Err(RevertRunError::Invalid(anyhow::anyhow!(
148 "reverting writes PID constants back to a live loop; pass --yes to confirm"
149 )));
150 }
151
152 let (Some(p_tag), Some(i_tag), Some(d_tag)) = (
153 &run.tags.proportional_constant,
154 &run.tags.integral_constant,
155 &run.tags.derivative_constant,
156 ) else {
157 return Err(RevertRunError::Invalid(anyhow::anyhow!(
158 "run {run_id}'s snapshotted tags have no PID constant tags configured"
159 )));
160 };
161
162 let (bridge_host, server) = resolve_revert_connection(
163 &run,
164 request.bridge_host.as_deref(),
165 request.server.as_deref(),
166 )
167 .map_err(RevertRunError::Invalid)?;
168 let restore_intent = serde_json::to_string(&serde_json::json!({
169 "version": 1,
170 "kind": "pid_restore",
171 "run_id": run_id,
172 "write_kind": WriteKind::Revert,
173 "state": "awaiting_prewrite_readings",
174 }))
175 .map_err(|error| RevertRunError::Invalid(anyhow::Error::new(error)))?;
176 let ownership = LiveOperationGuard::acquire_for_recorded_run(
177 pool,
178 &run,
179 LiveOperationKind::PidRevert,
180 restore_intent,
181 )
182 .await
183 .map_err(|error| RevertRunError::Persistence(error.into()))?;
184 let operation_result = async {
185 ownership
186 .ensure_healthy()
187 .map_err(RevertRunError::Persistence)?;
188 let driver = OpcDaDriver::connect(&bridge_host, &server)
189 .await
190 .map_err(|error| RevertRunError::Connection(anyhow::Error::new(error)))?;
191 let compatibility =
192 crate::gateway::require_live_gateway_compatible(&bridge_host, Some(&server))
193 .await
194 .map_err(RevertRunError::Gateway)?;
195 record_gateway_compatibility_if_missing(pool, &run, &compatibility).await?;
196
197 on_ready(RevertProgress {
198 run_id,
199 loop_name: &run.loop_name,
200 response_level,
201 target,
202 });
203
204 let audited = AuditedDriver::new(&driver, pool, &ownership, Some(run_id));
205 let outcome = write_pid_values_with_owner(
206 pool,
207 run_id,
208 &audited,
209 p_tag,
210 i_tag,
211 d_tag,
212 response_level,
213 target,
214 WriteKind::Revert,
215 allow_uncertain_quality,
216 )
217 .await
218 .map_err(RevertRunError::Persistence)?;
219 Ok::<_, RevertRunError>(RevertRunResult {
220 response_level,
221 target,
222 outcome,
223 })
224 }
225 .await;
226 let release_result = ownership.release().await;
227 combine_revert_and_release(operation_result, release_result)
228}
229
230fn combine_revert_and_release<T>(
231 operation_result: Result<T, RevertRunError>,
232 release_result: anyhow::Result<()>,
233) -> Result<T, RevertRunError> {
234 match (operation_result, release_result) {
235 (Ok(result), Ok(())) => Ok(result),
236 (Err(error), Ok(())) => Err(error),
237 (Ok(_), Err(error)) => Err(RevertRunError::Persistence(error)),
238 (Err(error), Err(release_error)) => Err(RevertRunError::Persistence(anyhow::anyhow!(
239 "{error}; additionally failed to persist live ownership release: {release_error}"
240 ))),
241 }
242}
243
244async fn record_gateway_compatibility_if_missing(
245 pool: &SqlitePool,
246 run: &TuneRunRow,
247 compatibility: &bhtune_driver::OpcDaGatewayCompatibility,
248) -> Result<(), RevertRunError> {
249 if run.gateway_compatibility_json.is_none() {
250 let snapshot = crate::gateway::compatibility_json(compatibility);
251 TuneRunRow::record_gateway_compatibility(pool, run.id, &snapshot)
252 .await
253 .map_err(|error| RevertRunError::Persistence(error.into()))?;
254 }
255 Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use bhtune_core::{ControllerType, LoopConfig, ProcessType};
262 use bhtune_db::models::{TemplateOrigin, TuneRunRow};
263
264 fn recorded_run() -> TuneRunRow {
265 let template = bhtune_core::built_in_templates().remove(0);
266 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
267 TuneRunRow {
268 id: 7,
269 loop_id: None,
270 demo_session_id: None,
271 loop_name: "Unit1.LIC101.PV".to_string(),
272 driver: TuneDriver::Opcda,
273 opc_server: Some("Kepware.KEPServerEX.V6".to_string()),
274 bridge_host: Some("127.0.0.1:7600".to_string()),
275 started_at: chrono::Utc::now(),
276 completed_at: None,
277 outcome: bhtune_db::models::TuneOutcome::Running,
278 failure_reason: None,
279 config: bhtune_core::LoopConfig {
280 process_type: bhtune_core::ProcessType::Flow,
281 controller_type: bhtune_core::ControllerType::Pi,
282 relay_amp_percent: 10.0,
283 num_cycles_skip: 1,
284 num_cycles_count: 2,
285 noise_protection_secs: 3,
286 mrft_delay_secs: 0,
287 },
288 template_origin: bhtune_db::models::TemplateOrigin::Builtin,
289 tags,
290 template,
291 request_json: "{}".to_string(),
292 notes: None,
293 initial_readings: None,
294 allow_uncertain_quality: false,
295 timing_metrics: None,
296 effective_tuning: None,
297 gateway_compatibility_json: None,
298 restore_status: None,
299 restore_detail: None,
300 recovery_state: None,
301 recovery_evidence_json: None,
302 created_at: chrono::Utc::now(),
303 }
304 }
305
306 #[test]
307 fn revert_and_release_results_preserve_both_failures() {
308 assert!(combine_revert_and_release::<()>(Ok(()), Ok(())).is_ok());
309
310 let operation_only = combine_revert_and_release::<()>(
311 Err(RevertRunError::Invalid(anyhow::anyhow!("operation failed"))),
312 Ok(()),
313 )
314 .unwrap_err();
315 assert!(operation_only.to_string().contains("operation failed"));
316
317 let release_only =
318 combine_revert_and_release::<()>(Ok(()), Err(anyhow::anyhow!("release failed")))
319 .unwrap_err();
320 assert!(release_only.to_string().contains("release failed"));
321
322 let both = combine_revert_and_release::<()>(
323 Err(RevertRunError::Invalid(anyhow::anyhow!("operation failed"))),
324 Err(anyhow::anyhow!("release failed")),
325 )
326 .unwrap_err();
327 assert!(both.to_string().contains("operation failed"));
328 assert!(both.to_string().contains("release failed"));
329 }
330
331 #[test]
332 fn revert_connection_uses_the_recorded_values() {
333 let run = recorded_run();
334
335 assert_eq!(
336 resolve_revert_connection(&run, None, None).unwrap(),
337 (
338 "127.0.0.1:7600".to_string(),
339 "Kepware.KEPServerEX.V6".to_string()
340 )
341 );
342 assert!(
343 resolve_revert_connection(&run, Some("127.0.0.1:7600"), Some("Kepware.KEPServerEX.V6"))
344 .is_ok()
345 );
346 }
347
348 #[test]
349 fn revert_connection_rejects_missing_or_contradictory_values() {
350 let mut run = recorded_run();
351 run.bridge_host = None;
352 assert!(
353 resolve_revert_connection(&run, None, None)
354 .unwrap_err()
355 .to_string()
356 .contains("recorded bridge host is missing")
357 );
358
359 let run = recorded_run();
360 assert!(
361 resolve_revert_connection(&run, None, Some("Different.Server"))
362 .unwrap_err()
363 .to_string()
364 .contains("contradicts")
365 );
366 assert!(
367 resolve_revert_connection(&run, Some("different:7600"), None)
368 .unwrap_err()
369 .to_string()
370 .contains("contradicts")
371 );
372 }
373
374 #[tokio::test]
375 async fn gateway_compatibility_snapshot_is_recorded_only_when_missing() {
376 let pool = bhtune_db::connect_in_memory().await.unwrap();
377 bhtune_db::seed_builtin_templates(&pool, chrono::Utc::now())
378 .await
379 .unwrap();
380 let template = bhtune_core::built_in_templates().remove(0);
381 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
382 let config = LoopConfig {
383 process_type: ProcessType::Flow,
384 controller_type: ControllerType::Pi,
385 relay_amp_percent: 10.0,
386 num_cycles_skip: 1,
387 num_cycles_count: 2,
388 noise_protection_secs: 3,
389 mrft_delay_secs: 0,
390 };
391 let run = TuneRunRow::start(
392 &pool,
393 None,
394 "Unit1.LIC101.PV",
395 TuneDriver::Opcda,
396 config,
397 TemplateOrigin::Builtin,
398 &template,
399 &tags,
400 chrono::Utc::now(),
401 )
402 .await
403 .unwrap();
404 let compatibility = bhtune_driver::OpcDaGatewayCompatibility::unverified();
405 record_gateway_compatibility_if_missing(&pool, &run, &compatibility)
406 .await
407 .unwrap();
408
409 let expected = crate::gateway::compatibility_json(&compatibility);
410 let saved = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
411 assert_eq!(
412 saved.gateway_compatibility_json.as_deref(),
413 Some(expected.as_str())
414 );
415
416 let existing = r#"{"status":"full","source":"original"}"#;
417 TuneRunRow::record_gateway_compatibility(&pool, run.id, existing)
418 .await
419 .unwrap();
420 let saved = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
421 record_gateway_compatibility_if_missing(&pool, &saved, &compatibility)
422 .await
423 .unwrap();
424 let saved = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
425 assert_eq!(saved.gateway_compatibility_json.as_deref(), Some(existing));
426 }
427}