Skip to main content

devicerail_protocol/
event.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use uuid::Uuid;
6
7use crate::{ActionResult, AssetRef, DeviceId, Observation, RecordedActionCall, RpcId, Viewport};
8
9/// Maximum Unicode code points in a persisted Verdict summary.
10pub const MAX_VERDICT_SUMMARY_LENGTH: usize = 16 * 1024;
11/// Maximum typed Evidence references attached to one Verdict.
12pub const MAX_VERDICT_EVIDENCE_REFERENCES: usize = 64;
13
14const fn is_false(value: &bool) -> bool {
15    !*value
16}
17
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
20#[serde(transparent)]
21pub struct SessionId(pub Uuid);
22
23impl SessionId {
24    pub fn new() -> Self {
25        Self(Uuid::new_v4())
26    }
27}
28
29impl Default for SessionId {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl fmt::Display for SessionId {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        self.0.fmt(formatter)
38    }
39}
40
41impl From<Uuid> for SessionId {
42    fn from(value: Uuid) -> Self {
43        Self(value)
44    }
45}
46
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
49#[serde(transparent)]
50pub struct EventId(pub Uuid);
51
52impl EventId {
53    pub fn new() -> Self {
54        Self(Uuid::new_v4())
55    }
56}
57
58impl Default for EventId {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl fmt::Display for EventId {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        self.0.fmt(formatter)
67    }
68}
69
70impl From<Uuid> for EventId {
71    fn from(value: Uuid) -> Self {
72        Self(value)
73    }
74}
75
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
78#[serde(transparent)]
79pub struct MediaStreamId(pub Uuid);
80
81impl MediaStreamId {
82    pub fn new() -> Self {
83        Self(Uuid::new_v4())
84    }
85}
86
87impl Default for MediaStreamId {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl fmt::Display for MediaStreamId {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        self.0.fmt(formatter)
96    }
97}
98
99impl From<Uuid> for MediaStreamId {
100    fn from(value: Uuid) -> Self {
101        Self(value)
102    }
103}
104
105/// A one-based sequence number within one session.
106///
107/// The wire value is capped at JavaScript's maximum safe integer so generated
108/// clients can sort and resume event streams without losing precision.
109#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
111#[serde(transparent)]
112pub struct EventSequence(
113    #[serde(
114        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
115        deserialize_with = "deserialize_event_sequence"
116    )]
117    #[cfg_attr(
118        feature = "schema",
119        schemars(range(min = 1_u64, max = 9_007_199_254_740_991_u64))
120    )]
121    u64,
122);
123
124fn deserialize_event_sequence<'de, D>(deserializer: D) -> Result<u64, D::Error>
125where
126    D: serde::Deserializer<'de>,
127{
128    let value = crate::wire_integer::deserialize_js_safe_u64(deserializer)?;
129    if value == 0 {
130        Err(serde::de::Error::custom(
131            "event sequence must be a one-based integer",
132        ))
133    } else {
134        Ok(value)
135    }
136}
137
138impl EventSequence {
139    pub const FIRST: Self = Self(1);
140
141    pub fn new(value: u64) -> Option<Self> {
142        (value > 0 && value <= crate::MAX_SAFE_INTEGER).then_some(Self(value))
143    }
144
145    pub const fn get(self) -> u64 {
146        self.0
147    }
148
149    pub fn checked_next(self) -> Option<Self> {
150        self.0.checked_add(1).and_then(Self::new)
151    }
152}
153
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
156#[serde(rename_all = "camelCase")]
157pub enum VerdictStatus {
158    Pass,
159    Fail,
160    Unknown,
161}
162
163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
164#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct Verdict {
167    pub status: VerdictStatus,
168    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 16_384)))]
169    pub summary: String,
170    #[serde(default)]
171    #[cfg_attr(feature = "schema", schemars(length(max = 64)))]
172    pub evidence: Vec<AssetRef>,
173}
174
175impl Verdict {
176    pub fn validate(&self) -> Result<(), VerdictValidationError> {
177        if self.summary.trim().is_empty() {
178            return Err(VerdictValidationError::EmptySummary);
179        }
180        let summary_length = self.summary.chars().count();
181        if summary_length > MAX_VERDICT_SUMMARY_LENGTH {
182            return Err(VerdictValidationError::SummaryTooLong {
183                actual: summary_length,
184                maximum: MAX_VERDICT_SUMMARY_LENGTH,
185            });
186        }
187        if self.evidence.len() > MAX_VERDICT_EVIDENCE_REFERENCES {
188            return Err(VerdictValidationError::TooManyEvidenceReferences {
189                actual: self.evidence.len(),
190                maximum: MAX_VERDICT_EVIDENCE_REFERENCES,
191            });
192        }
193        Ok(())
194    }
195}
196
197#[derive(Clone, Copy, Debug, PartialEq, Eq)]
198pub enum VerdictValidationError {
199    EmptySummary,
200    SummaryTooLong { actual: usize, maximum: usize },
201    TooManyEvidenceReferences { actual: usize, maximum: usize },
202}
203
204impl fmt::Display for VerdictValidationError {
205    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            Self::EmptySummary => formatter.write_str("verdict summary must not be blank"),
208            Self::SummaryTooLong { actual, maximum } => write!(
209                formatter,
210                "verdict summary contains {actual} Unicode code points; maximum is {maximum}"
211            ),
212            Self::TooManyEvidenceReferences { actual, maximum } => write!(
213                formatter,
214                "verdict contains {actual} Evidence references; maximum is {maximum}"
215            ),
216        }
217    }
218}
219
220impl std::error::Error for VerdictValidationError {}
221
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
224#[serde(rename_all = "camelCase", deny_unknown_fields)]
225pub struct ErrorInfo {
226    pub code: String,
227    pub message: String,
228    pub retryable: bool,
229    pub details: Option<Value>,
230}
231
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
234#[serde(rename_all = "camelCase")]
235pub enum SessionState {
236    Active,
237    Ended,
238}
239
240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
241#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
242#[serde(rename_all = "camelCase")]
243pub enum SessionOutcome {
244    Completed,
245    Failed,
246    Cancelled,
247    Shutdown,
248}
249
250#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
251#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
252#[serde(rename_all = "camelCase", deny_unknown_fields)]
253pub struct SessionInfo {
254    pub id: SessionId,
255    pub state: SessionState,
256    #[serde(
257        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
258        deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
259    )]
260    #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
261    pub started_at_ms: u64,
262    #[serde(
263        default,
264        serialize_with = "crate::wire_integer::serialize_optional_js_safe_u64",
265        deserialize_with = "crate::wire_integer::deserialize_optional_js_safe_u64"
266    )]
267    #[cfg_attr(feature = "schema", schemars(with = "Option<SafeWireIntegerSchema>"))]
268    pub ended_at_ms: Option<u64>,
269    pub event_count: EventSequence,
270    pub last_sequence: EventSequence,
271}
272
273#[cfg(feature = "schema")]
274#[allow(dead_code)]
275#[derive(schemars::JsonSchema)]
276#[schemars(inline)]
277struct SafeWireIntegerSchema(#[schemars(range(max = 9_007_199_254_740_991_u64))] u64);
278
279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
281#[serde(rename_all = "camelCase", deny_unknown_fields)]
282pub struct SessionExport {
283    pub session: SessionInfo,
284    pub events: Vec<TestEvent>,
285}
286
287#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
288#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
289#[serde(rename_all = "camelCase")]
290pub enum MediaStreamKind {
291    Screenshot,
292    Video,
293}
294
295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
296#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
297#[serde(rename_all = "camelCase", deny_unknown_fields)]
298pub struct MediaStreamInfo {
299    pub id: MediaStreamId,
300    pub kind: MediaStreamKind,
301    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 255)))]
302    pub media_type: String,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub viewport: Option<Viewport>,
305}
306
307#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
308#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
309#[serde(rename_all = "camelCase", deny_unknown_fields)]
310pub struct MediaFrame {
311    pub stream_id: MediaStreamId,
312    pub frame_index: EventSequence,
313    #[serde(default, skip_serializing_if = "is_false")]
314    pub key_frame: bool,
315    #[serde(
316        default,
317        serialize_with = "crate::wire_integer::serialize_optional_js_safe_u64",
318        deserialize_with = "crate::wire_integer::deserialize_optional_js_safe_u64",
319        skip_serializing_if = "Option::is_none"
320    )]
321    #[cfg_attr(feature = "schema", schemars(with = "Option<SafeWireIntegerSchema>"))]
322    pub duration_ms: Option<u64>,
323    pub evidence: AssetRef,
324}
325
326/// The terminal outcome for one action call.
327///
328/// Keeping the four outcomes structurally distinct prevents clients from
329/// having to infer timeout or cancellation from human-readable error text.
330#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
331#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
332#[serde(
333    tag = "outcome",
334    rename_all = "camelCase",
335    rename_all_fields = "camelCase",
336    deny_unknown_fields
337)]
338pub enum ActionOutcome {
339    Succeeded {
340        result: Box<ActionResult>,
341    },
342    Failed {
343        error: ErrorInfo,
344    },
345    Cancelled {
346        error: ErrorInfo,
347    },
348    TimedOut {
349        error: ErrorInfo,
350        #[serde(
351            serialize_with = "crate::wire_integer::serialize_js_safe_u64",
352            deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
353        )]
354        #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
355        timeout_ms: u64,
356    },
357}
358
359#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
360#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
361#[serde(
362    tag = "type",
363    rename_all = "camelCase",
364    rename_all_fields = "camelCase",
365    deny_unknown_fields
366)]
367pub enum TestEventPayload {
368    SessionStarted,
369    SessionEnded {
370        outcome: SessionOutcome,
371        reason: Option<String>,
372    },
373    ObservationCaptured {
374        observation: Box<Observation>,
375    },
376    ActionStarted {
377        call: RecordedActionCall,
378    },
379    ActionCompleted {
380        call_id: Uuid,
381        outcome: ActionOutcome,
382    },
383    MediaStreamStarted {
384        stream: MediaStreamInfo,
385    },
386    MediaFrameCaptured {
387        frame: MediaFrame,
388    },
389    MediaStreamEnded {
390        stream_id: MediaStreamId,
391        #[serde(
392            serialize_with = "crate::wire_integer::serialize_js_safe_u64",
393            deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
394        )]
395        #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
396        frame_count: u64,
397    },
398    VerdictRecorded {
399        verdict: Verdict,
400    },
401    Error {
402        error: ErrorInfo,
403    },
404}
405
406impl TestEventPayload {
407    /// Lowest Protocol 1.x minor that can represent this payload without
408    /// silently dropping additive fields.
409    pub fn required_protocol_minor(&self) -> u16 {
410        match self {
411            Self::MediaStreamStarted { .. }
412            | Self::MediaFrameCaptured { .. }
413            | Self::MediaStreamEnded { .. } => 4,
414            Self::ObservationCaptured { observation }
415                if observation.ui_snapshot.is_some()
416                    || observation.ui_snapshot_omission.is_some() =>
417            {
418                5
419            }
420            Self::ActionCompleted {
421                outcome: ActionOutcome::Succeeded { result },
422                ..
423            } if result.execution.is_some()
424                || result
425                    .before
426                    .iter()
427                    .chain(result.after.iter())
428                    .any(|observation| {
429                        observation.ui_snapshot.is_some()
430                            || observation.ui_snapshot_omission.is_some()
431                    }) =>
432            {
433                5
434            }
435            _ => 0,
436        }
437    }
438}
439
440#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
441#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
442#[serde(rename_all = "camelCase", deny_unknown_fields)]
443pub struct TestEvent {
444    pub event_id: EventId,
445    pub session_id: SessionId,
446    pub sequence: EventSequence,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub request_id: Option<RpcId>,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub device_id: Option<DeviceId>,
451    #[serde(
452        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
453        deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
454    )]
455    #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
456    pub at_ms: u64,
457    pub payload: TestEventPayload,
458}
459
460impl TestEvent {
461    pub fn required_protocol_minor(&self) -> u16 {
462        self.payload.required_protocol_minor()
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use serde_json::json;
469    use uuid::Uuid;
470
471    use super::{
472        ActionOutcome, ErrorInfo, EventId, EventSequence, MAX_VERDICT_EVIDENCE_REFERENCES,
473        MAX_VERDICT_SUMMARY_LENGTH, SessionId, TestEvent, TestEventPayload, Verdict, VerdictStatus,
474        VerdictValidationError,
475    };
476    use crate::AssetRef;
477
478    #[test]
479    fn envelope_nests_payload_and_has_js_safe_sequence() {
480        let event = TestEvent {
481            event_id: EventId::from(Uuid::nil()),
482            session_id: SessionId::from(Uuid::nil()),
483            sequence: EventSequence::FIRST,
484            request_id: None,
485            device_id: None,
486            at_ms: 1,
487            payload: TestEventPayload::SessionStarted,
488        };
489
490        assert_eq!(
491            serde_json::to_value(event).expect("serialize event"),
492            json!({
493                "eventId": Uuid::nil(),
494                "sessionId": Uuid::nil(),
495                "sequence": 1,
496                "atMs": 1,
497                "payload": {
498                    "type": "sessionStarted"
499                }
500            })
501        );
502        assert!(EventSequence::new(crate::MAX_SAFE_INTEGER).is_some());
503        assert!(EventSequence::new(crate::MAX_SAFE_INTEGER + 1).is_none());
504        assert!(serde_json::from_value::<EventSequence>(json!(0)).is_err());
505        assert!(
506            serde_json::from_value::<EventSequence>(json!(crate::MAX_SAFE_INTEGER + 1)).is_err()
507        );
508    }
509
510    #[test]
511    fn action_outcomes_have_explicit_wire_status() {
512        let outcome = ActionOutcome::Cancelled {
513            error: ErrorInfo {
514                code: "action_cancelled".to_owned(),
515                message: "cancelled".to_owned(),
516                retryable: false,
517                details: None,
518            },
519        };
520        assert_eq!(
521            serde_json::to_value(outcome).expect("serialize outcome")["outcome"],
522            "cancelled"
523        );
524    }
525
526    #[test]
527    fn verdict_validation_uses_schema_aligned_unicode_and_evidence_bounds() {
528        let mut verdict = Verdict {
529            status: VerdictStatus::Unknown,
530            summary: "🧪".repeat(MAX_VERDICT_SUMMARY_LENGTH),
531            evidence: Vec::new(),
532        };
533        verdict.validate().expect("maximum Unicode length");
534
535        verdict.summary.push('x');
536        assert_eq!(
537            verdict.validate(),
538            Err(VerdictValidationError::SummaryTooLong {
539                actual: MAX_VERDICT_SUMMARY_LENGTH + 1,
540                maximum: MAX_VERDICT_SUMMARY_LENGTH,
541            })
542        );
543
544        verdict.summary = " \n\t".to_owned();
545        assert_eq!(
546            verdict.validate(),
547            Err(VerdictValidationError::EmptySummary)
548        );
549
550        verdict.summary = "bounded".to_owned();
551        verdict.evidence = vec![
552            AssetRef {
553                id: "asset".to_owned(),
554                media_type: "image/png".to_owned(),
555                uri: "evidence://asset".to_owned(),
556                sha256: None,
557            };
558            MAX_VERDICT_EVIDENCE_REFERENCES + 1
559        ];
560        assert_eq!(
561            verdict.validate(),
562            Err(VerdictValidationError::TooManyEvidenceReferences {
563                actual: MAX_VERDICT_EVIDENCE_REFERENCES + 1,
564                maximum: MAX_VERDICT_EVIDENCE_REFERENCES,
565            })
566        );
567    }
568}