Skip to main content

majordomus_cli/execution/
event.rs

1//! The typed execution event: one envelope, a closed set of payloads, and a version.
2//!
3//! An event is a domain fact, not a message of a transport. The WebSocket endpoint, the
4//! history route, the audit line and anything added later render the same value; nothing
5//! downstream may add a field, and nothing upstream may send a string a consumer has to
6//! parse. The schema every client validates against is derived from these types by
7//! `schemars`, exactly as a capability's input and output schemas are.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use super::model::{
14    ExecutionDiagnostic, ExecutionError, ExecutionId, ExecutionState, LogStream, ProgressView,
15};
16
17/// The version of the event protocol this executable speaks.
18///
19/// It is carried on every event and negotiated when a client connects. A change that a
20/// client written against version 1 could not ignore safely — a renamed field, a removed
21/// variant, a changed meaning — increments it. A new variant does not: a client is
22/// required to ignore a payload type it does not know, which is what
23/// [`EventPayload::is_terminal`] and the Cockpit's own reader are written to do.
24pub const PROTOCOL_VERSION: &str = "1";
25
26/// One event about one execution.
27///
28/// `sequence` is dense and starts at 1 within an execution: a client that has seen
29/// sequence `n` knows it has missed something when the next event it reads is not `n + 1`,
30/// and asks for the gap by cursor rather than reloading the world.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32pub struct ExecutionEvent {
33    /// The protocol version, [`PROTOCOL_VERSION`].
34    pub schema_version: String,
35    /// The identity of this event: the execution's id and the sequence, so a client that
36    /// deduplicates needs no composite key of its own.
37    pub event_id: String,
38    /// The execution it is about.
39    pub execution_id: ExecutionId,
40    /// Its position in that execution's stream, from 1, without gaps.
41    pub sequence: u64,
42    /// When the event was produced, RFC 3339 in UTC.
43    pub timestamp: String,
44    /// What happened.
45    #[serde(flatten)]
46    pub payload: EventPayload,
47}
48
49impl ExecutionEvent {
50    /// The event for an execution at a sequence, stamped now.
51    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    /// The discriminator on the wire, for a log line or a filter.
63    pub fn type_name(&self) -> &'static str {
64        self.payload.type_name()
65    }
66}
67
68/// What happened, as a closed set.
69///
70/// The tag is `type` and the body is `data`, so every event on the wire has the same two
71/// members whatever it says, and a client switches on one string.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73#[serde(tag = "type", content = "data")]
74pub enum EventPayload {
75    /// The execution was accepted and exists. Carries what it will run and with what,
76    /// redacted, so a client that joins at the first event needs nothing else to render a
77    /// heading.
78    #[serde(rename = "execution.created")]
79    Created {
80        /// The capability's canonical id.
81        capability: String,
82        /// Its title.
83        title: String,
84        /// The input, with sensitive values already replaced.
85        input: Value,
86    },
87    /// It is waiting for a worker.
88    #[serde(rename = "execution.queued")]
89    Queued {
90        /// How many executions are ahead of it.
91        ahead: usize,
92    },
93    /// A worker picked it up and is about to enter the handler.
94    #[serde(rename = "execution.started")]
95    Started,
96    /// How far along it is.
97    #[serde(rename = "execution.progress")]
98    Progress(ProgressView),
99    /// A named phase was entered.
100    #[serde(rename = "execution.step.started")]
101    StepStarted {
102        /// The step's stable name.
103        name: String,
104        /// One line for a reader.
105        title: String,
106    },
107    /// A named phase finished.
108    #[serde(rename = "execution.step.completed")]
109    StepCompleted {
110        /// The step's stable name.
111        name: String,
112        /// Whether what it was asked to do happened.
113        ok: bool,
114        /// What it said, when it said anything.
115        #[serde(skip_serializing_if = "Option::is_none")]
116        detail: Option<String>,
117    },
118    /// A line of output.
119    #[serde(rename = "execution.log")]
120    Log {
121        /// Which stream it came from.
122        stream: LogStream,
123        /// The text, with control characters and terminal escapes already removed.
124        message: String,
125    },
126    /// A structured finding, distinct from the outcome.
127    #[serde(rename = "execution.diagnostic")]
128    Diagnostic(ExecutionDiagnostic),
129    /// Cancellation was asked for; the handler has not stopped yet.
130    #[serde(rename = "execution.cancelling")]
131    Cancelling {
132        /// Who asked, in one word.
133        by: String,
134    },
135    /// It stopped because it was asked to.
136    #[serde(rename = "execution.cancelled")]
137    Cancelled,
138    /// It finished, and the handler returned an output.
139    ///
140    /// The output is on this event and not on one of its own, because a client must never
141    /// be able to read "succeeded" and find no result: the state and the value it produced
142    /// are one fact and travel together.
143    #[serde(rename = "execution.completed")]
144    Completed {
145        /// What the handler returned.
146        output: Value,
147    },
148    /// It finished, and it did not.
149    #[serde(rename = "execution.failed")]
150    Failed {
151        /// Why.
152        error: ExecutionError,
153    },
154}
155
156impl EventPayload {
157    /// The discriminator on the wire.
158    ///
159    /// ```
160    /// use majordomus_cli::execution::EventPayload;
161    /// assert_eq!(EventPayload::Started.type_name(), "execution.started");
162    /// assert_eq!(EventPayload::Cancelled.type_name(), "execution.cancelled");
163    /// ```
164    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    /// Is this the last event of its execution? A subscriber that has read one may stop.
182    ///
183    /// ```
184    /// use majordomus_cli::execution::EventPayload;
185    /// assert!(EventPayload::Cancelled.is_terminal());
186    /// assert!(!EventPayload::Started.is_terminal());
187    /// ```
188    pub fn is_terminal(&self) -> bool {
189        matches!(
190            self,
191            EventPayload::Completed { .. } | EventPayload::Failed { .. } | EventPayload::Cancelled
192        )
193    }
194
195    /// The state this event puts the execution into, when it decides one.
196    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
209/// Text as an event may carry it: no control character, no terminal escape sequence, and
210/// bounded.
211///
212/// Every line a handler or a child process produces goes through here. A browser is not
213/// the only reason — a terminal reading `majordomus run --follow` would be repainted by an
214/// escape it did not choose — and doing it once, here, is why no consumer has to.
215///
216/// ```
217/// use majordomus_cli::execution::event::sanitise;
218/// assert_eq!(sanitise("plain", 100), "plain");
219/// assert_eq!(sanitise("\u{1b}[31mred\u{1b}[0m", 100), "red");
220/// assert_eq!(sanitise("a\u{0}b\tc", 100), "a b\tc");
221/// assert_eq!(sanitise("abcdef", 4), "abc…");
222/// ```
223pub 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            // the character after the escape says which sequence it is, and the two have
230            // different terminators: a control sequence ends at its final byte, an
231            // operating-system command at a BEL or a string terminator. Treating them
232            // alike leaves half of one on the wire.
233            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                // any other two-character escape: both are dropped
255                _ => {}
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}