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