返回 CodeWhale
automation_routing.rs
根目录 / crates / tui / src / tui / automation_routing.rs
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 &timestamp(record.next_run_at),
294 ),
295 field(
296 locale,
297 MessageId::AutomationLastLabel,
298 &timestamp(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
570 lines RUST