Skip to main content

relux_runtime/observe/structured/
event.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use serde::Deserialize;
5use serde::Serialize;
6use ts_rs::TS;
7
8use super::SourceLocation;
9use super::span::SpanId;
10
11pub type EventSeq = u64;
12
13/// Structured representation of an effective timeout (the `IrTimeout` value
14/// that bounded a wait or was installed by a `timeout` statement). Pre-formatted
15/// with humantime so consumers never do duration arithmetic.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
17#[cfg_attr(
18    feature = "ts-export",
19    ts(export, export_to = "../../../viewer/src/types/")
20)]
21#[serde(tag = "type", rename_all = "kebab-case")]
22pub enum TimeoutValue {
23    Tolerance {
24        duration: String,
25        multiplier: String,
26        total_duration: String,
27        source: Option<SourceLocation>,
28    },
29    Assertion {
30        duration: String,
31        source: Option<SourceLocation>,
32    },
33}
34
35/// Per-pattern descriptor for a `MultiMatch*` event. Carries the pattern's
36/// source text and whether it is a regex or literal. The matched substring
37/// and offsets live on the corresponding `BufferEventKind::Matched` event
38/// referenced by `MultiMatchPatternDone.buffer_seq` - they are not
39/// duplicated here.
40#[derive(Debug, Clone, Serialize, Deserialize, TS)]
41#[cfg_attr(
42    feature = "ts-export",
43    ts(export, export_to = "../../../viewer/src/types/")
44)]
45pub struct MultiMatchPattern {
46    pub pattern: String,
47    pub is_regex: bool,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, TS)]
51#[cfg_attr(
52    feature = "ts-export",
53    ts(export, export_to = "../../../viewer/src/types/")
54)]
55pub struct Event {
56    pub seq: EventSeq,
57    #[serde(with = "super::ts_duration_ms")]
58    #[ts(as = "f64")]
59    pub ts: Duration,
60    pub span: SpanId,
61    pub shell: Option<String>,
62    /// Stable identity for the shell, when present. Present iff
63    /// `shell` is present. Viewers index by marker; `shell` is the
64    /// display name at emit time (qualified post-export, bare pre).
65    pub shell_marker: Option<String>,
66    /// Source byte range that produced this event, when one is in
67    /// scope at the emit site. Resolves against `StructuredLog.sources`.
68    pub source: Option<SourceLocation>,
69    #[serde(flatten)]
70    pub kind: EventKind,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, TS)]
74#[cfg_attr(
75    feature = "ts-export",
76    ts(export, export_to = "../../../viewer/src/types/")
77)]
78#[serde(tag = "kind", rename_all = "kebab-case")]
79pub enum EventKind {
80    // Shell lifecycle
81    ShellSpawn {
82        name: String,
83        command: String,
84    },
85    ShellReady {
86        name: String,
87    },
88    ShellSwitch {
89        name: String,
90    },
91    ShellTerminate {
92        name: String,
93    },
94
95    // Effect exposes - emitted at the end of effect setup, one per
96    // expose decl. Hidden from the viewer timeline; surfaced as inline
97    // props on the owning effect-setup span.
98    EffectExposeShell {
99        /// Caller-visible name (the rename target, or the source name
100        /// when no `as <name>`).
101        name: String,
102        /// Source name in the local scope: a local shell key, or an
103        /// imported dep's exposed-shell key.
104        target: String,
105        /// `Some(alias)` when re-exposing from a dependency
106        /// (`expose shell <alias>.<target> as <name>`).
107        qualifier: Option<String>,
108    },
109    EffectExposeVar {
110        name: String,
111        target: String,
112        qualifier: Option<String>,
113        /// Resolved value at expose time.
114        value: String,
115    },
116
117    // I/O
118    Send {
119        data: String,
120    },
121    Recv {
122        data: String,
123    },
124
125    // Matching - buffer_seq references the corresponding buffer_events entry.
126    MatchStart {
127        pattern: String,
128        is_regex: bool,
129        /// The timeout that bounds this wait.
130        effective: TimeoutValue,
131    },
132    MatchDone {
133        matched: String,
134        #[serde(with = "super::ts_duration_ms")]
135        #[ts(as = "f64")]
136        elapsed: Duration,
137        captures: Option<HashMap<String, String>>,
138        buffer_seq: EventSeq,
139    },
140    Timeout {
141        pattern: String,
142        /// `None` when no buffer event corresponds (the failure record's
143        /// `buffer_tail` is canonical for the timeout state).
144        buffer_seq: Option<EventSeq>,
145        /// The timeout that fired.
146        effective: TimeoutValue,
147    },
148
149    // Fail patterns
150    FailPatternSet {
151        pattern: String,
152        is_regex: bool,
153    },
154    FailPatternCleared,
155    FailPatternTriggered {
156        pattern: String,
157        is_regex: bool,
158        matched_line: String,
159        /// `None` for fail-pattern hits - they observe without advancing the
160        /// cursor, so no `Matched` buffer event corresponds.
161        buffer_seq: Option<EventSeq>,
162    },
163
164    // Control flow
165    SleepStart {
166        #[serde(with = "super::ts_duration_ms")]
167        #[ts(as = "f64")]
168        duration: Duration,
169    },
170    SleepDone,
171    TimeoutSet {
172        timeout: TimeoutValue,
173        previous: TimeoutValue,
174    },
175
176    // Values
177    VarLet {
178        name: String,
179        value: String,
180    },
181    VarAssign {
182        name: String,
183        value: String,
184        previous: String,
185    },
186    StringEval {
187        result: String,
188    },
189    Interpolation {
190        template: String,
191        result: String,
192        bindings: Vec<(String, String)>,
193    },
194    /// Pure string-match attempt - emitted before the match runs.
195    /// `value` is the haystack inline (a shell match reads the buffer).
196    PureMatchStart {
197        value: String,
198        pattern: String,
199        is_regex: bool,
200    },
201    /// Pure string-match success. `matched` is the whole-match substring
202    /// (`$0` / the literal needle); `captures` mirrors shell-buffer captures.
203    PureMatchDone {
204        matched: String,
205        captures: HashMap<String, String>,
206    },
207    /// Pure string-match failure (no match). Empty payload; the preceding
208    /// `PureMatchStart` in the same span carries value/pattern.
209    PureMatchFailed {},
210    /// Pure variable read: a bare-var expression resolved against the
211    /// active scope or environment. `value` is the resolved string
212    /// (`""` when the variable is undefined). Read counterpart to
213    /// `var-let` / `var-assign`.
214    VarRead {
215        name: String,
216        value: String,
217    },
218    /// Final truthy/falsy evaluation of a marker condition. Carries
219    /// the shape-specific payload (Unconditional / Bare / Eq / Regex)
220    /// and the `met` outcome that determined the marker's decision.
221    /// Emitted as the last event inside a `marker-eval` span.
222    BoolCheck {
223        evaluation: super::span::MarkerEvalDetail,
224    },
225
226    // Diagnostics
227    Annotate {
228        text: String,
229    },
230    Log {
231        message: String,
232    },
233    Warning {
234        message: String,
235    },
236    Error {
237        message: String,
238    },
239
240    // External interruption observed by the VM. Tagged with the reason
241    // (test-timeout, suite-timeout, fail-fast, sigint). Emitted on the
242    // span the VM was in when it noticed `cancel.is_cancelled()`.
243    Cancelled {
244        reason: CancelReasonRecord,
245    },
246
247    // --- Multimatch (R014) -------------------------------
248    //
249    // `<{ ?... =... }` and timed variants record this set of events:
250    //   Success: MultiMatchStart + N MultiMatchPatternDone (in completion
251    //            order, not source order) + MultiMatchDone.
252    //   Timeout: MultiMatchStart + 0..N MultiMatchPatternDone +
253    //            MultiMatchTimeout.
254    //   Fail-pattern abort: MultiMatchStart + 0..N MultiMatchPatternDone,
255    //            then the existing FailPatternTriggered path takes over.
256    MultiMatchStart {
257        /// The block-level timeout that bounds this wait.
258        effective: TimeoutValue,
259        /// All patterns in source order. Indices into this vec are
260        /// referenced by `MultiMatchPatternDone.index` and
261        /// `MultiMatchTimeout.unmatched`.
262        patterns: Vec<MultiMatchPattern>,
263    },
264    MultiMatchPatternDone {
265        /// Index into `MultiMatchStart.patterns`.
266        index: usize,
267        /// Time since block entry (not test start).
268        #[serde(with = "super::ts_duration_ms")]
269        #[ts(as = "f64")]
270        elapsed: Duration,
271        /// `EventSeq` of the per-pattern `BufferEventKind::Matched` event
272        /// emitted under the same buf lock as this pattern's success.
273        /// Single source of truth for matched text and absolute offsets.
274        buffer_seq: EventSeq,
275    },
276    MultiMatchDone {
277        /// `EventSeq` of the per-pattern `Matched` whose match ends
278        /// farthest in the buffer. The viewer applies the block-end
279        /// cursor advance by `len(before) + len(matched)` of this event.
280        advance_to: EventSeq,
281    },
282    MultiMatchTimeout {
283        /// Indices into `MultiMatchStart.patterns` that did not match
284        /// before the block timeout fired.
285        unmatched: Vec<usize>,
286    },
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
290#[cfg_attr(
291    feature = "ts-export",
292    ts(export, export_to = "../../../viewer/src/types/")
293)]
294#[serde(tag = "type", rename_all = "kebab-case")]
295pub enum CancelReasonRecord {
296    TestTimeout { duration_ms: u64 },
297    SuiteTimeout { duration_ms: u64 },
298    FailFast { trigger_test: String },
299    Sigint,
300}
301
302impl CancelReasonRecord {
303    pub fn tag(&self) -> &'static str {
304        match self {
305            Self::TestTimeout { .. } => "test-timeout",
306            Self::SuiteTimeout { .. } => "suite-timeout",
307            Self::FailFast { .. } => "fail-fast",
308            Self::Sigint => "sigint",
309        }
310    }
311}
312
313impl From<&crate::cancel::CancelReason> for CancelReasonRecord {
314    fn from(r: &crate::cancel::CancelReason) -> Self {
315        use crate::cancel::CancelReason;
316        match r {
317            CancelReason::TestTimeout { duration } => Self::TestTimeout {
318                duration_ms: duration.as_millis() as u64,
319            },
320            CancelReason::SuiteTimeout { duration } => Self::SuiteTimeout {
321                duration_ms: duration.as_millis() as u64,
322            },
323            CancelReason::FailFast { trigger_test } => Self::FailFast {
324                trigger_test: trigger_test.clone(),
325            },
326            CancelReason::Sigint => Self::Sigint,
327        }
328    }
329}
330
331#[cfg(test)]
332mod pure_match_tests {
333    use super::*;
334    use std::collections::HashMap;
335
336    #[test]
337    fn pure_match_trio_serialises() {
338        let mut caps = HashMap::new();
339        caps.insert("0".to_string(), "abc".to_string());
340        let start = EventKind::PureMatchStart {
341            value: "abc".into(),
342            pattern: "^a.c$".into(),
343            is_regex: true,
344        };
345        let done = EventKind::PureMatchDone {
346            matched: "abc".into(),
347            captures: caps,
348        };
349        let failed = EventKind::PureMatchFailed {};
350        assert_eq!(
351            serde_json::to_value(&start).unwrap()["kind"],
352            serde_json::json!("pure-match-start")
353        );
354        assert_eq!(
355            serde_json::to_value(&start).unwrap()["is_regex"],
356            serde_json::json!(true)
357        );
358        assert_eq!(
359            serde_json::to_value(&done).unwrap()["kind"],
360            serde_json::json!("pure-match-done")
361        );
362        assert_eq!(
363            serde_json::to_value(&failed).unwrap()["kind"],
364            serde_json::json!("pure-match-failed")
365        );
366    }
367
368    #[test]
369    fn multimatch_start_event_kind_serialises() {
370        let k = EventKind::MultiMatchStart {
371            effective: TimeoutValue::Assertion {
372                duration: "5s".into(),
373                source: None,
374            },
375            patterns: vec![
376                MultiMatchPattern {
377                    pattern: "^ok$".into(),
378                    is_regex: true,
379                },
380                MultiMatchPattern {
381                    pattern: "batch complete".into(),
382                    is_regex: false,
383                },
384            ],
385        };
386        let v = serde_json::to_value(&k).unwrap();
387        assert_eq!(v["kind"], serde_json::json!("multi-match-start"));
388        assert_eq!(v["patterns"][0]["is_regex"], serde_json::json!(true));
389        assert_eq!(
390            v["patterns"][1]["pattern"],
391            serde_json::json!("batch complete")
392        );
393    }
394
395    #[test]
396    fn multimatch_pattern_done_event_kind_serialises() {
397        let k = EventKind::MultiMatchPatternDone {
398            index: 1,
399            elapsed: Duration::from_millis(42),
400            buffer_seq: 17,
401        };
402        let v = serde_json::to_value(&k).unwrap();
403        assert_eq!(v["kind"], serde_json::json!("multi-match-pattern-done"));
404        assert_eq!(v["index"], serde_json::json!(1));
405        assert_eq!(v["buffer_seq"], serde_json::json!(17));
406    }
407
408    #[test]
409    fn multimatch_done_event_kind_serialises() {
410        let k = EventKind::MultiMatchDone { advance_to: 23 };
411        let v = serde_json::to_value(&k).unwrap();
412        assert_eq!(v["kind"], serde_json::json!("multi-match-done"));
413        assert_eq!(v["advance_to"], serde_json::json!(23));
414    }
415
416    #[test]
417    fn multimatch_timeout_event_kind_serialises() {
418        let k = EventKind::MultiMatchTimeout {
419            unmatched: vec![0, 2],
420        };
421        let v = serde_json::to_value(&k).unwrap();
422        assert_eq!(v["kind"], serde_json::json!("multi-match-timeout"));
423        assert_eq!(v["unmatched"], serde_json::json!([0, 2]));
424    }
425}