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