返回 CodeWhale
automation.rs
根目录 / crates / tui / src / tui / history / automation.rs
1 //! Typed transcript receipt for durable scheduled automations
2 //! (AUTOMATION-VISIBILITY-SPEC §2.2).
3 //!
4 //! One-line bulleted card, mirroring kimi's `CronMessageComponent`:
5 //! `● {name} {verb}` plus an optional dim detail segment. Replaces the
6 //! bare-String `HistoryCell::System` receipts `automation_routing.rs` used to
7 //! emit, so the palette can color the event and the pager can navigate it.
8 //! Later slices add producers (engine fire/complete/coalesce/miss/expire
9 //! events); the kind vocabulary is the spec's full set.
10
11 use ratatui::style::{Modifier, Style};
12 use ratatui::text::{Line, Span};
13
14 use crate::tui::glyphs;
15 use codewhale_localization::{Locale, MessageId, tr};
16 use codewhale_palette::ChromeInk;
17
18 /// What happened to the automation or its run. Drives the card's ink; the
19 /// visible verb phrase comes from the producer (localized at construction).
20 // Slice 1 produces Started / Completed / Failed / Mutated (the `/automation
21 // run` receipt and the projection's settled-run receipts); Fired / Coalesced
22 // / Missed / Expired arrive with their engine-side producers in Slice 4.
23 #[cfg_attr(not(test), expect(dead_code))]
24 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25 pub enum AutomationCellKind {
26 /// The schedule fired and a run was enqueued.
27 Fired,
28 /// A run started in the background.
29 Started,
30 /// A run completed in the background.
31 Completed,
32 /// A run genuinely crashed — the only kind that may wear Failure red.
33 Failed,
34 /// A run was canceled by the operator, a cancel timeout or shutdown
35 /// (#6162). Consequential enough to see, never a failure.
36 Canceled,
37 /// Missed slots collapsed into a single delivery.
38 Coalesced,
39 /// A scheduled run was missed while the app was down.
40 Missed,
41 /// An idle automation expired (paused, never deleted).
42 Expired,
43 /// The definition changed (pause / resume / delete).
44 Mutated,
45 }
46
47 impl AutomationCellKind {
48 /// Status-bar grammar ink (docs/design/STATUS_BAR_COLOR_GRAMMAR.md):
49 /// Fired/Started → Active, Completed → Outcome, Coalesced/Missed/Expired
50 /// → Attention ("consequential, needs your eye"), Mutated → Info. Only a
51 /// genuinely crashed run takes Failure — a failed report job is not a
52 /// product failure.
53 #[must_use]
54 pub const fn chrome_ink(self) -> ChromeInk {
55 match self {
56 Self::Fired | Self::Started => ChromeInk::Active,
57 Self::Completed => ChromeInk::Outcome,
58 Self::Failed => ChromeInk::Failure,
59 Self::Canceled | Self::Coalesced | Self::Missed | Self::Expired => ChromeInk::Attention,
60 Self::Mutated => ChromeInk::Info,
61 }
62 }
63
64 /// Canonical verb phrase for run-lifecycle kinds. `Mutated` has none —
65 /// the producer supplies the concrete word (`paused` / `resumed` /
66 /// `deleted`) via [`AutomationCell::mutated`].
67 #[must_use]
68 pub fn canonical_verb(self, locale: Locale) -> Option<String> {
69 let id = match self {
70 Self::Fired => MessageId::AutomationReceiptFired,
71 Self::Started => MessageId::AutomationReceiptStarted,
72 Self::Completed => MessageId::AutomationReceiptCompleted,
73 Self::Failed => MessageId::AutomationRunStatusFailed,
74 Self::Canceled => MessageId::AutomationRunStatusCanceled,
75 Self::Coalesced => MessageId::AutomationReceiptCoalesced,
76 Self::Missed => MessageId::AutomationReceiptMissed,
77 Self::Expired => MessageId::AutomationReceiptExpired,
78 Self::Mutated => return None,
79 };
80 Some(tr(locale, id).into_owned())
81 }
82 }
83
84 /// One-line automation receipt card. `name`, `verb`, and `detail` arrive
85 /// display-safe (ANSI-stripped, secret-redacted) and localized; the renderer
86 /// only truncates.
87 #[derive(Debug, Clone, PartialEq, Eq)]
88 pub struct AutomationCell {
89 pub kind: AutomationCellKind,
90 pub name: String,
91 pub verb: String,
92 pub detail: Option<String>,
93 }
94
95 impl AutomationCell {
96 /// Receipt for a run-lifecycle event; the kind supplies the verb.
97 /// `Mutated` is not a run-lifecycle kind — use [`Self::mutated`].
98 #[must_use]
99 pub fn event(kind: AutomationCellKind, name: String, locale: Locale) -> Self {
100 debug_assert!(
101 !matches!(kind, AutomationCellKind::Mutated),
102 "Mutated receipts name their mutation; use AutomationCell::mutated"
103 );
104 Self {
105 kind,
106 name,
107 verb: kind.canonical_verb(locale).unwrap_or_default(),
108 detail: None,
109 }
110 }
111
112 /// Receipt for a definition change; `verb` is the concrete mutation word
113 /// (already localized by the producer).
114 #[must_use]
115 pub fn mutated(name: String, verb: String) -> Self {
116 Self {
117 kind: AutomationCellKind::Mutated,
118 name,
119 verb,
120 detail: None,
121 }
122 }
123
124 #[must_use]
125 pub fn with_detail(mut self, detail: Option<String>) -> Self {
126 self.detail = detail.filter(|detail| !detail.trim().is_empty());
127 self
128 }
129
130 /// Plain-text form for the pager/clipboard/inspection surfaces — the same
131 /// line the card paints, minus ink.
132 #[must_use]
133 pub fn plain_summary(&self) -> String {
134 let mut line = format!("{} {}", self.name, self.verb);
135 if let Some(detail) = &self.detail {
136 line.push_str(" ");
137 line.push_str(detail);
138 }
139 line.trim_end().to_string()
140 }
141
142 /// Render the one-line card at `width`. The card never wraps: the detail
143 /// segment sheds first, then the name/verb truncate.
144 pub(crate) fn render(&self, width: u16) -> Vec<Line<'static>> {
145 let color = self.kind.chrome_ink().color(&codewhale_palette::UI_THEME);
146 let bullet_width = 2usize; // `● ` — the charter's current marker + space
147 let budget = usize::from(width).saturating_sub(bullet_width);
148 let mut text = self.name.clone();
149 if !self.verb.is_empty() {
150 text.push(' ');
151 text.push_str(&self.verb);
152 }
153 let text = codewhale_localization::truncate_to_width(&text, budget);
154 let used = unicode_width::UnicodeWidthStr::width(text.as_str());
155 let detail = self.detail.as_deref().and_then(|detail| {
156 let remaining = budget.saturating_sub(used + 2);
157 (remaining > 0).then(|| codewhale_localization::truncate_to_width(detail, remaining))
158 });
159 let mut spans = vec![
160 Span::styled(
161 format!("{} ", glyphs::CURRENT),
162 Style::default().fg(color).add_modifier(Modifier::BOLD),
163 ),
164 Span::styled(text, Style::default().fg(color)),
165 ];
166 if let Some(detail) = detail {
167 spans.push(Span::styled(
168 format!(" {detail}"),
169 Style::default().fg(codewhale_palette::TEXT_DIM),
170 ));
171 }
172 vec![Line::from(spans)]
173 }
174 }
175
176 #[cfg(test)]
177 mod tests {
178 use super::*;
179 use crate::tui::golden_harness::{assert_matches_golden, render_golden_text};
180
181 fn cell_text(cell: &AutomationCell, width: u16) -> String {
182 cell.render(width)
183 .into_iter()
184 .map(|line| {
185 line.spans
186 .iter()
187 .map(|span| span.content.to_string())
188 .collect::<String>()
189 })
190 .collect::<Vec<_>>()
191 .join("\n")
192 }
193
194 #[test]
195 fn receipt_kinds_follow_the_color_grammar() {
196 assert_eq!(AutomationCellKind::Fired.chrome_ink(), ChromeInk::Active);
197 assert_eq!(AutomationCellKind::Started.chrome_ink(), ChromeInk::Active);
198 assert_eq!(
199 AutomationCellKind::Completed.chrome_ink(),
200 ChromeInk::Outcome
201 );
202 assert_eq!(AutomationCellKind::Failed.chrome_ink(), ChromeInk::Failure);
203 for kind in [
204 AutomationCellKind::Canceled,
205 AutomationCellKind::Coalesced,
206 AutomationCellKind::Missed,
207 AutomationCellKind::Expired,
208 ] {
209 assert_eq!(kind.chrome_ink(), ChromeInk::Attention, "{kind:?}");
210 }
211 assert_eq!(AutomationCellKind::Mutated.chrome_ink(), ChromeInk::Info);
212 }
213
214 /// The spec's reservation check (§6 Slice 1 accept): no automation
215 /// receipt ink resolves to the failure color in any selectable preset —
216 /// except `Failed`, which pins Failure deliberately and is reserved for a
217 /// genuinely crashed run, never a report job.
218 #[test]
219 fn no_automation_receipt_ink_spends_failure_red_but_the_crashed_run() {
220 for theme_id in codewhale_palette::SELECTABLE_THEMES {
221 let theme = theme_id.ui_theme();
222 for kind in [
223 AutomationCellKind::Fired,
224 AutomationCellKind::Started,
225 AutomationCellKind::Completed,
226 AutomationCellKind::Canceled,
227 AutomationCellKind::Coalesced,
228 AutomationCellKind::Missed,
229 AutomationCellKind::Expired,
230 AutomationCellKind::Mutated,
231 ] {
232 assert_ne!(
233 kind.chrome_ink().color(&theme),
234 theme.error_fg,
235 "theme '{}' spends Failure red on automation receipt {kind:?}",
236 theme_id.name()
237 );
238 }
239 }
240 }
241
242 #[test]
243 fn cards_are_one_line_and_shed_detail_before_the_name() {
244 let cell = AutomationCell::event(
245 AutomationCellKind::Fired,
246 "Release Manager".to_string(),
247 Locale::En,
248 )
249 .with_detail(Some("run r-8f21 · task t-9de".to_string()));
250 let full = cell_text(&cell, 80);
251 assert_eq!(full, "● Release Manager fired run r-8f21 · task t-9de");
252 let narrow = cell_text(&cell, 24);
253 assert!(
254 !narrow.contains("run r-8f21"),
255 "detail sheds first: {narrow}"
256 );
257 assert!(narrow.starts_with("● "), "{narrow}");
258 assert_eq!(narrow.lines().count(), 1, "the card never wraps");
259 }
260
261 /// Golden contract for the fired/completed cards (spec §6 Slice 1
262 /// accept), following `golden_harness.rs`. Re-bless with
263 /// `CODEWHALE_BLESS_GOLDENS=1`.
264 #[test]
265 fn receipt_cards_match_goldens() {
266 let fired = AutomationCell::event(
267 AutomationCellKind::Fired,
268 "Release Manager".to_string(),
269 Locale::En,
270 )
271 .with_detail(Some("run r-8f21 · task t-9de".to_string()));
272 let completed = AutomationCell::event(
273 AutomationCellKind::Completed,
274 "Documentation".to_string(),
275 Locale::En,
276 )
277 .with_detail(Some("42s · run r-8f19".to_string()));
278 let mutated =
279 AutomationCell::mutated("Market Intelligence".to_string(), "paused".to_string());
280 for (w, h) in [(80u16, 3u16), (40, 3)] {
281 let rendered = render_golden_text(w, h, |buf| {
282 for (y, cell) in [&fired, &completed, &mutated].into_iter().enumerate() {
283 for line in cell.render(w) {
284 buf.set_line(0, y as u16, &line, w);
285 }
286 }
287 });
288 assert_matches_golden(&format!("automation_receipts_{w}x{h}"), &rendered);
289 }
290 }
291 }
292
292 lines RUST