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