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    /// Whether the answer was observed live this session (stamped with the
41    /// page's generation) or remembered from a prior session's persisted
42    /// context. Additive; absent means a live generation-0 answer.
43    #[serde(default)]
44    pub observed_at: ContextObservedAt,
45    /// How the underlying record entered the graph. Absent for live answers
46    /// (always direct observation).
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub source: Option<ContextAnswerSource>,
49}
50
51impl Default for ContextObservedAt {
52    fn default() -> Self {
53        Self::Generation { generation: 0 }
54    }
55}
56
57/// Provenance of a [`ContextAnswer`]: live-observed under a page generation,
58/// or remembered from the persisted per-profile context store.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(tag = "kind", rename_all = "camelCase")]
62pub enum ContextObservedAt {
63    Generation { generation: u64 },
64    Persisted,
65}
66
67/// How a remembered control record entered the graph.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70#[serde(rename_all = "kebab-case")]
71pub enum ContextAnswerSource {
72    Observed,
73    VisionPromoted,
74}
75
76/// The remembered form structure around a located control (`context_neighbors`).
77/// Structure only: roles, names, ordinals, and per-intent counters — never
78/// values, page text, or timestamps finer than a day.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[serde(rename_all = "camelCase")]
82pub struct ContextNeighbors {
83    pub answer: ContextAnswer,
84    /// Key of the enclosing form within the remembered page.
85    pub form: String,
86    /// Pattern of the remembered page the form belongs to.
87    pub page_pattern: String,
88    pub controls: Vec<ContextNeighborControl>,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[serde(rename_all = "camelCase")]
94pub struct ContextNeighborControl {
95    pub role: String,
96    pub accessible_name: String,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub ordinal: Option<usize>,
99    /// Per-intent-kind counters, keyed by intent kind.
100    pub intents: std::collections::BTreeMap<String, ContextNeighborStats>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105#[serde(rename_all = "camelCase")]
106pub struct ContextNeighborStats {
107    pub success_count: u64,
108    pub failure_count: u64,
109    /// Days since the Unix epoch of the last verified success.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub last_verified_day: Option<u32>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub source: Option<ContextAnswerSource>,
114}
115
116/// One remembered site's structure: page pattern → form key → controls.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119#[serde(rename_all = "camelCase")]
120pub struct ContextSiteView {
121    pub site_key: String,
122    pub pages: std::collections::BTreeMap<
123        String,
124        std::collections::BTreeMap<String, Vec<ContextNeighborControl>>,
125    >,
126}
127
128/// One node in an `accessibilitySnapshot` result tree.
129#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131#[serde(rename_all = "camelCase", deny_unknown_fields)]
132pub struct AccessibilityNode {
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub role: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub name: Option<String>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub target: Option<AccessibilityTarget>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub value: Option<String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub description: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub required: Option<bool>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub disabled: Option<bool>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub read_only: Option<bool>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub invalid: Option<bool>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub checked: Option<bool>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub autocomplete: Option<String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub value_min: Option<String>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub value_max: Option<String>,
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub children: Vec<AccessibilityNode>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
165#[serde(
166    tag = "status",
167    rename_all = "camelCase",
168    rename_all_fields = "camelCase"
169)]
170pub enum CommandOutcome {
171    Completed {
172        command_id: CommandId,
173        evidence: Vec<Evidence>,
174    },
175    RetryableFailure {
176        command_id: CommandId,
177        error: CommandError,
178    },
179    NeedsReconciliation {
180        command_id: CommandId,
181        error: CommandError,
182        evidence: Vec<Evidence>,
183    },
184    PolicyDenied {
185        command_id: CommandId,
186        error: CommandError,
187    },
188    ResourceExhausted {
189        command_id: CommandId,
190        error: CommandError,
191        retry_after_ms: u64,
192    },
193    Restarted {
194        command_id: CommandId,
195        prior_attempt_id: AttemptId,
196        attempt_id: AttemptId,
197        reason: String,
198        #[serde(default)]
199        evidence: Vec<Evidence>,
200    },
201    Failed {
202        command_id: CommandId,
203        error: CommandError,
204        #[serde(default)]
205        evidence: Vec<Evidence>,
206    },
207}
208
209/// Upper bound on [`Evidence::Wait`]'s `observed` value, in characters.
210///
211/// A verification value -- a matched label, a URL, a ready state -- not page
212/// text. Truncation is on a character boundary; a byte index can land inside a
213/// multi-byte codepoint and panic, which is exactly how extraction used to die
214/// on non-ASCII pages.
215pub const MAX_WAIT_OBSERVED_CHARS: usize = 512;
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219#[serde(
220    tag = "kind",
221    rename_all = "camelCase",
222    rename_all_fields = "camelCase"
223)]
224pub enum Evidence {
225    ExecutionPath {
226        path: ExecutionPath,
227        reason: ExecutionReason,
228        state_version: u64,
229        elapsed_ms: u64,
230        bytes: Option<u64>,
231        sha256: Option<String>,
232        #[serde(default, skip_serializing_if = "Option::is_none")]
233        final_url: Option<String>,
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        content_type: Option<String>,
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        status: Option<u16>,
238        #[serde(default, skip_serializing_if = "Vec::is_empty")]
239        redirect_chain: Vec<String>,
240    },
241    Navigation {
242        url: String,
243        title: String,
244    },
245    Inspection {
246        selector: Option<String>,
247        url: String,
248        title: String,
249        text: String,
250        html: Option<String>,
251    },
252    Element {
253        selector: String,
254        text: Option<String>,
255    },
256    Upload {
257        selector: String,
258        paths: Vec<String>,
259    },
260    Page {
261        page_id: PageId,
262        url: String,
263        title: String,
264    },
265    Pages {
266        pages: Vec<PageEvidence>,
267    },
268    Popup {
269        opener_page_id: PageId,
270        page_id: PageId,
271        url: String,
272        title: String,
273    },
274    Download {
275        filename: String,
276        path: String,
277        bytes: u64,
278        sha256: String,
279    },
280    Configuration {
281        name: String,
282        value: String,
283    },
284    Resolution {
285        target: Box<crate::TargetSpec>,
286        fingerprint: Box<TargetFingerprint>,
287        candidates: Vec<CandidateEvidence>,
288        best_match_authorized: bool,
289    },
290    Wait {
291        condition: crate::WaitCondition,
292        elapsed_ms: u64,
293        observations: u64,
294        #[serde(default, skip_serializing_if = "Vec::is_empty")]
295        excluded_classes: Vec<String>,
296        /// What the satisfying poll actually read: the element text or value,
297        /// the URL, or the document ready state, depending on the condition.
298        ///
299        /// The wait already reads this to decide whether it is satisfied. It
300        /// used to be discarded, so an agent that verified a submit had to
301        /// spend a second round trip snapshotting the page to learn what it
302        /// had just confirmed. Bounded by [`MAX_WAIT_OBSERVED_CHARS`] -- this
303        /// is a verification value, never a page-text dump.
304        #[serde(default, skip_serializing_if = "Option::is_none")]
305        observed: Option<String>,
306    },
307    Screenshot {
308        artifact_id: String,
309        media_type: String,
310        width: u32,
311        height: u32,
312        bytes: u64,
313        sha256: String,
314    },
315    BrowserExecution {
316        engine: String,
317        browser_version: String,
318        profile_id: String,
319        interaction_path: String,
320    },
321    JavaScriptResult {
322        value: serde_json::Value,
323        truncated: bool,
324    },
325    AccessibilitySnapshot {
326        page_id: PageId,
327        nodes: Vec<AccessibilityNode>,
328        truncated: bool,
329    },
330    FormSnapshot {
331        snapshot: crate::FormSnapshot,
332    },
333    ControlAction {
334        action: ControlActionEvidence,
335    },
336    StructuredExtraction {
337        page_id: PageId,
338        value: serde_json::Value,
339        truncated: bool,
340    },
341    CookieState {
342        page_id: Option<PageId>,
343        cookies: Vec<crate::CookieRecord>,
344    },
345    PdfArtifact {
346        artifact_id: String,
347        media_type: String,
348        bytes: u64,
349        sha256: String,
350    },
351    Dialog {
352        dialog_type: String,
353        message: String,
354        action: String,
355    },
356    Emulation {
357        viewport: Option<crate::ViewportSize>,
358        geolocation: Option<crate::GeolocationCoordinates>,
359    },
360    HarArtifact {
361        artifact_id: String,
362        media_type: String,
363        bytes: u64,
364        sha256: String,
365        entries: u32,
366    },
367    IntentExecution {
368        record: ExecutionRecord,
369    },
370    /// Input timing the runtime synthesized rather than observed, emitted when
371    /// the session opted into `executionPolicy.humanize`. Lets `intent-engine`
372    /// verify an effect against the timing actually issued. Carries no typed
373    /// text: action counts and durations only.
374    Humanization {
375        engine: String,
376        actions: u32,
377        synthesized_ms: u64,
378    },
379    /// Result of resolving one named field of an `ExtractIntent`, emitted once
380    /// per field in field order. `value: None` means unresolved, with the
381    /// reason in `errorCode`; the rest of the extraction still runs.
382    Extraction {
383        field: String,
384        #[serde(default, skip_serializing_if = "Option::is_none")]
385        value: Option<String>,
386        resolution_path: IntentResolutionPath,
387        #[serde(default, skip_serializing_if = "Option::is_none")]
388        error_code: Option<ErrorCode>,
389    },
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
394#[serde(rename_all = "camelCase")]
395pub enum IntentResolutionPath {
396    Deterministic,
397    VisionFallback,
398    /// Resolved from a cached vision proposal (lazy batch prefill) rather
399    /// than a live stuck-rescue escalation.
400    VisionPrefill,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
404#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
405#[serde(rename_all = "camelCase")]
406pub struct ExecutionRecord {
407    pub intent_kind: String,
408    pub purpose: Option<String>,
409    pub resolution_path: IntentResolutionPath,
410    pub plan_summary: String,
411    pub candidates: Vec<CandidateEvidence>,
412    pub wait_elapsed_ms: Option<u64>,
413    pub verification: String,
414    pub artifact_ids: Vec<String>,
415    pub vision_proposal_sha256: Option<String>,
416}
417
418impl Evidence {
419    pub fn journal_safe(&self) -> Self {
420        fn safe_url(value: &str) -> String {
421            let Ok(mut url) = url::Url::parse(value) else {
422                return "[redacted-invalid-url]".into();
423            };
424            let _ = url.set_username("");
425            let _ = url.set_password(None);
426            url.set_query(None);
427            url.set_fragment(None);
428            url.to_string()
429        }
430        let mut safe = self.clone();
431        match &mut safe {
432            Self::ExecutionPath {
433                final_url,
434                redirect_chain,
435                ..
436            } => {
437                if let Some(url) = final_url {
438                    *url = safe_url(url);
439                }
440                for url in redirect_chain {
441                    *url = safe_url(url);
442                }
443            }
444            Self::Navigation { url, .. }
445            | Self::Inspection { url, .. }
446            | Self::Page { url, .. }
447            | Self::Popup { url, .. } => *url = safe_url(url),
448            Self::Pages { pages } => {
449                for page in pages {
450                    page.url = safe_url(&page.url);
451                }
452            }
453            Self::Upload { paths, .. } => {
454                for (index, path) in paths.iter_mut().enumerate() {
455                    *path = format!("upload://evidence/{index}");
456                }
457            }
458            Self::Download { path, sha256, .. } => {
459                *path = format!("artifact://sha256/{sha256}");
460            }
461            Self::BrowserExecution { .. } => {}
462            Self::IntentExecution { .. } => {}
463            Self::Extraction { .. } => {}
464            _ => {}
465        }
466        safe
467    }
468}
469
470impl CommandOutcome {
471    pub fn journal_safe(&self) -> Self {
472        let mut safe = self.clone();
473        match &mut safe {
474            Self::Completed { evidence, .. }
475            | Self::NeedsReconciliation { evidence, .. }
476            | Self::Restarted { evidence, .. }
477            | Self::Failed { evidence, .. } => {
478                *evidence = evidence.iter().map(Evidence::journal_safe).collect();
479            }
480            _ => {}
481        }
482        match &mut safe {
483            Self::RetryableFailure { error, .. }
484            | Self::NeedsReconciliation { error, .. }
485            | Self::PolicyDenied { error, .. }
486            | Self::ResourceExhausted { error, .. }
487            | Self::Failed { error, .. } => error.message = "redacted durable diagnostic".into(),
488            _ => {}
489        }
490        safe
491    }
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
496#[serde(rename_all = "camelCase")]
497pub enum ExecutionPath {
498    DirectHttp,
499    Chromium,
500    ChromiumFallback,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
505#[serde(rename_all = "camelCase")]
506pub enum ExecutionReason {
507    EligibleStaticDocument,
508    EligibleExplicitDownload,
509    IneligibleCommand,
510    SemanticTargetRequired,
511    JavascriptRequired,
512    UnsupportedContentType,
513    StateConflict,
514    PolicyRequired,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
518#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
519#[serde(rename_all = "camelCase")]
520pub struct CandidateEvidence {
521    pub role: Option<String>,
522    pub name: Option<String>,
523    pub score: i32,
524    pub reasons: Vec<String>,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
528#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
529#[serde(rename_all = "camelCase")]
530pub struct TargetFingerprint {
531    pub page_id: PageId,
532    pub frame: Option<String>,
533    pub role: Option<String>,
534    pub name: Option<String>,
535    pub stable_attributes: std::collections::BTreeMap<String, String>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
539#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
540#[serde(rename_all = "camelCase")]
541pub struct PageEvidence {
542    pub page_id: PageId,
543    pub url: String,
544    pub title: String,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
549#[serde(rename_all = "camelCase")]
550pub struct CommandError {
551    pub code: ErrorCode,
552    pub message: String,
553    pub layer: ErrorLayer,
554    pub retryable: bool,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
558#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
559#[serde(rename_all = "camelCase")]
560pub enum ErrorCode {
561    InvalidRequest,
562    NotFound,
563    DeadlineExceeded,
564    BrowserLaunchFailed,
565    BrowserCommandFailed,
566    VerificationFailed,
567    JournalFailed,
568    ResourceExhausted,
569    PolicyDenied,
570    Internal,
571    TargetNotFound,
572    TargetAmbiguous,
573    FrameNotFound,
574    ShadowRootUnavailable,
575    TargetDetached,
576    TargetObscured,
577    TargetOutOfBounds,
578    WaitConditionTimedOut,
579    ScreenshotCaptureFailed,
580    NetworkPolicyDenied,
581    HttpResponseTooLarge,
582    HttpTransferFailed,
583    HttpStateConflict,
584    HttpEquivalenceUnproven,
585    IntentCompileFailed,
586    IntentActionMismatch,
587    ObstructionSuspected,
588    VisionAssistDenied,
589    VisionAssistFailed,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
593#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
594#[serde(rename_all = "camelCase")]
595pub enum ErrorLayer {
596    Interface,
597    Broker,
598    Workflow,
599    Page,
600    Driver,
601    Browser,
602    Network,
603    Site,
604    Journal,
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
609#[serde(rename_all = "camelCase")]
610pub enum CommandPhase {
611    Accepted,
612    Prepared,
613    Executing,
614    ResultPrepared,
615    Verifying,
616    Recovering,
617    Completed,
618    Failed,
619}
620
621#[derive(Debug, Error, Serialize, Deserialize)]
622#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
623pub enum RuntimeError {
624    #[error("not found: {0}")]
625    NotFound(String),
626    #[error("invalid request: {0}")]
627    InvalidRequest(String),
628    #[error("internal error: {0}")]
629    Internal(String),
630}