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/// Machine-readable reason returned when retained context has no answer.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
143pub enum ContextMissReason {
144    #[serde(rename = "notRemembered")]
145    NotRemembered,
146}
147
148/// Repair step returned with a retained-context miss.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151pub enum ContextNextStep {
152    #[serde(rename = "a11y_snapshot")]
153    A11ySnapshot,
154}
155
156/// Response from `GET /v1/context/ask`.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct ContextAskResponse {
161    pub answer: Option<ContextAnswer>,
162    pub hit: bool,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub reason: Option<ContextMissReason>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub next_step: Option<ContextNextStep>,
167}
168
169/// Response from `GET /v1/context/neighbors`.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(rename_all = "camelCase", deny_unknown_fields)]
173pub struct ContextNeighborsResponse {
174    pub neighbors: Option<ContextNeighbors>,
175    pub hit: bool,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub reason: Option<ContextMissReason>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub next_step: Option<ContextNextStep>,
180}
181
182/// Response from `GET /v1/context/site/{key}`.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[serde(rename_all = "camelCase", deny_unknown_fields)]
186pub struct ContextSiteResponse {
187    pub site: Option<ContextSiteView>,
188}
189
190/// One node in an `accessibilitySnapshot` result tree.
191#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct AccessibilityNode {
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub role: Option<String>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub name: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub target: Option<AccessibilityTarget>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub value: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub description: Option<String>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub required: Option<bool>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub disabled: Option<bool>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub read_only: Option<bool>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub invalid: Option<bool>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub checked: Option<bool>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub autocomplete: Option<String>,
217    /// Link destination, when the engine reports one for this node. Lets an
218    /// agent feed `download_url` (or navigate) without an HTML round-trip.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub url: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub value_min: Option<String>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub value_max: Option<String>,
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub children: Vec<AccessibilityNode>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
231#[serde(
232    tag = "status",
233    rename_all = "camelCase",
234    rename_all_fields = "camelCase"
235)]
236pub enum CommandOutcome {
237    Completed {
238        command_id: CommandId,
239        evidence: Vec<Evidence>,
240    },
241    RetryableFailure {
242        command_id: CommandId,
243        error: CommandError,
244    },
245    NeedsReconciliation {
246        command_id: CommandId,
247        error: CommandError,
248        evidence: Vec<Evidence>,
249    },
250    PolicyDenied {
251        command_id: CommandId,
252        error: CommandError,
253    },
254    ResourceExhausted {
255        command_id: CommandId,
256        error: CommandError,
257        retry_after_ms: u64,
258    },
259    Restarted {
260        command_id: CommandId,
261        prior_attempt_id: AttemptId,
262        attempt_id: AttemptId,
263        reason: String,
264        #[serde(default)]
265        evidence: Vec<Evidence>,
266    },
267    Failed {
268        command_id: CommandId,
269        error: CommandError,
270        #[serde(default)]
271        evidence: Vec<Evidence>,
272    },
273}
274
275/// Upper bound on [`Evidence::Wait`]'s `observed` value, in characters.
276///
277/// A verification value -- a matched label, a URL, a ready state -- not page
278/// text. Truncation is on a character boundary; a byte index can land inside a
279/// multi-byte codepoint and panic, which is exactly how extraction used to die
280/// on non-ASCII pages.
281pub const MAX_WAIT_OBSERVED_CHARS: usize = 512;
282
283#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285#[serde(rename_all = "camelCase")]
286pub enum SubmitSettlementOutcome {
287    Settled,
288    ValidationRejected,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
292#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
293#[serde(
294    tag = "kind",
295    rename_all = "camelCase",
296    rename_all_fields = "camelCase"
297)]
298pub enum Evidence {
299    ExecutionPath {
300        path: ExecutionPath,
301        reason: ExecutionReason,
302        state_version: u64,
303        elapsed_ms: u64,
304        bytes: Option<u64>,
305        sha256: Option<String>,
306        #[serde(default, skip_serializing_if = "Option::is_none")]
307        final_url: Option<String>,
308        #[serde(default, skip_serializing_if = "Option::is_none")]
309        content_type: Option<String>,
310        #[serde(default, skip_serializing_if = "Option::is_none")]
311        status: Option<u16>,
312        #[serde(default, skip_serializing_if = "Vec::is_empty")]
313        redirect_chain: Vec<String>,
314    },
315    Navigation {
316        url: String,
317        title: String,
318    },
319    Inspection {
320        selector: Option<String>,
321        url: String,
322        title: String,
323        text: String,
324        html: Option<String>,
325    },
326    /// Result of a boundary submit that used `networkQuiet` as its
327    /// postcondition. This classifies the settled page without requiring the
328    /// caller to predict confirmation copy or risk a second submit.
329    SubmitSettlement {
330        outcome: SubmitSettlementOutcome,
331    },
332    Element {
333        selector: String,
334        text: Option<String>,
335    },
336    Upload {
337        selector: String,
338        paths: Vec<String>,
339    },
340    Page {
341        page_id: PageId,
342        url: String,
343        title: String,
344    },
345    /// Retained page-context generation after command bookkeeping.
346    PageGeneration {
347        page_id: PageId,
348        generation: u64,
349    },
350    Pages {
351        pages: Vec<PageEvidence>,
352    },
353    Popup {
354        opener_page_id: PageId,
355        page_id: PageId,
356        url: String,
357        title: String,
358    },
359    /// A handle-resolved call found its bound page no longer open (a popup
360    /// the handle was following, since closed) and fell back to the opener
361    /// the earlier `click_and_wait_for_popup` recorded. `popup_page_id` is
362    /// the page that is now gone; `opener_page_id` is the page the handle is
363    /// bound to going forward. Carried on a completed read-only retry, or
364    /// on the original `failed` outcome when the call was mutating.
365    PopupClosed {
366        popup_page_id: PageId,
367        opener_page_id: PageId,
368    },
369    Download {
370        filename: String,
371        path: String,
372        bytes: u64,
373        sha256: String,
374        /// The exact caller-supplied destination after it passed the configured
375        /// downloads-root policy. Absolute input is echoed as absolute; relative
376        /// input remains relative. The runtime never discovers or exposes a path
377        /// the caller did not already provide.
378        #[serde(default, skip_serializing_if = "Option::is_none")]
379        saved_to: Option<String>,
380    },
381    Configuration {
382        name: String,
383        value: String,
384    },
385    Resolution {
386        target: Box<crate::TargetSpec>,
387        fingerprint: Box<TargetFingerprint>,
388        candidates: Vec<CandidateEvidence>,
389        best_match_authorized: bool,
390    },
391    Wait {
392        condition: crate::WaitCondition,
393        elapsed_ms: u64,
394        observations: u64,
395        #[serde(default, skip_serializing_if = "Vec::is_empty")]
396        excluded_classes: Vec<String>,
397        /// What the satisfying poll actually read: the element text or value,
398        /// the URL, or the document ready state, depending on the condition.
399        ///
400        /// The wait already reads this to decide whether it is satisfied. It
401        /// used to be discarded, so an agent that verified a submit had to
402        /// spend a second round trip snapshotting the page to learn what it
403        /// had just confirmed. Bounded by [`MAX_WAIT_OBSERVED_CHARS`] -- this
404        /// is a verification value, never a page-text dump.
405        #[serde(default, skip_serializing_if = "Option::is_none")]
406        observed: Option<String>,
407    },
408    Screenshot {
409        artifact_id: String,
410        media_type: String,
411        width: u32,
412        height: u32,
413        bytes: u64,
414        sha256: String,
415    },
416    BrowserExecution {
417        engine: String,
418        browser_version: String,
419        profile_id: String,
420        interaction_path: String,
421    },
422    JavaScriptResult {
423        value: serde_json::Value,
424        truncated: bool,
425    },
426    AccessibilitySnapshot {
427        page_id: PageId,
428        nodes: Vec<AccessibilityNode>,
429        truncated: bool,
430    },
431    FormSnapshot {
432        snapshot: crate::FormSnapshot,
433    },
434    FormValidation {
435        issues: Vec<crate::FormValidationIssue>,
436    },
437    ControlAction {
438        action: ControlActionEvidence,
439    },
440    StructuredExtraction {
441        page_id: PageId,
442        value: serde_json::Value,
443        truncated: bool,
444    },
445    /// A challenge classification from `detectChallenge`: `Some` is a
446    /// detected challenge, `None` is a provably clean page. `confidence` is
447    /// the model's confidence in its answer either way — a caller gating a
448    /// solve spend on a clean answer needs it. `prior_kind` records the site
449    /// prior that enriched the prompt when one existed — transparency, never
450    /// a blended answer.
451    ChallengeDetection {
452        confidence: f32,
453        detection: Option<crate::challenges::ChallengeDetection>,
454        #[serde(default, skip_serializing_if = "Option::is_none")]
455        prior_kind: Option<String>,
456    },
457    CookieState {
458        page_id: Option<PageId>,
459        cookies: Vec<crate::CookieRecord>,
460    },
461    PdfArtifact {
462        artifact_id: String,
463        media_type: String,
464        bytes: u64,
465        sha256: String,
466    },
467    Dialog {
468        dialog_type: String,
469        message: String,
470        action: String,
471    },
472    Emulation {
473        viewport: Option<crate::ViewportSize>,
474        geolocation: Option<crate::GeolocationCoordinates>,
475    },
476    HarArtifact {
477        artifact_id: String,
478        media_type: String,
479        bytes: u64,
480        sha256: String,
481        entries: u32,
482    },
483    IntentExecution {
484        record: ExecutionRecord,
485    },
486    /// Input timing the runtime synthesized rather than observed, emitted when
487    /// the session opted into `executionPolicy.humanize`. Lets `intent-engine`
488    /// verify an effect against the timing actually issued. Carries no typed
489    /// text: action counts and durations only.
490    Humanization {
491        engine: String,
492        actions: u32,
493        synthesized_ms: u64,
494    },
495    /// Result of resolving one named field of an `ExtractIntent`, emitted once
496    /// per field in field order. `value: None` means unresolved, with the
497    /// reason in `errorCode`; the rest of the extraction still runs.
498    Extraction {
499        field: String,
500        #[serde(default, skip_serializing_if = "Option::is_none")]
501        value: Option<String>,
502        resolution_path: IntentResolutionPath,
503        #[serde(default, skip_serializing_if = "Option::is_none")]
504        error_code: Option<ErrorCode>,
505    },
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
510#[serde(rename_all = "camelCase")]
511pub enum IntentResolutionPath {
512    Deterministic,
513    VisionFallback,
514    /// Resolved from a cached proactive vision proposal.
515    VisionPrefill,
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
519#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
520#[serde(rename_all = "camelCase")]
521pub struct ExecutionRecord {
522    pub intent_kind: String,
523    pub purpose: Option<String>,
524    pub resolution_path: IntentResolutionPath,
525    pub plan_summary: String,
526    pub candidates: Vec<CandidateEvidence>,
527    pub wait_elapsed_ms: Option<u64>,
528    pub verification: String,
529    pub artifact_ids: Vec<String>,
530    pub vision_proposal_sha256: Option<String>,
531}
532
533impl Evidence {
534    pub fn journal_safe(&self) -> Self {
535        fn safe_url(value: &str) -> String {
536            let Ok(mut url) = url::Url::parse(value) else {
537                return "[redacted-invalid-url]".into();
538            };
539            let _ = url.set_username("");
540            let _ = url.set_password(None);
541            url.set_query(None);
542            url.set_fragment(None);
543            url.to_string()
544        }
545        let mut safe = self.clone();
546        match &mut safe {
547            Self::ExecutionPath {
548                final_url,
549                redirect_chain,
550                ..
551            } => {
552                if let Some(url) = final_url {
553                    *url = safe_url(url);
554                }
555                for url in redirect_chain {
556                    *url = safe_url(url);
557                }
558            }
559            Self::Navigation { url, .. }
560            | Self::Inspection { url, .. }
561            | Self::Page { url, .. }
562            | Self::Popup { url, .. } => *url = safe_url(url),
563            Self::Pages { pages } => {
564                for page in pages {
565                    page.url = safe_url(&page.url);
566                }
567            }
568            Self::Upload { paths, .. } => {
569                for (index, path) in paths.iter_mut().enumerate() {
570                    *path = format!("upload://evidence/{index}");
571                }
572            }
573            Self::Download {
574                path,
575                sha256,
576                saved_to,
577                ..
578            } => {
579                *path = format!("artifact://sha256/{sha256}");
580                *saved_to = None;
581            }
582            Self::PopupClosed { .. } => {}
583            Self::BrowserExecution { .. } => {}
584            Self::IntentExecution { .. } => {}
585            Self::Extraction { .. } => {}
586            _ => {}
587        }
588        safe
589    }
590}
591
592impl CommandOutcome {
593    pub fn journal_safe(&self) -> Self {
594        let mut safe = self.clone();
595        match &mut safe {
596            Self::Completed { evidence, .. }
597            | Self::NeedsReconciliation { evidence, .. }
598            | Self::Restarted { evidence, .. }
599            | Self::Failed { evidence, .. } => {
600                *evidence = evidence.iter().map(Evidence::journal_safe).collect();
601            }
602            _ => {}
603        }
604        match &mut safe {
605            Self::RetryableFailure { error, .. }
606            | Self::NeedsReconciliation { error, .. }
607            | Self::PolicyDenied { error, .. }
608            | Self::ResourceExhausted { error, .. }
609            | Self::Failed { error, .. } => error.message = "redacted durable diagnostic".into(),
610            _ => {}
611        }
612        safe
613    }
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
618#[serde(rename_all = "camelCase")]
619/// Which strategy served a command: the direct-HTTP reader, or the live
620/// browser. The browser variants name the *strategy*, never an engine — a
621/// Firefox-companion run reports `browser`, and reporting `chromium` there
622/// contradicted the `browserExecution` evidence sitting beside it. The old
623/// wire names still deserialize so recorded journals keep replaying.
624pub enum ExecutionPath {
625    DirectHttp,
626    #[serde(alias = "chromium")]
627    Browser,
628    #[serde(alias = "chromiumFallback")]
629    BrowserFallback,
630}
631
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
633#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
634#[serde(rename_all = "camelCase")]
635pub enum ExecutionReason {
636    EligibleStaticDocument,
637    EligibleExplicitDownload,
638    IneligibleCommand,
639    SemanticTargetRequired,
640    JavascriptRequired,
641    UnsupportedContentType,
642    StateConflict,
643    PolicyRequired,
644    /// The page has mutated since load (a non-read-only command ran against
645    /// it), so a whole-page read must come from the live DOM, not a refetch.
646    PageMutated,
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
651#[serde(rename_all = "camelCase")]
652pub struct CandidateEvidence {
653    pub role: Option<String>,
654    pub name: Option<String>,
655    pub score: i32,
656    pub reasons: Vec<String>,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
660#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
661#[serde(rename_all = "camelCase")]
662pub struct TargetFingerprint {
663    pub page_id: PageId,
664    pub frame: Option<String>,
665    pub role: Option<String>,
666    pub name: Option<String>,
667    pub stable_attributes: std::collections::BTreeMap<String, String>,
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
671#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
672#[serde(rename_all = "camelCase")]
673pub struct PageEvidence {
674    pub page_id: PageId,
675    pub url: String,
676    pub title: String,
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
680#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
681#[serde(rename_all = "camelCase")]
682pub struct CommandError {
683    pub code: ErrorCode,
684    pub message: String,
685    pub layer: ErrorLayer,
686    pub retryable: bool,
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
690#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
691#[serde(rename_all = "camelCase")]
692pub enum ErrorCode {
693    InvalidRequest,
694    NotFound,
695    DeadlineExceeded,
696    BrowserLaunchFailed,
697    BrowserCommandFailed,
698    VerificationFailed,
699    JournalFailed,
700    ResourceExhausted,
701    PolicyDenied,
702    Internal,
703    TargetNotFound,
704    TargetAmbiguous,
705    FrameNotFound,
706    ShadowRootUnavailable,
707    TargetDetached,
708    TargetObscured,
709    TargetOutOfBounds,
710    WaitConditionTimedOut,
711    ScreenshotCaptureFailed,
712    NetworkPolicyDenied,
713    HttpResponseTooLarge,
714    HttpTransferFailed,
715    HttpStateConflict,
716    HttpEquivalenceUnproven,
717    IntentCompileFailed,
718    IntentActionMismatch,
719    ObstructionSuspected,
720    VisionAssistDenied,
721    VisionAssistFailed,
722    // `submitAndVerify`'s expected state already held before the submit ran,
723    // so a post-act pass would prove nothing (static-copy matcher).
724    ExpectedStatePreSatisfied,
725    // A Boundary submit already completed within this workflow; running
726    // another would double-apply the effect unless the caller explicitly
727    // acknowledges the prior one (`reSubmit`).
728    BoundaryAlreadyExecuted,
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
733#[serde(rename_all = "camelCase")]
734pub enum ErrorLayer {
735    Interface,
736    Broker,
737    Workflow,
738    Page,
739    Driver,
740    Browser,
741    Network,
742    Site,
743    Journal,
744}
745
746#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
747#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
748#[serde(rename_all = "camelCase")]
749pub enum CommandPhase {
750    Accepted,
751    Prepared,
752    Executing,
753    ResultPrepared,
754    Verifying,
755    Recovering,
756    Completed,
757    Failed,
758}
759
760#[derive(Debug, Error, Serialize, Deserialize)]
761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
762pub enum RuntimeError {
763    #[error("not found: {0}")]
764    NotFound(String),
765    #[error("invalid request: {0}")]
766    InvalidRequest(String),
767    /// The configured browser engine could not be reached or started. An
768    /// environment fault, not a bad call: the caller's arguments were fine and
769    /// resubmitting them unchanged is the right move once the engine is up.
770    #[error("{0}")]
771    EngineUnreachable(String),
772    #[error("internal error: {0}")]
773    Internal(String),
774}