majordomus_cli/execution/
event.rs1use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use super::model::{
14 ExecutionDiagnostic, ExecutionError, ExecutionId, ExecutionState, LogStream, ProgressView,
15};
16
17pub const PROTOCOL_VERSION: &str = "1";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32pub struct ExecutionEvent {
33 pub schema_version: String,
35 pub event_id: String,
38 pub execution_id: ExecutionId,
40 pub sequence: u64,
42 pub timestamp: String,
44 #[serde(flatten)]
46 pub payload: EventPayload,
47}
48
49impl ExecutionEvent {
50 pub fn new(execution_id: ExecutionId, sequence: u64, payload: EventPayload) -> Self {
52 ExecutionEvent {
53 schema_version: PROTOCOL_VERSION.to_string(),
54 event_id: format!("{execution_id}#{sequence}"),
55 execution_id,
56 sequence,
57 timestamp: crate::peers::rfc3339(std::time::SystemTime::now()),
58 payload,
59 }
60 }
61
62 pub fn type_name(&self) -> &'static str {
64 self.payload.type_name()
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73#[serde(tag = "type", content = "data")]
74pub enum EventPayload {
75 #[serde(rename = "execution.created")]
79 Created {
80 capability: String,
82 title: String,
84 input: Value,
86 },
87 #[serde(rename = "execution.queued")]
89 Queued {
90 ahead: usize,
92 },
93 #[serde(rename = "execution.started")]
95 Started,
96 #[serde(rename = "execution.progress")]
98 Progress(ProgressView),
99 #[serde(rename = "execution.step.started")]
101 StepStarted {
102 name: String,
104 title: String,
106 },
107 #[serde(rename = "execution.step.completed")]
109 StepCompleted {
110 name: String,
112 ok: bool,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 detail: Option<String>,
117 },
118 #[serde(rename = "execution.log")]
120 Log {
121 stream: LogStream,
123 message: String,
125 },
126 #[serde(rename = "execution.diagnostic")]
128 Diagnostic(ExecutionDiagnostic),
129 #[serde(rename = "execution.cancelling")]
131 Cancelling {
132 by: String,
134 },
135 #[serde(rename = "execution.cancelled")]
137 Cancelled,
138 #[serde(rename = "execution.completed")]
144 Completed {
145 output: Value,
147 },
148 #[serde(rename = "execution.failed")]
150 Failed {
151 error: ExecutionError,
153 },
154}
155
156impl EventPayload {
157 pub fn type_name(&self) -> &'static str {
165 match self {
166 EventPayload::Created { .. } => "execution.created",
167 EventPayload::Queued { .. } => "execution.queued",
168 EventPayload::Started => "execution.started",
169 EventPayload::Progress(_) => "execution.progress",
170 EventPayload::StepStarted { .. } => "execution.step.started",
171 EventPayload::StepCompleted { .. } => "execution.step.completed",
172 EventPayload::Log { .. } => "execution.log",
173 EventPayload::Diagnostic(_) => "execution.diagnostic",
174 EventPayload::Cancelling { .. } => "execution.cancelling",
175 EventPayload::Cancelled => "execution.cancelled",
176 EventPayload::Completed { .. } => "execution.completed",
177 EventPayload::Failed { .. } => "execution.failed",
178 }
179 }
180
181 pub fn is_terminal(&self) -> bool {
189 matches!(
190 self,
191 EventPayload::Completed { .. } | EventPayload::Failed { .. } | EventPayload::Cancelled
192 )
193 }
194
195 pub fn implies_state(&self) -> Option<ExecutionState> {
197 match self {
198 EventPayload::Queued { .. } => Some(ExecutionState::Queued),
199 EventPayload::Started => Some(ExecutionState::Running),
200 EventPayload::Cancelling { .. } => Some(ExecutionState::Cancelling),
201 EventPayload::Cancelled => Some(ExecutionState::Cancelled),
202 EventPayload::Completed { .. } => Some(ExecutionState::Succeeded),
203 EventPayload::Failed { .. } => Some(ExecutionState::Failed),
204 _ => None,
205 }
206 }
207}
208
209pub fn sanitise(text: &str, max_chars: usize) -> String {
224 let mut out = String::with_capacity(text.len().min(max_chars));
225 let mut chars = text.chars().peekable();
226 let mut count = 0usize;
227 while let Some(c) = chars.next() {
228 if c == '\u{1b}' {
229 match chars.next() {
234 Some('[') => {
235 for next in chars.by_ref() {
236 if ('\u{40}'..='\u{7e}').contains(&next) {
237 break;
238 }
239 }
240 }
241 Some(']') => {
242 while let Some(next) = chars.next() {
243 if next == '\u{7}' {
244 break;
245 }
246 if next == '\u{1b}' {
247 if chars.peek() == Some(&'\\') {
248 chars.next();
249 }
250 break;
251 }
252 }
253 }
254 _ => {}
256 }
257 continue;
258 }
259 let c = if c == '\t' || !c.is_control() { c } else { ' ' };
260 if count + 1 >= max_chars {
261 out.push('…');
262 return out;
263 }
264 out.push(c);
265 count += 1;
266 }
267 out
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn event(payload: EventPayload) -> ExecutionEvent {
275 ExecutionEvent::new(ExecutionId::fresh(), 1, payload)
276 }
277
278 #[test]
279 fn an_event_is_one_envelope_with_a_type_and_a_data_member() {
280 let e = event(EventPayload::Progress(ProgressView {
281 current: 17,
282 total: Some(42),
283 message: Some("Validating".into()),
284 }));
285 let v = serde_json::to_value(&e).unwrap();
286 assert_eq!(v["schema_version"], PROTOCOL_VERSION);
287 assert_eq!(v["type"], "execution.progress");
288 assert_eq!(v["data"]["current"], 17);
289 assert_eq!(v["sequence"], 1);
290 assert_eq!(v["event_id"], format!("{}#1", e.execution_id));
291 let back: ExecutionEvent = serde_json::from_value(v).unwrap();
292 assert_eq!(back, e);
293 }
294
295 #[test]
296 fn every_payload_names_itself_and_round_trips() {
297 let payloads = [
298 EventPayload::Created {
299 capability: "demo.x".into(),
300 title: "Demo".into(),
301 input: serde_json::json!({}),
302 },
303 EventPayload::Queued { ahead: 0 },
304 EventPayload::Started,
305 EventPayload::Progress(ProgressView {
306 current: 1,
307 total: None,
308 message: None,
309 }),
310 EventPayload::StepStarted {
311 name: "a".into(),
312 title: "A".into(),
313 },
314 EventPayload::StepCompleted {
315 name: "a".into(),
316 ok: true,
317 detail: None,
318 },
319 EventPayload::Log {
320 stream: LogStream::Stdout,
321 message: "hi".into(),
322 },
323 EventPayload::Diagnostic(ExecutionDiagnostic {
324 severity: crate::model::Severity::Warning,
325 code: "slow".into(),
326 summary: "s".into(),
327 detail: None,
328 suggestion: None,
329 }),
330 EventPayload::Cancelling {
331 by: "client".into(),
332 },
333 EventPayload::Cancelled,
334 EventPayload::Completed {
335 output: serde_json::json!({ "ok": true }),
336 },
337 EventPayload::Failed {
338 error: ExecutionError {
339 code: "internal".into(),
340 message: "m".into(),
341 suggestion: None,
342 correlation_id: "x".into(),
343 },
344 },
345 ];
346 let mut names = std::collections::BTreeSet::new();
347 for p in payloads {
348 let name = p.type_name();
349 assert!(names.insert(name), "{name} is claimed twice");
350 let v = serde_json::to_value(&p).unwrap();
351 assert_eq!(v["type"], name);
352 let back: EventPayload = serde_json::from_value(v).unwrap();
353 assert_eq!(back.type_name(), name);
354 assert_eq!(back.is_terminal(), p.is_terminal());
355 }
356 assert_eq!(names.len(), 12);
357 }
358
359 #[test]
360 fn a_terminal_payload_is_exactly_a_final_state() {
361 for p in [
362 EventPayload::Completed {
363 output: Value::Null,
364 },
365 EventPayload::Cancelled,
366 EventPayload::Failed {
367 error: ExecutionError {
368 code: "internal".into(),
369 message: String::new(),
370 suggestion: None,
371 correlation_id: String::new(),
372 },
373 },
374 ] {
375 assert!(p.is_terminal());
376 assert!(p.implies_state().is_some_and(ExecutionState::is_final));
377 }
378 assert_eq!(
379 EventPayload::Started.implies_state(),
380 Some(ExecutionState::Running)
381 );
382 assert_eq!(
383 EventPayload::Started.implies_state().map(|s| s.is_final()),
384 Some(false)
385 );
386 }
387
388 #[test]
389 fn text_is_stripped_of_escapes_and_bounded_once_here() {
390 assert_eq!(sanitise("\u{1b}[1;32mok\u{1b}[0m done", 64), "ok done");
391 assert_eq!(sanitise("\u{1b}]0;title\u{7}x", 64), "x");
392 assert_eq!(sanitise("line\r\n", 64), "line ");
393 let long = "x".repeat(10_000);
394 let cut = sanitise(&long, 80);
395 assert!(cut.chars().count() <= 80, "{}", cut.chars().count());
396 assert!(cut.ends_with('…'));
397 }
398}