1use std::collections::BTreeMap;
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::vocab::{
21 CoordinateFallbackReason, ScreenshotOmissionReason, UiContextKind, UiSnapshotOmissionReason,
22 VerdictStatus,
23};
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct AssetRef {
30 pub id: String,
32 pub media_type: String,
34 pub uri: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub sha256: Option<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct EvidenceGap {
47 pub asset: AssetRef,
49 pub reason: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58pub struct EvidenceRef {
59 pub asset: AssetRef,
61 pub sha256: String,
63 pub local_path: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct Viewport {
71 pub width: u64,
73 pub height: u64,
75 pub scale_factor: f64,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct UiContextRef {
85 pub context_kind: UiContextKind,
87 pub context_id: String,
89 pub document_epoch: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct UiNodeRef {
97 pub observation_id: String,
99 pub context: UiContextRef,
101 pub stable_node_id: String,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct UiSnapshotRef {
111 pub evidence: AssetRef,
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct Observation {
120 pub id: String,
122 pub device_id: String,
124 pub captured_at_ms: u64,
126 pub viewport: Viewport,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub screenshot: Option<AssetRef>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub screenshot_omission: Option<ScreenshotOmissionReason>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub ui_snapshot: Option<UiSnapshotRef>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
140 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
142 pub metadata: BTreeMap<String, Value>,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
148#[serde(rename_all = "camelCase", deny_unknown_fields)]
149pub struct ErrorInfo {
150 pub code: String,
153 pub message: String,
155 pub retryable: bool,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub details: Option<Value>,
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
165#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
166pub enum ActionExecution {
167 #[serde(rename_all = "camelCase")]
169 NativeSemantic {
170 context: UiContextRef,
172 },
173 #[serde(rename_all = "camelCase")]
175 WebSemantic {
176 context: UiContextRef,
178 },
179 #[serde(rename_all = "camelCase")]
182 CoordinateFallback {
183 context: UiContextRef,
185 fallback_reason: CoordinateFallbackReason,
187 },
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct ActionResult {
195 pub call_id: String,
197 pub started_at_ms: u64,
199 pub finished_at_ms: u64,
201 pub output: Value,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub before: Option<Observation>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub after: Option<Observation>,
209 pub evidence: Vec<AssetRef>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub execution: Option<ActionExecution>,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
219#[serde(tag = "outcome", rename_all = "camelCase", deny_unknown_fields)]
220pub enum ActionOutcome {
221 #[serde(rename_all = "camelCase")]
223 Succeeded {
224 result: Box<ActionResult>,
226 },
227 #[serde(rename_all = "camelCase")]
229 Failed {
230 error: ErrorInfo,
232 },
233 #[serde(rename_all = "camelCase")]
235 Cancelled {
236 error: ErrorInfo,
238 },
239 #[serde(rename_all = "camelCase")]
241 TimedOut {
242 error: ErrorInfo,
244 },
245}
246
247impl ActionOutcome {
248 pub fn kind(&self) -> &'static str {
250 match self {
251 ActionOutcome::Succeeded { .. } => "succeeded",
252 ActionOutcome::Failed { .. } => "failed",
253 ActionOutcome::Cancelled { .. } => "cancelled",
254 ActionOutcome::TimedOut { .. } => "timedOut",
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
261#[serde(tag = "fate", rename_all = "camelCase", deny_unknown_fields)]
262pub enum ReconcileResult {
263 #[serde(rename_all = "camelCase")]
268 Completed {
269 outcome: Box<ActionOutcome>,
272 },
273 NeverDispatched,
275 StartedNoTerminal,
278 #[serde(rename_all = "camelCase")]
280 LogUnavailable {
281 reason: String,
283 },
284}
285
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
289#[serde(rename_all = "camelCase", deny_unknown_fields)]
290pub struct Verdict {
291 pub status: VerdictStatus,
293 pub degraded: bool,
296 pub summary: String,
299 pub evidence: Vec<AssetRef>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub supersedes: Option<String>,
304}
305
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
310#[serde(rename_all = "camelCase", deny_unknown_fields)]
311pub struct StepVerdict {
312 pub status: VerdictStatus,
314 pub degraded: bool,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub supersedes: Option<String>,
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use serde_json::json;
325
326 #[test]
327 fn action_outcome_wire_discriminants_round_trip() {
328 let error = ErrorInfo {
329 code: "action_timeout".to_owned(),
330 message: "budget elapsed".to_owned(),
331 retryable: true,
332 details: None,
333 };
334 let timed_out = ActionOutcome::TimedOut {
335 error: error.clone(),
336 };
337 let wire = serde_json::to_value(&timed_out).expect("serialize");
338 assert_eq!(wire["outcome"], "timedOut");
339 assert_eq!(wire["error"]["retryable"], true);
340 let back: ActionOutcome = serde_json::from_value(wire).expect("deserialize");
341 assert_eq!(back, timed_out);
342 assert_eq!(back.kind(), "timedOut");
343
344 let cancelled = ActionOutcome::Cancelled { error };
345 assert_eq!(
346 serde_json::to_value(&cancelled).expect("serialize")["outcome"],
347 "cancelled"
348 );
349 }
350
351 #[test]
352 fn reconcile_fates_round_trip() {
353 let unavailable = ReconcileResult::LogUnavailable {
354 reason: "session deleted".to_owned(),
355 };
356 let wire = serde_json::to_value(&unavailable).expect("serialize");
357 assert_eq!(
358 wire,
359 json!({"fate": "logUnavailable", "reason": "session deleted"})
360 );
361 let never: ReconcileResult =
362 serde_json::from_value(json!({"fate": "neverDispatched"})).expect("deserialize");
363 assert_eq!(never, ReconcileResult::NeverDispatched);
364 }
365
366 #[test]
367 fn reconcile_completed_carries_the_four_way_outcome_verbatim() {
368 let completed = ReconcileResult::Completed {
369 outcome: Box::new(ActionOutcome::Failed {
370 error: ErrorInfo {
371 code: "device_unavailable".to_owned(),
372 message: "device went away".to_owned(),
373 retryable: true,
374 details: None,
375 },
376 }),
377 };
378 let wire = serde_json::to_value(&completed).expect("serialize");
379 assert_eq!(wire["fate"], "completed");
380 assert_eq!(wire["outcome"]["outcome"], "failed");
381 assert_eq!(wire["outcome"]["error"]["retryable"], true);
382 let back: ReconcileResult = serde_json::from_value(wire).expect("deserialize");
383 assert_eq!(back, completed);
384 }
385
386 #[test]
387 fn observation_omissions_are_camel_case_and_absent_when_none() {
388 let observation = Observation {
389 id: "obs-1".to_owned(),
390 device_id: "dev-1".to_owned(),
391 captured_at_ms: 1,
392 viewport: Viewport {
393 width: 1080,
394 height: 2400,
395 scale_factor: 2.0,
396 },
397 screenshot: None,
398 screenshot_omission: Some(ScreenshotOmissionReason::ProtectedAction),
399 ui_snapshot: None,
400 ui_snapshot_omission: Some(UiSnapshotOmissionReason::DriverUnsupported),
401 metadata: BTreeMap::new(),
402 };
403 let wire = serde_json::to_value(&observation).expect("serialize");
404 assert_eq!(wire["screenshotOmission"], "protectedAction");
405 assert_eq!(wire["uiSnapshotOmission"], "driverUnsupported");
406 assert!(wire.get("screenshot").is_none());
407 assert!(wire.get("metadata").is_none());
408 let back: Observation = serde_json::from_value(wire).expect("deserialize");
409 assert_eq!(back, observation);
410 }
411
412 #[test]
413 fn execution_mode_fallback_reason_round_trips() {
414 let execution = ActionExecution::CoordinateFallback {
415 context: UiContextRef {
416 context_kind: UiContextKind::Native,
417 context_id: "ctx-1".to_owned(),
418 document_epoch: "epoch-1".to_owned(),
419 },
420 fallback_reason: CoordinateFallbackReason::SemanticInteractionUnavailable,
421 };
422 let wire = serde_json::to_value(&execution).expect("serialize");
423 assert_eq!(wire["mode"], "coordinateFallback");
424 assert_eq!(wire["fallbackReason"], "semanticInteractionUnavailable");
425 let back: ActionExecution = serde_json::from_value(wire).expect("deserialize");
426 assert_eq!(back, execution);
427 }
428}