Skip to main content

bobby_browser_client/
outcomes.rs

1//! Command outcomes, evidence, and accessibility snapshot nodes.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{AttemptId, CommandId, PageId};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[serde(rename_all = "camelCase", deny_unknown_fields)]
11pub struct ControlActionEvidence {
12    pub operation: crate::FormControlOperation,
13    pub target: crate::FormControlTarget,
14    pub state: crate::FormControlState,
15    pub validity: crate::FormControlValidity,
16    pub node_replaced: bool,
17}
18
19/// Semantic target for accessibility-based commands (role + accessible name).
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23pub struct AccessibilityTarget {
24    pub role: String,
25    pub accessible_name: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub ordinal: Option<usize>,
28}
29
30/// Where the retained page context says a described control is.
31///
32/// Must stay in `types`: the crate-boundary guard requires every wire-advertised
33/// shape to be a `types::` one so the schema parity guard covers it.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36#[serde(rename_all = "camelCase")]
37pub struct ContextAnswer {
38    pub target: AccessibilityTarget,
39    pub confidence: f32,
40}
41
42/// One node in an `accessibilitySnapshot` result tree.
43#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct AccessibilityNode {
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub role: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub name: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub target: Option<AccessibilityTarget>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub value: Option<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub description: Option<String>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub required: Option<bool>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub disabled: Option<bool>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub read_only: Option<bool>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub invalid: Option<bool>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub checked: Option<bool>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub autocomplete: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub value_min: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub value_max: Option<String>,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub children: Vec<AccessibilityNode>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79#[serde(
80    tag = "status",
81    rename_all = "camelCase",
82    rename_all_fields = "camelCase"
83)]
84pub enum CommandOutcome {
85    Completed {
86        command_id: CommandId,
87        evidence: Vec<Evidence>,
88    },
89    RetryableFailure {
90        command_id: CommandId,
91        error: CommandError,
92    },
93    NeedsReconciliation {
94        command_id: CommandId,
95        error: CommandError,
96        evidence: Vec<Evidence>,
97    },
98    PolicyDenied {
99        command_id: CommandId,
100        error: CommandError,
101    },
102    ResourceExhausted {
103        command_id: CommandId,
104        error: CommandError,
105        retry_after_ms: u64,
106    },
107    Restarted {
108        command_id: CommandId,
109        prior_attempt_id: AttemptId,
110        attempt_id: AttemptId,
111        reason: String,
112        #[serde(default)]
113        evidence: Vec<Evidence>,
114    },
115    Failed {
116        command_id: CommandId,
117        error: CommandError,
118        #[serde(default)]
119        evidence: Vec<Evidence>,
120    },
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
125#[serde(
126    tag = "kind",
127    rename_all = "camelCase",
128    rename_all_fields = "camelCase"
129)]
130pub enum Evidence {
131    ExecutionPath {
132        path: ExecutionPath,
133        reason: ExecutionReason,
134        state_version: u64,
135        elapsed_ms: u64,
136        bytes: Option<u64>,
137        sha256: Option<String>,
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        final_url: Option<String>,
140        #[serde(default, skip_serializing_if = "Option::is_none")]
141        content_type: Option<String>,
142        #[serde(default, skip_serializing_if = "Option::is_none")]
143        status: Option<u16>,
144        #[serde(default, skip_serializing_if = "Vec::is_empty")]
145        redirect_chain: Vec<String>,
146    },
147    Navigation {
148        url: String,
149        title: String,
150    },
151    Inspection {
152        selector: Option<String>,
153        url: String,
154        title: String,
155        text: String,
156        html: Option<String>,
157    },
158    Element {
159        selector: String,
160        text: Option<String>,
161    },
162    Upload {
163        selector: String,
164        paths: Vec<String>,
165    },
166    Page {
167        page_id: PageId,
168        url: String,
169        title: String,
170    },
171    Pages {
172        pages: Vec<PageEvidence>,
173    },
174    Popup {
175        opener_page_id: PageId,
176        page_id: PageId,
177        url: String,
178        title: String,
179    },
180    Download {
181        filename: String,
182        path: String,
183        bytes: u64,
184        sha256: String,
185    },
186    Configuration {
187        name: String,
188        value: String,
189    },
190    Resolution {
191        target: Box<crate::TargetSpec>,
192        fingerprint: Box<TargetFingerprint>,
193        candidates: Vec<CandidateEvidence>,
194        best_match_authorized: bool,
195    },
196    Wait {
197        condition: crate::WaitCondition,
198        elapsed_ms: u64,
199        observations: u64,
200        #[serde(default, skip_serializing_if = "Vec::is_empty")]
201        excluded_classes: Vec<String>,
202    },
203    Screenshot {
204        artifact_id: String,
205        media_type: String,
206        width: u32,
207        height: u32,
208        bytes: u64,
209        sha256: String,
210    },
211    BrowserExecution {
212        engine: String,
213        browser_version: String,
214        profile_id: String,
215        interaction_path: String,
216    },
217    JavaScriptResult {
218        value: serde_json::Value,
219        truncated: bool,
220    },
221    AccessibilitySnapshot {
222        page_id: PageId,
223        nodes: Vec<AccessibilityNode>,
224        truncated: bool,
225    },
226    FormSnapshot {
227        snapshot: crate::FormSnapshot,
228    },
229    ControlAction {
230        action: ControlActionEvidence,
231    },
232    StructuredExtraction {
233        page_id: PageId,
234        value: serde_json::Value,
235        truncated: bool,
236    },
237    CookieState {
238        page_id: Option<PageId>,
239        cookies: Vec<crate::CookieRecord>,
240    },
241    PdfArtifact {
242        artifact_id: String,
243        media_type: String,
244        bytes: u64,
245        sha256: String,
246    },
247    Dialog {
248        dialog_type: String,
249        message: String,
250        action: String,
251    },
252    Emulation {
253        viewport: Option<crate::ViewportSize>,
254        geolocation: Option<crate::GeolocationCoordinates>,
255    },
256    HarArtifact {
257        artifact_id: String,
258        media_type: String,
259        bytes: u64,
260        sha256: String,
261        entries: u32,
262    },
263    IntentExecution {
264        record: ExecutionRecord,
265    },
266    /// Input timing the runtime synthesized rather than observed, emitted when
267    /// the session opted into `executionPolicy.humanize`. Lets `intent-engine`
268    /// verify an effect against the timing actually issued. Carries no typed
269    /// text: action counts and durations only.
270    Humanization {
271        engine: String,
272        actions: u32,
273        synthesized_ms: u64,
274    },
275    /// Result of resolving one named field of an `ExtractIntent`, emitted once
276    /// per field in field order. `value: None` means unresolved, with the
277    /// reason in `errorCode`; the rest of the extraction still runs.
278    Extraction {
279        field: String,
280        #[serde(default, skip_serializing_if = "Option::is_none")]
281        value: Option<String>,
282        resolution_path: IntentResolutionPath,
283        #[serde(default, skip_serializing_if = "Option::is_none")]
284        error_code: Option<ErrorCode>,
285    },
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
290#[serde(rename_all = "camelCase")]
291pub enum IntentResolutionPath {
292    Deterministic,
293    VisionFallback,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298#[serde(rename_all = "camelCase")]
299pub struct ExecutionRecord {
300    pub intent_kind: String,
301    pub purpose: Option<String>,
302    pub resolution_path: IntentResolutionPath,
303    pub plan_summary: String,
304    pub candidates: Vec<CandidateEvidence>,
305    pub wait_elapsed_ms: Option<u64>,
306    pub verification: String,
307    pub artifact_ids: Vec<String>,
308    pub vision_proposal_sha256: Option<String>,
309}
310
311impl Evidence {
312    pub fn journal_safe(&self) -> Self {
313        fn safe_url(value: &str) -> String {
314            let Ok(mut url) = url::Url::parse(value) else {
315                return "[redacted-invalid-url]".into();
316            };
317            let _ = url.set_username("");
318            let _ = url.set_password(None);
319            url.set_query(None);
320            url.set_fragment(None);
321            url.to_string()
322        }
323        let mut safe = self.clone();
324        match &mut safe {
325            Self::ExecutionPath {
326                final_url,
327                redirect_chain,
328                ..
329            } => {
330                if let Some(url) = final_url {
331                    *url = safe_url(url);
332                }
333                for url in redirect_chain {
334                    *url = safe_url(url);
335                }
336            }
337            Self::Navigation { url, .. }
338            | Self::Inspection { url, .. }
339            | Self::Page { url, .. }
340            | Self::Popup { url, .. } => *url = safe_url(url),
341            Self::Pages { pages } => {
342                for page in pages {
343                    page.url = safe_url(&page.url);
344                }
345            }
346            Self::Upload { paths, .. } => {
347                for (index, path) in paths.iter_mut().enumerate() {
348                    *path = format!("upload://evidence/{index}");
349                }
350            }
351            Self::Download { path, sha256, .. } => {
352                *path = format!("artifact://sha256/{sha256}");
353            }
354            Self::BrowserExecution { .. } => {}
355            Self::IntentExecution { .. } => {}
356            Self::Extraction { .. } => {}
357            _ => {}
358        }
359        safe
360    }
361}
362
363impl CommandOutcome {
364    pub fn journal_safe(&self) -> Self {
365        let mut safe = self.clone();
366        match &mut safe {
367            Self::Completed { evidence, .. }
368            | Self::NeedsReconciliation { evidence, .. }
369            | Self::Restarted { evidence, .. }
370            | Self::Failed { evidence, .. } => {
371                *evidence = evidence.iter().map(Evidence::journal_safe).collect();
372            }
373            _ => {}
374        }
375        match &mut safe {
376            Self::RetryableFailure { error, .. }
377            | Self::NeedsReconciliation { error, .. }
378            | Self::PolicyDenied { error, .. }
379            | Self::ResourceExhausted { error, .. }
380            | Self::Failed { error, .. } => error.message = "redacted durable diagnostic".into(),
381            _ => {}
382        }
383        safe
384    }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
389#[serde(rename_all = "camelCase")]
390pub enum ExecutionPath {
391    DirectHttp,
392    Chromium,
393    ChromiumFallback,
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
398#[serde(rename_all = "camelCase")]
399pub enum ExecutionReason {
400    EligibleStaticDocument,
401    EligibleExplicitDownload,
402    IneligibleCommand,
403    SemanticTargetRequired,
404    JavascriptRequired,
405    UnsupportedContentType,
406    StateConflict,
407    PolicyRequired,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
411#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
412#[serde(rename_all = "camelCase")]
413pub struct CandidateEvidence {
414    pub role: Option<String>,
415    pub name: Option<String>,
416    pub score: i32,
417    pub reasons: Vec<String>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
422#[serde(rename_all = "camelCase")]
423pub struct TargetFingerprint {
424    pub page_id: PageId,
425    pub frame: Option<String>,
426    pub role: Option<String>,
427    pub name: Option<String>,
428    pub stable_attributes: std::collections::BTreeMap<String, String>,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
433#[serde(rename_all = "camelCase")]
434pub struct PageEvidence {
435    pub page_id: PageId,
436    pub url: String,
437    pub title: String,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442#[serde(rename_all = "camelCase")]
443pub struct CommandError {
444    pub code: ErrorCode,
445    pub message: String,
446    pub layer: ErrorLayer,
447    pub retryable: bool,
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
451#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
452#[serde(rename_all = "camelCase")]
453pub enum ErrorCode {
454    InvalidRequest,
455    NotFound,
456    DeadlineExceeded,
457    BrowserLaunchFailed,
458    BrowserCommandFailed,
459    VerificationFailed,
460    JournalFailed,
461    ResourceExhausted,
462    PolicyDenied,
463    Internal,
464    TargetNotFound,
465    TargetAmbiguous,
466    FrameNotFound,
467    ShadowRootUnavailable,
468    TargetDetached,
469    TargetObscured,
470    TargetOutOfBounds,
471    WaitConditionTimedOut,
472    ScreenshotCaptureFailed,
473    NetworkPolicyDenied,
474    HttpResponseTooLarge,
475    HttpTransferFailed,
476    HttpStateConflict,
477    HttpEquivalenceUnproven,
478    IntentCompileFailed,
479    IntentActionMismatch,
480    ObstructionSuspected,
481    VisionAssistDenied,
482    VisionAssistFailed,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
486#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
487#[serde(rename_all = "camelCase")]
488pub enum ErrorLayer {
489    Interface,
490    Broker,
491    Workflow,
492    Page,
493    Driver,
494    Browser,
495    Network,
496    Site,
497    Journal,
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
501#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
502#[serde(rename_all = "camelCase")]
503pub enum CommandPhase {
504    Accepted,
505    Prepared,
506    Executing,
507    ResultPrepared,
508    Verifying,
509    Recovering,
510    Completed,
511    Failed,
512}
513
514#[derive(Debug, Error, Serialize, Deserialize)]
515#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
516pub enum RuntimeError {
517    #[error("not found: {0}")]
518    NotFound(String),
519    #[error("invalid request: {0}")]
520    InvalidRequest(String),
521    #[error("internal error: {0}")]
522    Internal(String),
523}