use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::vocab::{
CoordinateFallbackReason, ScreenshotOmissionReason, UiContextKind, UiSnapshotOmissionReason,
VerdictStatus,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AssetRef {
pub id: String,
pub media_type: String,
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EvidenceGap {
pub asset: AssetRef,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EvidenceRef {
pub asset: AssetRef,
pub sha256: String,
pub local_path: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Viewport {
pub width: u64,
pub height: u64,
pub scale_factor: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiContextRef {
pub context_kind: UiContextKind,
pub context_id: String,
pub document_epoch: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiNodeRef {
pub observation_id: String,
pub context: UiContextRef,
pub stable_node_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiSnapshotRef {
pub evidence: AssetRef,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Observation {
pub id: String,
pub device_id: String,
pub captured_at_ms: u64,
pub viewport: Viewport,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<AssetRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot_omission: Option<ScreenshotOmissionReason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_snapshot: Option<UiSnapshotRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ErrorInfo {
pub code: String,
pub message: String,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
pub enum ActionExecution {
#[serde(rename_all = "camelCase")]
NativeSemantic {
context: UiContextRef,
},
#[serde(rename_all = "camelCase")]
WebSemantic {
context: UiContextRef,
},
#[serde(rename_all = "camelCase")]
CoordinateFallback {
context: UiContextRef,
fallback_reason: CoordinateFallbackReason,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ActionResult {
pub call_id: String,
pub started_at_ms: u64,
pub finished_at_ms: u64,
pub output: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<Observation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<Observation>,
pub evidence: Vec<AssetRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub execution: Option<ActionExecution>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "outcome", rename_all = "camelCase", deny_unknown_fields)]
pub enum ActionOutcome {
#[serde(rename_all = "camelCase")]
Succeeded {
result: Box<ActionResult>,
},
#[serde(rename_all = "camelCase")]
Failed {
error: ErrorInfo,
},
#[serde(rename_all = "camelCase")]
Cancelled {
error: ErrorInfo,
},
#[serde(rename_all = "camelCase")]
TimedOut {
error: ErrorInfo,
},
}
impl ActionOutcome {
pub fn kind(&self) -> &'static str {
match self {
ActionOutcome::Succeeded { .. } => "succeeded",
ActionOutcome::Failed { .. } => "failed",
ActionOutcome::Cancelled { .. } => "cancelled",
ActionOutcome::TimedOut { .. } => "timedOut",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "fate", rename_all = "camelCase", deny_unknown_fields)]
pub enum ReconcileResult {
#[serde(rename_all = "camelCase")]
Completed {
outcome: Box<ActionOutcome>,
},
NeverDispatched,
StartedNoTerminal,
#[serde(rename_all = "camelCase")]
LogUnavailable {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Verdict {
pub status: VerdictStatus,
pub degraded: bool,
pub summary: String,
pub evidence: Vec<AssetRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StepVerdict {
pub status: VerdictStatus,
pub degraded: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn action_outcome_wire_discriminants_round_trip() {
let error = ErrorInfo {
code: "action_timeout".to_owned(),
message: "budget elapsed".to_owned(),
retryable: true,
details: None,
};
let timed_out = ActionOutcome::TimedOut {
error: error.clone(),
};
let wire = serde_json::to_value(&timed_out).expect("serialize");
assert_eq!(wire["outcome"], "timedOut");
assert_eq!(wire["error"]["retryable"], true);
let back: ActionOutcome = serde_json::from_value(wire).expect("deserialize");
assert_eq!(back, timed_out);
assert_eq!(back.kind(), "timedOut");
let cancelled = ActionOutcome::Cancelled { error };
assert_eq!(
serde_json::to_value(&cancelled).expect("serialize")["outcome"],
"cancelled"
);
}
#[test]
fn reconcile_fates_round_trip() {
let unavailable = ReconcileResult::LogUnavailable {
reason: "session deleted".to_owned(),
};
let wire = serde_json::to_value(&unavailable).expect("serialize");
assert_eq!(
wire,
json!({"fate": "logUnavailable", "reason": "session deleted"})
);
let never: ReconcileResult =
serde_json::from_value(json!({"fate": "neverDispatched"})).expect("deserialize");
assert_eq!(never, ReconcileResult::NeverDispatched);
}
#[test]
fn reconcile_completed_carries_the_four_way_outcome_verbatim() {
let completed = ReconcileResult::Completed {
outcome: Box::new(ActionOutcome::Failed {
error: ErrorInfo {
code: "device_unavailable".to_owned(),
message: "device went away".to_owned(),
retryable: true,
details: None,
},
}),
};
let wire = serde_json::to_value(&completed).expect("serialize");
assert_eq!(wire["fate"], "completed");
assert_eq!(wire["outcome"]["outcome"], "failed");
assert_eq!(wire["outcome"]["error"]["retryable"], true);
let back: ReconcileResult = serde_json::from_value(wire).expect("deserialize");
assert_eq!(back, completed);
}
#[test]
fn observation_omissions_are_camel_case_and_absent_when_none() {
let observation = Observation {
id: "obs-1".to_owned(),
device_id: "dev-1".to_owned(),
captured_at_ms: 1,
viewport: Viewport {
width: 1080,
height: 2400,
scale_factor: 2.0,
},
screenshot: None,
screenshot_omission: Some(ScreenshotOmissionReason::ProtectedAction),
ui_snapshot: None,
ui_snapshot_omission: Some(UiSnapshotOmissionReason::DriverUnsupported),
metadata: BTreeMap::new(),
};
let wire = serde_json::to_value(&observation).expect("serialize");
assert_eq!(wire["screenshotOmission"], "protectedAction");
assert_eq!(wire["uiSnapshotOmission"], "driverUnsupported");
assert!(wire.get("screenshot").is_none());
assert!(wire.get("metadata").is_none());
let back: Observation = serde_json::from_value(wire).expect("deserialize");
assert_eq!(back, observation);
}
#[test]
fn execution_mode_fallback_reason_round_trips() {
let execution = ActionExecution::CoordinateFallback {
context: UiContextRef {
context_kind: UiContextKind::Native,
context_id: "ctx-1".to_owned(),
document_epoch: "epoch-1".to_owned(),
},
fallback_reason: CoordinateFallbackReason::SemanticInteractionUnavailable,
};
let wire = serde_json::to_value(&execution).expect("serialize");
assert_eq!(wire["mode"], "coordinateFallback");
assert_eq!(wire["fallbackReason"], "semanticInteractionUnavailable");
let back: ActionExecution = serde_json::from_value(wire).expect("deserialize");
assert_eq!(back, execution);
}
}