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