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
9pub const MAX_VERDICT_SUMMARY_LENGTH: usize = 16 * 1024;
11pub 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#[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#[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 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}