| 1 | //! Durable automation formatting and operator actions. |
| 2 | |
| 3 | use crate::automation_manager::{ |
| 4 | AutomationRecord, AutomationRunRecord, AutomationRunStatus, AutomationStatus, |
| 5 | SharedAutomationManager, run_now_shared, |
| 6 | }; |
| 7 | use crate::localization::{Locale, MessageId, tr}; |
| 8 | use crate::task_manager::SharedTaskManager; |
| 9 | use crate::tui::app::{App, AutomationAction}; |
| 10 | use crate::tui::history::HistoryCell; |
| 11 | |
| 12 | pub(super) async fn handle_action( |
| 13 | app: &mut App, |
| 14 | action: AutomationAction, |
| 15 | task_manager: &SharedTaskManager, |
| 16 | ) { |
| 17 | let locale = app.ui_locale; |
| 18 | let Some(automations) = app.runtime_services.automations.clone() else { |
| 19 | add_message( |
| 20 | app, |
| 21 | tr(locale, MessageId::AutomationManagerUnavailable).into_owned(), |
| 22 | ); |
| 23 | return; |
| 24 | }; |
| 25 | |
| 26 | let content = match action { |
| 27 | AutomationAction::List => list(locale, &automations).await, |
| 28 | AutomationAction::Show(id) => show(locale, &automations, &id).await, |
| 29 | AutomationAction::Pause(id) => mutate(locale, &automations, &id, Mutation::Pause).await, |
| 30 | AutomationAction::Resume(id) => mutate(locale, &automations, &id, Mutation::Resume).await, |
| 31 | AutomationAction::Delete { id, confirmation } => { |
| 32 | delete(locale, &automations, &id, confirmation.as_deref()).await |
| 33 | } |
| 34 | AutomationAction::Run(id) => match run_now_shared(&automations, &id, task_manager).await { |
| 35 | Ok(run) => format_run_enqueued(locale, &id, &run), |
| 36 | Err(error) => action_failed(locale, MessageId::AutomationActionRun, &id, &error), |
| 37 | }, |
| 38 | }; |
| 39 | add_message(app, content); |
| 40 | } |
| 41 | |
| 42 | async fn list(locale: Locale, automations: &SharedAutomationManager) -> String { |
| 43 | match automations.lock().await.list_automations() { |
| 44 | Ok(records) => format_list(locale, &records), |
| 45 | Err(error) => { |
| 46 | tr(locale, MessageId::AutomationListFailed).replace("{error}", &error.to_string()) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | async fn show(locale: Locale, automations: &SharedAutomationManager, id: &str) -> String { |
| 52 | let manager = automations.lock().await; |
| 53 | match manager.get_automation(id) { |
| 54 | Ok(record) => { |
| 55 | let runs = manager.list_runs(id, Some(5)).ok(); |
| 56 | format_detail(locale, &record, runs.as_deref()) |
| 57 | } |
| 58 | Err(error) => action_failed(locale, MessageId::AutomationActionInspect, id, &error), |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | #[derive(Clone, Copy)] |
| 63 | enum Mutation { |
| 64 | Pause, |
| 65 | Resume, |
| 66 | } |
| 67 | |
| 68 | impl Mutation { |
| 69 | const fn action_id(self) -> MessageId { |
| 70 | match self { |
| 71 | Self::Pause => MessageId::AutomationActionPause, |
| 72 | Self::Resume => MessageId::AutomationActionResume, |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | const fn receipt_id(self) -> MessageId { |
| 77 | match self { |
| 78 | Self::Pause => MessageId::AutomationActionPaused, |
| 79 | Self::Resume => MessageId::AutomationActionResumed, |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | async fn mutate( |
| 85 | locale: Locale, |
| 86 | automations: &SharedAutomationManager, |
| 87 | id: &str, |
| 88 | mutation: Mutation, |
| 89 | ) -> String { |
| 90 | let manager = automations.lock().await; |
| 91 | let result = match mutation { |
| 92 | Mutation::Pause => manager.pause_automation(id), |
| 93 | Mutation::Resume => manager.resume_automation(id), |
| 94 | }; |
| 95 | |
| 96 | match result { |
| 97 | Ok(record) => tr(locale, MessageId::AutomationMutationReceipt) |
| 98 | .replace("{name}", &display_text(&record.name)) |
| 99 | .replace("{action}", &tr(locale, mutation.receipt_id())) |
| 100 | .replace( |
| 101 | "{status_label}", |
| 102 | &tr(locale, MessageId::AutomationStatusLabel), |
| 103 | ) |
| 104 | .replace("{status}", &status_label(locale, record.status)), |
| 105 | Err(error) => action_failed(locale, mutation.action_id(), id, &error), |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | async fn delete( |
| 110 | locale: Locale, |
| 111 | automations: &SharedAutomationManager, |
| 112 | id: &str, |
| 113 | confirmation: Option<&str>, |
| 114 | ) -> String { |
| 115 | let manager = automations.lock().await; |
| 116 | let record = match manager.get_automation(id) { |
| 117 | Ok(record) => record, |
| 118 | Err(error) => { |
| 119 | return action_failed(locale, MessageId::AutomationActionDelete, id, &error); |
| 120 | } |
| 121 | }; |
| 122 | let runs = match manager.list_runs(id, None) { |
| 123 | Ok(runs) => runs, |
| 124 | Err(error) => { |
| 125 | return action_failed(locale, MessageId::AutomationActionDelete, id, &error); |
| 126 | } |
| 127 | }; |
| 128 | let token = match deletion_token(&record, &runs) { |
| 129 | Ok(token) => token, |
| 130 | Err(error) => { |
| 131 | return action_failed(locale, MessageId::AutomationActionDelete, id, &error); |
| 132 | } |
| 133 | }; |
| 134 | |
| 135 | let Some(confirmation) = confirmation else { |
| 136 | let command = format!("/automation delete {id} --confirm {token}"); |
| 137 | let recent_len = runs.len().min(5); |
| 138 | let detail = format_detail(locale, &record, Some(&runs[..recent_len])); |
| 139 | let preview = tr(locale, MessageId::AutomationDeletePreview) |
| 140 | .replace("{id}", id) |
| 141 | .replace("{name}", &display_text(&record.name)) |
| 142 | .replace("{run_count}", &runs.len().to_string()) |
| 143 | .replace("{command}", &command); |
| 144 | return format!("{detail}\n\n{preview}"); |
| 145 | }; |
| 146 | |
| 147 | if confirmation != token { |
| 148 | let command = format!("/automation delete {id}"); |
| 149 | return tr(locale, MessageId::AutomationDeleteConfirmationStale) |
| 150 | .replace("{id}", id) |
| 151 | .replace("{command}", &command); |
| 152 | } |
| 153 | |
| 154 | match manager.delete_automation(id) { |
| 155 | Ok(record) => tr(locale, MessageId::AutomationDeleted) |
| 156 | .replace("{id}", id) |
| 157 | .replace("{name}", &display_text(&record.name)) |
| 158 | .replace("{run_count}", &runs.len().to_string()), |
| 159 | Err(error) => action_failed(locale, MessageId::AutomationActionDelete, id, &error), |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | fn deletion_token( |
| 164 | record: &AutomationRecord, |
| 165 | runs: &[AutomationRunRecord], |
| 166 | ) -> Result<String, serde_json::Error> { |
| 167 | let mut canonical_runs = runs.iter().collect::<Vec<_>>(); |
| 168 | canonical_runs.sort_by(|left, right| left.id.cmp(&right.id)); |
| 169 | serde_json::to_vec(&(record, canonical_runs)).map(crate::hashing::sha256_hex) |
| 170 | } |
| 171 | |
| 172 | fn action_failed( |
| 173 | locale: Locale, |
| 174 | action_id: MessageId, |
| 175 | id: &str, |
| 176 | error: &impl std::fmt::Display, |
| 177 | ) -> String { |
| 178 | tr(locale, MessageId::AutomationActionFailed) |
| 179 | .replace("{action}", &tr(locale, action_id)) |
| 180 | .replace("{id}", id) |
| 181 | .replace("{error}", &error.to_string()) |
| 182 | } |
| 183 | |
| 184 | fn format_list(locale: Locale, records: &[AutomationRecord]) -> String { |
| 185 | if records.is_empty() { |
| 186 | return tr(locale, MessageId::AutomationEmpty).into_owned(); |
| 187 | } |
| 188 | |
| 189 | let lines = records |
| 190 | .iter() |
| 191 | .map(|record| { |
| 192 | format!( |
| 193 | "{} [{}] {} ({}; {}: {})", |
| 194 | record.id, |
| 195 | status_label(locale, record.status), |
| 196 | display_text(&record.name), |
| 197 | delivery_mode_label(record), |
| 198 | tr(locale, MessageId::AutomationNextLabel), |
| 199 | timestamp(record.next_run_at) |
| 200 | ) |
| 201 | }) |
| 202 | .collect::<Vec<_>>() |
| 203 | .join("\n"); |
| 204 | format!("{}:\n{lines}", tr(locale, MessageId::AutomationListHeading)) |
| 205 | } |
| 206 | |
| 207 | fn format_detail( |
| 208 | locale: Locale, |
| 209 | record: &AutomationRecord, |
| 210 | runs: Option<&[AutomationRunRecord]>, |
| 211 | ) -> String { |
| 212 | let runs = match runs { |
| 213 | Some([]) => format!(" {}", tr(locale, MessageId::AutomationNoRuns)), |
| 214 | Some(runs) => runs |
| 215 | .iter() |
| 216 | .map(|run| { |
| 217 | format!( |
| 218 | " {} {} ({} {})", |
| 219 | run_status_label(locale, run.status), |
| 220 | run.scheduled_for.to_rfc3339(), |
| 221 | tr(locale, MessageId::AutomationTaskLabel), |
| 222 | run.task_id.as_deref().unwrap_or("-") |
| 223 | ) |
| 224 | }) |
| 225 | .collect::<Vec<_>>() |
| 226 | .join("\n"), |
| 227 | None => format!(" {}", tr(locale, MessageId::AutomationRunsUnavailable)), |
| 228 | }; |
| 229 | let mut lines = vec![ |
| 230 | format!( |
| 231 | "{} {} [{}]", |
| 232 | tr(locale, MessageId::AutomationNoun), |
| 233 | record.id, |
| 234 | status_label(locale, record.status) |
| 235 | ), |
| 236 | field( |
| 237 | locale, |
| 238 | MessageId::AutomationNameLabel, |
| 239 | &display_text(&record.name), |
| 240 | ), |
| 241 | format!(" {}:", tr(locale, MessageId::AutomationPromptLabel)), |
| 242 | ]; |
| 243 | lines.extend( |
| 244 | display_text(&record.prompt) |
| 245 | .lines() |
| 246 | .map(|line| format!(" {line}")), |
| 247 | ); |
| 248 | lines.extend(record.cwds.iter().map(|cwd| { |
| 249 | field( |
| 250 | locale, |
| 251 | MessageId::AutomationCwdLabel, |
| 252 | &display_text(&crate::utils::display_path(cwd)), |
| 253 | ) |
| 254 | })); |
| 255 | if let Some(mode) = record.mode.as_deref() { |
| 256 | lines.push(field( |
| 257 | locale, |
| 258 | MessageId::AutomationModeLabel, |
| 259 | &display_text(mode), |
| 260 | )); |
| 261 | } |
| 262 | if let Some(allow_shell) = record.allow_shell { |
| 263 | lines.push(field( |
| 264 | locale, |
| 265 | MessageId::AutomationAllowShellLabel, |
| 266 | &allow_shell.to_string(), |
| 267 | )); |
| 268 | } |
| 269 | if let Some(trust_mode) = record.trust_mode { |
| 270 | lines.push(field( |
| 271 | locale, |
| 272 | MessageId::AutomationTrustModeLabel, |
| 273 | &trust_mode.to_string(), |
| 274 | )); |
| 275 | } |
| 276 | if let Some(auto_approve) = record.auto_approve { |
| 277 | lines.push(field( |
| 278 | locale, |
| 279 | MessageId::AutomationAutoApproveLabel, |
| 280 | &auto_approve.to_string(), |
| 281 | )); |
| 282 | } |
| 283 | lines.extend([ |
| 284 | field(locale, MessageId::AutomationRruleLabel, &record.rrule), |
| 285 | field( |
| 286 | locale, |
| 287 | MessageId::AutomationDeliveryLabel, |
| 288 | &delivery_mode_label(record), |
| 289 | ), |
| 290 | field( |
| 291 | locale, |
| 292 | MessageId::AutomationNextLabel, |
| 293 | ×tamp(record.next_run_at), |
| 294 | ), |
| 295 | field( |
| 296 | locale, |
| 297 | MessageId::AutomationLastLabel, |
| 298 | ×tamp(record.last_run_at), |
| 299 | ), |
| 300 | format!( |
| 301 | "{}:\n{runs}", |
| 302 | tr(locale, MessageId::AutomationRecentRunsLabel) |
| 303 | ), |
| 304 | ]); |
| 305 | lines.join("\n") |
| 306 | } |
| 307 | |
| 308 | fn field(locale: Locale, label: MessageId, value: &str) -> String { |
| 309 | format!(" {}: {value}", tr(locale, label)) |
| 310 | } |
| 311 | |
| 312 | fn display_text(value: &str) -> String { |
| 313 | let mut visible = String::with_capacity(value.len()); |
| 314 | crate::tui::osc8::strip_ansi_into(value, &mut visible); |
| 315 | codewhale_config::persistence::redact_secrets(&visible) |
| 316 | } |
| 317 | |
| 318 | fn format_run_enqueued(locale: Locale, id: &str, run: &AutomationRunRecord) -> String { |
| 319 | tr(locale, MessageId::AutomationRunEnqueued) |
| 320 | .replace("{id}", id) |
| 321 | .replace("{status}", &run_status_label(locale, run.status)) |
| 322 | .replace("{task}", run.task_id.as_deref().unwrap_or("-")) |
| 323 | } |
| 324 | |
| 325 | fn status_label(locale: Locale, status: AutomationStatus) -> String { |
| 326 | let id = match status { |
| 327 | AutomationStatus::Active => MessageId::AutomationStatusActive, |
| 328 | AutomationStatus::Paused => MessageId::AutomationStatusPaused, |
| 329 | }; |
| 330 | tr(locale, id).into_owned() |
| 331 | } |
| 332 | |
| 333 | fn run_status_label(locale: Locale, status: AutomationRunStatus) -> String { |
| 334 | let id = match status { |
| 335 | AutomationRunStatus::Queued => MessageId::AutomationRunStatusQueued, |
| 336 | AutomationRunStatus::Running => MessageId::AutomationRunStatusRunning, |
| 337 | AutomationRunStatus::Completed => MessageId::AutomationRunStatusCompleted, |
| 338 | AutomationRunStatus::Failed => MessageId::AutomationRunStatusFailed, |
| 339 | AutomationRunStatus::Canceled => MessageId::AutomationRunStatusCanceled, |
| 340 | }; |
| 341 | tr(locale, id).into_owned() |
| 342 | } |
| 343 | |
| 344 | fn timestamp(value: Option<chrono::DateTime<chrono::Utc>>) -> String { |
| 345 | value |
| 346 | .map(|timestamp| timestamp.to_rfc3339()) |
| 347 | .unwrap_or_else(|| "-".to_string()) |
| 348 | } |
| 349 | |
| 350 | /// Delivery mode is a stored enum value, not prose — render it raw like |
| 351 | /// `mode`/`rrule` rather than translating it. Unset means the default `task`. |
| 352 | fn delivery_mode_label(record: &AutomationRecord) -> String { |
| 353 | format!("{:?}", record.delivery_mode.unwrap_or_default()).to_ascii_lowercase() |
| 354 | } |
| 355 | |
| 356 | fn add_message(app: &mut App, content: String) { |
| 357 | app.add_message(HistoryCell::System { content }); |
| 358 | } |
| 359 | |
| 360 | #[cfg(test)] |
| 361 | mod tests { |
| 362 | use std::fs; |
| 363 | use std::sync::Arc; |
| 364 | |
| 365 | use super::*; |
| 366 | use crate::automation_manager::{ |
| 367 | AutomationDeliveryMode, AutomationManager, CreateAutomationRequest, |
| 368 | }; |
| 369 | use chrono::Utc; |
| 370 | use tempfile::TempDir; |
| 371 | use tokio::sync::Mutex; |
| 372 | |
| 373 | fn record(status: AutomationStatus) -> AutomationRecord { |
| 374 | let now = Utc::now(); |
| 375 | AutomationRecord { |
| 376 | schema_version: 1, |
| 377 | id: "auto_1".to_string(), |
| 378 | name: "Nightly checks".to_string(), |
| 379 | prompt: "Run checks".to_string(), |
| 380 | rrule: "FREQ=DAILY".to_string(), |
| 381 | cwds: Vec::new(), |
| 382 | mode: None, |
| 383 | allow_shell: None, |
| 384 | trust_mode: None, |
| 385 | auto_approve: None, |
| 386 | delivery_mode: None, |
| 387 | status, |
| 388 | created_at: now, |
| 389 | updated_at: now, |
| 390 | next_run_at: None, |
| 391 | last_run_at: None, |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | #[test] |
| 396 | fn list_explains_empty_state_and_operator_controls() { |
| 397 | assert!(format_list(Locale::En, &[]).contains("`automation` tool to create one")); |
| 398 | let text = format_list(Locale::En, &[record(AutomationStatus::Paused)]); |
| 399 | assert!(text.contains("auto_1 [paused] Nightly checks")); |
| 400 | assert!(text.contains("next: -")); |
| 401 | } |
| 402 | |
| 403 | #[test] |
| 404 | fn detail_keeps_schedule_and_recent_run_shape() { |
| 405 | let text = format_detail(Locale::En, &record(AutomationStatus::Active), Some(&[])); |
| 406 | assert!(text.contains("Automation auto_1 [active]")); |
| 407 | assert!(text.contains("rrule: FREQ=DAILY")); |
| 408 | assert!(text.contains("recent runs:")); |
| 409 | } |
| 410 | |
| 411 | #[test] |
| 412 | fn list_and_detail_surface_delivery_mode() { |
| 413 | let mut automation = record(AutomationStatus::Active); |
| 414 | automation.delivery_mode = Some(AutomationDeliveryMode::Watcher); |
| 415 | |
| 416 | let list = format_list(Locale::En, std::slice::from_ref(&automation)); |
| 417 | assert!(list.contains("(watcher; next:")); |
| 418 | |
| 419 | let detail = format_detail(Locale::En, &automation, Some(&[])); |
| 420 | assert!(detail.contains(" delivery: watcher")); |
| 421 | |
| 422 | let default_detail = |
| 423 | format_detail(Locale::En, &record(AutomationStatus::Active), Some(&[])); |
| 424 | assert!(default_detail.contains(" delivery: task")); |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn detail_exposes_configured_execution_contract_and_redacts_prompt() { |
| 429 | let mut automation = record(AutomationStatus::Active); |
| 430 | automation.prompt = "Run release checks\napi_key = \"sk-audit-secret-value\"".to_string(); |
| 431 | automation.cwds = vec!["release-workspace".into()]; |
| 432 | automation.mode = Some("agent".to_string()); |
| 433 | automation.allow_shell = Some(true); |
| 434 | automation.trust_mode = Some(false); |
| 435 | automation.auto_approve = Some(true); |
| 436 | |
| 437 | let text = format_detail(Locale::En, &automation, Some(&[])); |
| 438 | |
| 439 | assert!(text.contains(" prompt:\n Run release checks")); |
| 440 | assert!(text.contains("[redacted]")); |
| 441 | assert!(!text.contains("sk-audit-secret-value")); |
| 442 | assert!(text.contains(" cwd: release-workspace")); |
| 443 | assert!(text.contains(" mode: agent")); |
| 444 | assert!(text.contains(" allow_shell: true")); |
| 445 | assert!(text.contains(" trust_mode: false")); |
| 446 | assert!(text.contains(" auto_approve: true")); |
| 447 | } |
| 448 | |
| 449 | #[test] |
| 450 | fn list_stays_compact_and_detail_omits_unset_execution_overrides() { |
| 451 | let automation = record(AutomationStatus::Paused); |
| 452 | |
| 453 | let list = format_list(Locale::En, std::slice::from_ref(&automation)); |
| 454 | assert!(!list.contains(&automation.prompt)); |
| 455 | assert!(!list.contains("prompt:")); |
| 456 | assert!(!list.contains("cwd:")); |
| 457 | assert!(!list.contains("mode:")); |
| 458 | assert!(!list.contains("allow_shell:")); |
| 459 | assert!(!list.contains("trust_mode:")); |
| 460 | assert!(!list.contains("auto_approve:")); |
| 461 | |
| 462 | let detail = format_detail(Locale::En, &automation, Some(&[])); |
| 463 | assert!(!detail.contains("cwd:")); |
| 464 | assert!(!detail.contains("mode:")); |
| 465 | assert!(!detail.contains("allow_shell:")); |
| 466 | assert!(!detail.contains("trust_mode:")); |
| 467 | assert!(!detail.contains("auto_approve:")); |
| 468 | } |
| 469 | |
| 470 | #[test] |
| 471 | fn automation_output_routes_through_the_selected_locale() { |
| 472 | let french = format_list(Locale::Fr, &[record(AutomationStatus::Paused)]); |
| 473 | assert!(french.starts_with(tr(Locale::Fr, MessageId::AutomationListHeading).as_ref())); |
| 474 | assert!(french.contains(tr(Locale::Fr, MessageId::AutomationStatusPaused).as_ref())); |
| 475 | assert!(!french.starts_with(tr(Locale::En, MessageId::AutomationListHeading).as_ref())); |
| 476 | |
| 477 | for locale in Locale::shipped_complete() { |
| 478 | for id in [ |
| 479 | MessageId::AutomationManagerUnavailable, |
| 480 | MessageId::AutomationDeletePreview, |
| 481 | MessageId::AutomationDeleted, |
| 482 | MessageId::AutomationRunEnqueued, |
| 483 | ] { |
| 484 | assert_ne!(tr(*locale, id).as_ref(), format!("{id:?}"), "{locale:?}"); |
| 485 | } |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | #[tokio::test] |
| 490 | async fn delete_is_a_noop_until_snapshot_confirmation_then_removes_definition_and_runs() { |
| 491 | let temp = TempDir::new().expect("temp dir"); |
| 492 | let manager = AutomationManager::open(temp.path().to_path_buf()).expect("manager"); |
| 493 | let automation = manager |
| 494 | .create_automation(CreateAutomationRequest { |
| 495 | name: "Nightly checks".to_string(), |
| 496 | prompt: "Run checks".to_string(), |
| 497 | rrule: "FREQ=HOURLY;INTERVAL=1".to_string(), |
| 498 | cwds: Vec::new(), |
| 499 | mode: None, |
| 500 | allow_shell: None, |
| 501 | trust_mode: None, |
| 502 | auto_approve: None, |
| 503 | delivery_mode: None, |
| 504 | status: Some(AutomationStatus::Paused), |
| 505 | }) |
| 506 | .expect("automation"); |
| 507 | let now = Utc::now(); |
| 508 | let run = AutomationRunRecord { |
| 509 | schema_version: 1, |
| 510 | id: "run_1".to_string(), |
| 511 | automation_id: automation.id.clone(), |
| 512 | scheduled_for: now, |
| 513 | status: AutomationRunStatus::Completed, |
| 514 | created_at: now, |
| 515 | started_at: Some(now), |
| 516 | ended_at: Some(now), |
| 517 | task_id: Some("task_1".to_string()), |
| 518 | thread_id: None, |
| 519 | turn_id: None, |
| 520 | error: None, |
| 521 | }; |
| 522 | let runs_dir = temp.path().join("runs").join(&automation.id); |
| 523 | fs::create_dir_all(&runs_dir).expect("runs dir"); |
| 524 | fs::write( |
| 525 | runs_dir.join("run_1.json"), |
| 526 | serde_json::to_vec_pretty(&run).expect("serialize run"), |
| 527 | ) |
| 528 | .expect("write run"); |
| 529 | let manager = Arc::new(Mutex::new(manager)); |
| 530 | |
| 531 | let preview = delete(Locale::En, &manager, &automation.id, None).await; |
| 532 | assert!(preview.contains("Nothing was deleted"), "{preview}"); |
| 533 | assert!(preview.contains("Recorded runs: 1"), "{preview}"); |
| 534 | assert!( |
| 535 | manager.lock().await.get_automation(&automation.id).is_ok(), |
| 536 | "preview must preserve the definition" |
| 537 | ); |
| 538 | assert_eq!( |
| 539 | manager |
| 540 | .lock() |
| 541 | .await |
| 542 | .list_runs(&automation.id, None) |
| 543 | .expect("runs after preview") |
| 544 | .len(), |
| 545 | 1, |
| 546 | "preview must preserve run history" |
| 547 | ); |
| 548 | |
| 549 | let stale = delete(Locale::En, &manager, &automation.id, Some("wrong-receipt")).await; |
| 550 | assert!(stale.contains("no longer matches"), "{stale}"); |
| 551 | assert!( |
| 552 | manager.lock().await.get_automation(&automation.id).is_ok(), |
| 553 | "a mismatched receipt must not delete" |
| 554 | ); |
| 555 | |
| 556 | let token = preview |
| 557 | .lines() |
| 558 | .find(|line| line.starts_with("/automation delete ")) |
| 559 | .and_then(|line| line.split_whitespace().last()) |
| 560 | .expect("preview confirmation receipt"); |
| 561 | let deleted = delete(Locale::En, &manager, &automation.id, Some(token)).await; |
| 562 | assert!(deleted.contains("Recorded runs deleted: 1"), "{deleted}"); |
| 563 | assert!( |
| 564 | manager.lock().await.get_automation(&automation.id).is_err(), |
| 565 | "confirmed deletion removes definition" |
| 566 | ); |
| 567 | assert!(!runs_dir.exists(), "confirmed deletion removes run history"); |
| 568 | } |
| 569 | } |
| 570 |