@effect-agent/platform-cloudflare 0.1.0-beta.110 → 0.1.0-beta.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,13 +15,13 @@ import {
15
15
  Effect,
16
16
  Layer,
17
17
  Redacted,
18
- Ref,
19
18
  Schema,
20
19
  Semaphore,
21
20
  Scope,
22
21
  } from "effect";
23
22
  import {
24
23
  BrowserActionResult,
24
+ BrowserExpectedTargetState,
25
25
  BrowserSelectFileRequest,
26
26
  BrowserFileSelectionResult,
27
27
  BrowserScreenshotRequest,
@@ -40,7 +40,9 @@ import {
40
40
  InteractiveBrowserTargetUrl,
41
41
  InteractiveBrowserUnsupportedError,
42
42
  type BrowserHandle,
43
+ type BrowserExpectedTarget,
43
44
  type InteractiveBrowserError,
45
+ type InteractiveBrowserFailureEvidence,
44
46
  type InteractiveBrowserNetworkPolicy,
45
47
  } from "effect-agent/interactive-browser";
46
48
  import { PageScreenshotResult } from "effect-agent/page-screenshot";
@@ -126,24 +128,21 @@ const ActionObservation = Schema.Struct({
126
128
  after: Schema.optionalKey(ActionTargetState),
127
129
  afterUnavailable: Schema.Boolean,
128
130
  network: ActionNetworkState,
131
+ inputMillis: Schema.optionalKey(Schema.Finite),
132
+ observationMillis: Schema.optionalKey(Schema.Finite),
129
133
  });
130
134
 
131
- const PageObservation = Schema.fromJsonString(
135
+ export const BrowserRunPageObservation = Schema.fromJsonString(
132
136
  Schema.Struct({
137
+ documentId: Schema.NonEmptyString,
133
138
  pageText: BoundedRemoteText,
134
139
  selectorMatchCount: Schema.Natural,
135
140
  controlsTruncated: Schema.Boolean,
136
141
  controls: Schema.Array(
137
142
  Schema.Struct({
143
+ nodeId: Schema.NonEmptyString,
138
144
  selector: BoundedRemoteText,
139
- kind: BoundedRemoteText,
140
- label: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200))),
141
- checked: Schema.optionalKey(Schema.Boolean),
142
- selected: Schema.optionalKey(Schema.Boolean),
143
- disabled: Schema.optionalKey(Schema.Boolean),
144
- required: Schema.optionalKey(Schema.Boolean),
145
- valid: Schema.optionalKey(Schema.Boolean),
146
- formValid: Schema.optionalKey(Schema.Boolean),
145
+ ...BrowserExpectedTargetState.fields,
147
146
  }),
148
147
  ).check(Schema.isMaxLength(MAX_OBSERVED_CONTROLS)),
149
148
  }),
@@ -319,6 +318,7 @@ export interface BrowserRunInteractiveCdpSession {
319
318
 
320
319
  /** Narrow page boundary used by deterministic tests; SDK values remain in this package. */
321
320
  export interface BrowserRunInteractivePage {
321
+ readonly identity?: () => Promise<BrowserRunPageIdentity>;
322
322
  readonly close: () => Promise<void>;
323
323
  readonly setBypassServiceWorker: (enabled: boolean) => Promise<void>;
324
324
  readonly setRequestInterception: (enabled: boolean) => Promise<void>;
@@ -332,16 +332,21 @@ export interface BrowserRunInteractivePage {
332
332
  value: string,
333
333
  signal: AbortSignal,
334
334
  onDispatch: () => void,
335
+ onComplete?: () => void,
336
+ expectedTarget?: BrowserExpectedTarget,
335
337
  ) => Promise<unknown>;
336
338
  readonly click: (
337
339
  selector: string,
338
340
  signal: AbortSignal,
339
341
  onDispatch: () => void,
342
+ onComplete?: () => void,
343
+ expectedTarget?: BrowserExpectedTarget,
340
344
  ) => Promise<unknown>;
341
345
  readonly selectFile: (
342
346
  request: BrowserSelectFileRequest,
343
347
  signal: AbortSignal,
344
348
  onDispatch: () => void,
349
+ onComplete?: () => void,
345
350
  ) => Promise<unknown>;
346
351
  readonly screenshot: (fullPage: boolean) => Promise<unknown>;
347
352
  readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
@@ -355,6 +360,13 @@ export interface BrowserRunInteractiveContext {
355
360
  }
356
361
 
357
362
  export interface BrowserRunInteractiveBrowser {
363
+ readonly detach?: () => Promise<void>;
364
+ readonly reattach?: (
365
+ identity: BrowserRunPageIdentity,
366
+ ) => Promise<
367
+ | { readonly context: BrowserRunInteractiveContext; readonly page: BrowserRunInteractivePage }
368
+ | undefined
369
+ >;
358
370
  readonly createContext: () => Promise<BrowserRunInteractiveContext>;
359
371
  readonly close: () => Promise<void>;
360
372
  readonly sessionId: () => unknown;
@@ -409,13 +421,43 @@ export class BrowserRunInteractiveBinding extends Context.Service<
409
421
  }
410
422
  }
411
423
 
424
+ /** Private target identity; never include this checkpoint in model-visible records. */
425
+ export class BrowserRunPageIdentity extends Schema.Class<BrowserRunPageIdentity>(
426
+ "BrowserRunPageIdentity",
427
+ )({
428
+ contextId: BrowserRunSessionId,
429
+ targetId: BrowserRunSessionId,
430
+ }) {}
431
+
432
+ /** Host-owned recovery data. Persist with the application's input receipts and ownership fence. */
433
+ export class BrowserRunInteractiveCheckpoint extends Schema.Class<BrowserRunInteractiveCheckpoint>(
434
+ "BrowserRunInteractiveCheckpoint",
435
+ )({
436
+ sessionId: Schema.RedactedFromValue(BrowserRunSessionId),
437
+ page: BrowserRunPageIdentity,
438
+ policy: InteractiveBrowserPolicy,
439
+ startedAt: Schema.Natural,
440
+ consumedActions: Schema.Natural.check(Schema.isLessThanOrEqualTo(1_000)),
441
+ inputState: Schema.Literals(["idle", "running", "unknown"]),
442
+ }) {}
443
+
444
+ export type BrowserRunInputState = "idle" | "running" | "unknown";
445
+
412
446
  export interface BrowserRunInteractiveSession {
413
447
  readonly handle: BrowserHandle;
448
+ readonly checkpoint: Effect.Effect<BrowserRunInteractiveCheckpoint, InteractiveBrowserError>;
449
+ /** Invalidate this owner and disconnect locally, retaining the exact remote page until its deadline.
450
+ * Checkpoint and durable input receipts must be persisted first. Never grants another owner authority.
451
+ */
452
+ readonly detach: Effect.Effect<void, InteractiveBrowserError>;
453
+ readonly inputState: Effect.Effect<BrowserRunInputState>;
454
+ /** Wait at most 500 ms for local SDK input. A restart/transport-unknown fence cannot be drained. */
455
+ readonly drainInput: Effect.Effect<BrowserRunInputState>;
414
456
  readonly sessionId: Redacted.Redacted<string>;
415
457
  /**
416
458
  * Resize without charging an agent action or changing emulation modes. Shares
417
459
  * the handle's fail-fast lock, page-policy preflight, and elapsed deadline.
418
- * A timeout or interruption leaves the session unusable; no resize is retried.
460
+ * An interrupted resize fences further input until the SDK settles; no resize is retried.
419
461
  * The host must authorize callers. No viewer ownership or durable state is added.
420
462
  */
421
463
  readonly resizeViewport: (
@@ -449,6 +491,14 @@ export class BrowserRunInteractiveHost extends Context.Service<
449
491
  readonly open: (
450
492
  policy: InteractiveBrowserPolicy,
451
493
  ) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
494
+ /** Requires exclusive host ownership. Never allocates a replacement page or replays an action.
495
+ * pendingInput must include durable receipts written after the checkpoint. Unknown input keeps
496
+ * mutations and human control fenced; reads remain available. Only confirmed close ends that fence.
497
+ */
498
+ readonly resume: (
499
+ checkpoint: BrowserRunInteractiveCheckpoint,
500
+ options: { readonly pendingInput: boolean },
501
+ ) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
452
502
  readonly closeSession: (
453
503
  sessionId: Redacted.Redacted<string>,
454
504
  ) => Effect.Effect<void, InteractiveBrowserError>;
@@ -719,6 +769,366 @@ const disposeActionHandles = async (handles: ReadonlyArray<{ dispose: () => Prom
719
769
  );
720
770
  };
721
771
 
772
+ /** Runs entirely in the page realm for both observations and the last bound-element guard. */
773
+ const observePage = (
774
+ target: object | undefined,
775
+ requestedSelector: string | undefined,
776
+ maximum: number,
777
+ maximumControls: number,
778
+ input?: {
779
+ readonly mode: "click" | "fill";
780
+ readonly expectedTarget: BrowserExpectedTarget;
781
+ readonly value?: string;
782
+ },
783
+ ) => {
784
+ const pageDocument = Reflect.get(globalThis, "document");
785
+
786
+ const matches =
787
+ requestedSelector === undefined
788
+ ? undefined
789
+ : Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
790
+ requestedSelector,
791
+ ]);
792
+
793
+ const selectorMatchCount =
794
+ matches === undefined || matches === null
795
+ ? 1
796
+ : Math.min(10_000, Reflect.get(matches, "length"));
797
+
798
+ const element =
799
+ target ??
800
+ (matches === undefined || matches === null
801
+ ? Reflect.get(pageDocument, "body")
802
+ : Reflect.get(matches, 0));
803
+
804
+ if (target !== undefined && Reflect.get(target, "isConnected") !== true)
805
+ return { _tag: "MissingElement" };
806
+
807
+ if (element === null) return { _tag: "MissingElement" };
808
+ if (element === undefined) return { _tag: "MissingElement" };
809
+ const innerText = Reflect.get(element, "innerText");
810
+ const textContent = Reflect.get(element, "textContent");
811
+
812
+ const pageText =
813
+ typeof innerText === "string" ? innerText : typeof textContent === "string" ? textContent : "";
814
+
815
+ const primaryControlSelector =
816
+ 'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
817
+
818
+ const optionSelector = "select option";
819
+ const secondaryControlSelector = "a[href]";
820
+ const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
821
+
822
+ const selectorFor = (candidate: object) => {
823
+ const parts: Array<string> = [];
824
+ let current: object | null = candidate;
825
+
826
+ while (current !== null && current !== undefined) {
827
+ const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
828
+
829
+ if (tagName === "") break;
830
+ const parent: object | null = Reflect.get(current, "parentElement");
831
+
832
+ if (parent === null) {
833
+ parts.push(tagName);
834
+ break;
835
+ }
836
+ let sibling = Reflect.get(current, "previousElementSibling");
837
+ let index = 1;
838
+
839
+ while (sibling !== null && sibling !== undefined) {
840
+ if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName) index++;
841
+ sibling = Reflect.get(sibling, "previousElementSibling");
842
+ }
843
+ parts.push(`${tagName}:nth-of-type(${index})`);
844
+ current = parent;
845
+ }
846
+
847
+ return parts.reverse().join(" > ");
848
+ };
849
+
850
+ const visible = (candidate: object) => {
851
+ const hidden = Reflect.get(candidate, "hidden");
852
+
853
+ const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
854
+ "aria-hidden",
855
+ ]);
856
+
857
+ const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
858
+
859
+ return (
860
+ hidden !== true &&
861
+ ariaHidden !== "true" &&
862
+ typeof rects === "object" &&
863
+ rects !== null &&
864
+ Reflect.get(rects, "length") > 0
865
+ );
866
+ };
867
+
868
+ const candidates: Array<object> = [];
869
+ let controlsTruncated = false;
870
+
871
+ const consider = (candidate: object) => {
872
+ const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
873
+
874
+ // Collapsed native options have no client rects. Their owning
875
+ // select determines visibility; observe text/selection, never value.
876
+ const visibilityTarget =
877
+ tagName === "option"
878
+ ? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
879
+ : candidate;
880
+
881
+ const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
882
+
883
+ if (
884
+ !actionable ||
885
+ typeof visibilityTarget !== "object" ||
886
+ visibilityTarget === null ||
887
+ !visible(visibilityTarget)
888
+ )
889
+ return;
890
+ candidates.push(candidate);
891
+ if (candidates.length > maximumControls) controlsTruncated = true;
892
+ };
893
+
894
+ const elementMatches = Reflect.get(element, "matches");
895
+
896
+ if (
897
+ typeof elementMatches === "function" &&
898
+ Reflect.apply(elementMatches, element, [controlSelector])
899
+ ) {
900
+ consider(element);
901
+ }
902
+
903
+ const considerSelector = (candidateSelector: string) => {
904
+ const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
905
+ candidateSelector,
906
+ ]);
907
+
908
+ if (typeof descendants !== "object" || descendants === null) return;
909
+ const descendantCount = Reflect.get(descendants, "length");
910
+
911
+ for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
912
+ consider(Reflect.get(descendants, index));
913
+ }
914
+ };
915
+
916
+ considerSelector(primaryControlSelector);
917
+ if (!controlsTruncated) considerSelector(optionSelector);
918
+ if (!controlsTruncated) considerSelector(secondaryControlSelector);
919
+
920
+ const identityKey = Symbol.for("@effect-agent/browser-observation");
921
+ let identity = Reflect.get(globalThis, identityKey);
922
+
923
+ if (identity === undefined || identity.document !== pageDocument) {
924
+ identity = {
925
+ document: pageDocument,
926
+ documentId: crypto.getRandomValues(new Uint32Array(4)).join("-"),
927
+ nodes: new WeakMap<object, string>(),
928
+ };
929
+ Reflect.set(globalThis, identityKey, identity);
930
+ }
931
+
932
+ const controls = candidates.slice(0, maximumControls).map((candidate) => {
933
+ let nodeId = identity.nodes.get(candidate);
934
+
935
+ if (nodeId === undefined) {
936
+ nodeId = crypto.getRandomValues(new Uint32Array(4)).join("-");
937
+ identity.nodes.set(candidate, nodeId);
938
+ }
939
+ const associated = Reflect.get(candidate, "control") ?? candidate;
940
+ const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
941
+ const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
942
+
943
+ const role = String(
944
+ Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
945
+ ).toLowerCase();
946
+
947
+ const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
948
+ "aria-label",
949
+ ]);
950
+
951
+ const candidateText =
952
+ tagName === "textarea" || tagName === "input" || tagName === "select"
953
+ ? undefined
954
+ : Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
955
+
956
+ const associatedLabels = Reflect.get(associated, "labels");
957
+
958
+ const associatedLabel =
959
+ associatedLabels !== undefined &&
960
+ associatedLabels !== null &&
961
+ Reflect.get(associatedLabels, "length") > 0
962
+ ? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
963
+ : undefined;
964
+
965
+ const label = String(
966
+ typeof ariaLabel === "string" && ariaLabel !== ""
967
+ ? ariaLabel
968
+ : typeof candidateText === "string" && candidateText !== ""
969
+ ? candidateText
970
+ : (associatedLabel ?? ""),
971
+ )
972
+ .replace(/\s+/g, " ")
973
+ .trim()
974
+ .slice(0, 200);
975
+
976
+ const checked = Reflect.get(associated, "checked");
977
+
978
+ const selected =
979
+ tagName === "select"
980
+ ? Reflect.get(associated, "selectedIndex") >= 0
981
+ : Reflect.get(associated, "selected");
982
+
983
+ const disabled = Reflect.get(associated, "disabled");
984
+ const required = Reflect.get(associated, "required");
985
+ const validity = Reflect.get(associated, "validity");
986
+ const form = Reflect.get(associated, "form");
987
+
988
+ const formMatches =
989
+ form === null || form === undefined ? undefined : Reflect.get(form, "matches");
990
+
991
+ const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
992
+ "aria-checked",
993
+ ]);
994
+
995
+ const ariaSelected = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
996
+ "aria-selected",
997
+ ]);
998
+
999
+ const ariaDisabled = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
1000
+ "aria-disabled",
1001
+ ]);
1002
+
1003
+ return {
1004
+ nodeId,
1005
+ ...(inputType === "" ? {} : { inputType: inputType.slice(0, 64) }),
1006
+ selector: selectorFor(candidate),
1007
+ kind: (tagName === "label"
1008
+ ? `label:${inputType || "control"}`
1009
+ : tagName === "input"
1010
+ ? `input:${inputType || "text"}`
1011
+ : role !== ""
1012
+ ? `role:${role}`
1013
+ : tagName
1014
+ ).slice(0, 128),
1015
+ ...(label === "" ? {} : { label }),
1016
+ ...(typeof checked === "boolean"
1017
+ ? { checked }
1018
+ : ariaChecked === "true" || ariaChecked === "false"
1019
+ ? { checked: ariaChecked === "true" }
1020
+ : {}),
1021
+ ...(typeof selected === "boolean"
1022
+ ? { selected }
1023
+ : ariaSelected === "true" || ariaSelected === "false"
1024
+ ? { selected: ariaSelected === "true" }
1025
+ : {}),
1026
+ ...(typeof disabled === "boolean"
1027
+ ? { disabled }
1028
+ : ariaDisabled === "true" || ariaDisabled === "false"
1029
+ ? { disabled: ariaDisabled === "true" }
1030
+ : {}),
1031
+ ...(typeof required === "boolean" ? { required } : {}),
1032
+ ...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
1033
+ ? { valid: Reflect.get(validity, "valid") }
1034
+ : {}),
1035
+ ...(typeof formMatches === "function"
1036
+ ? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
1037
+ : {}),
1038
+ };
1039
+ });
1040
+
1041
+ if (input !== undefined) {
1042
+ const expected = input.expectedTarget;
1043
+ const control = controls.find((candidate) => candidate.nodeId === expected.nodeId);
1044
+
1045
+ const fields = [
1046
+ "kind",
1047
+ "inputType",
1048
+ "label",
1049
+ "checked",
1050
+ "selected",
1051
+ "disabled",
1052
+ "required",
1053
+ "valid",
1054
+ "formValid",
1055
+ ] as const;
1056
+
1057
+ if (
1058
+ target === undefined ||
1059
+ identity.documentId !== expected.documentId ||
1060
+ identity.nodes.get(target) !== expected.nodeId ||
1061
+ control === undefined ||
1062
+ (expected.state !== undefined &&
1063
+ fields.some((field) => control[field] !== expected.state?.[field])) ||
1064
+ Reflect.get(target, "isConnected") !== true
1065
+ )
1066
+ return { _tag: "MissingElement" };
1067
+ if (expected.scopeSelector !== undefined) {
1068
+ let roots;
1069
+
1070
+ try {
1071
+ roots = Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
1072
+ expected.scopeSelector,
1073
+ ]);
1074
+ } catch {
1075
+ return { _tag: "MissingElement" };
1076
+ }
1077
+ if (typeof roots !== "object" || roots === null) return { _tag: "MissingElement" };
1078
+ const root = Reflect.get(roots, 0);
1079
+
1080
+ if (
1081
+ Reflect.get(roots, "length") !== 1 ||
1082
+ root === undefined ||
1083
+ !Reflect.apply(Reflect.get(root, "contains"), root, [target])
1084
+ )
1085
+ return { _tag: "MissingElement" };
1086
+ }
1087
+ // Validation and dispatch share one page JS task. No coordinate lookup, selector
1088
+ // re-resolution, or asynchronous boundary can select a replacement node.
1089
+ if (input.mode === "click") {
1090
+ const prototype = Reflect.get(Reflect.get(globalThis, "HTMLElement"), "prototype");
1091
+
1092
+ Reflect.apply(Reflect.get(prototype, "click"), target, []);
1093
+ } else {
1094
+ let prototype = Reflect.getPrototypeOf(target);
1095
+ let setValue: ((value: string) => void) | undefined;
1096
+
1097
+ while (prototype !== null) {
1098
+ const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
1099
+
1100
+ if (typeof setter === "function") {
1101
+ setValue = setter;
1102
+ break;
1103
+ }
1104
+ prototype = Reflect.getPrototypeOf(prototype);
1105
+ }
1106
+ if (setValue === undefined || input.value === undefined) return { _tag: "MissingElement" };
1107
+ Reflect.apply(setValue, target, [input.value]);
1108
+ const dispatchEvent = Reflect.get(target, "dispatchEvent");
1109
+
1110
+ Reflect.apply(dispatchEvent, target, [new Event("input", { bubbles: true })]);
1111
+ Reflect.apply(dispatchEvent, target, [new Event("change", { bubbles: true })]);
1112
+ }
1113
+
1114
+ return { _tag: "Text", text: "" };
1115
+ }
1116
+
1117
+ // JSON is the bounded wire representation of this existing text result.
1118
+ // eslint-disable-next-line no-restricted-properties
1119
+ const text = JSON.stringify({
1120
+ documentId: identity.documentId,
1121
+ pageText,
1122
+ selectorMatchCount,
1123
+ controls,
1124
+ controlsTruncated,
1125
+ });
1126
+
1127
+ const observed = new TextEncoder().encode(text).byteLength;
1128
+
1129
+ return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
1130
+ };
1131
+
722
1132
  const runObservedPageAction = async (
723
1133
  page: Page,
724
1134
  selector: string,
@@ -726,6 +1136,7 @@ const runObservedPageAction = async (
726
1136
  onDispatch: () => void,
727
1137
  action: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<void>,
728
1138
  validate?: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<boolean>,
1139
+ onComplete?: () => void,
729
1140
  ): Promise<unknown> => {
730
1141
  if (signal.aborted) throw new BrowserRunActionUndispatched(0);
731
1142
 
@@ -764,13 +1175,20 @@ const runObservedPageAction = async (
764
1175
  // dispatched, interruption is uncertain, even if the SDK later resolves.
765
1176
  if (signal.aborted) throw new BrowserRunActionUndispatched(1);
766
1177
  onDispatch();
1178
+ const inputStarted = performance.now();
1179
+
767
1180
  await action(matches[0]);
1181
+ const inputFinished = performance.now();
1182
+
1183
+ onComplete?.();
768
1184
  tracker.markActionSettled();
769
1185
  const network = await tracker.wait();
770
1186
  const after = signal.aborted ? undefined : await readActionTargetAfter(page, selector);
771
1187
 
772
1188
  return {
773
1189
  before,
1190
+ inputMillis: Math.max(0, inputFinished - inputStarted),
1191
+ observationMillis: Math.max(0, performance.now() - inputFinished),
774
1192
  ...(after === undefined ? {} : { after }),
775
1193
  afterUnavailable: after === undefined,
776
1194
  network,
@@ -782,11 +1200,42 @@ const runObservedPageAction = async (
782
1200
  }
783
1201
  };
784
1202
 
1203
+ const guardedPageInput = async (
1204
+ element: NonNullable<Awaited<ReturnType<Page["$"]>>>,
1205
+ expectedTarget: BrowserExpectedTarget,
1206
+ mode: "click" | "fill",
1207
+ value?: string,
1208
+ ): Promise<void> => {
1209
+ const observed = Schema.decodeUnknownSync(TextObservation)(
1210
+ await element.evaluate(observePage, undefined, 256 * 1024, 1, {
1211
+ mode,
1212
+ expectedTarget,
1213
+ ...(value === undefined ? {} : { value }),
1214
+ }),
1215
+ );
1216
+
1217
+ if (observed._tag !== "Text") throw new BrowserRunActionUndispatched(1);
1218
+ };
1219
+
785
1220
  const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
786
1221
  const prepareFileSelection = makeFileSelection(page);
787
1222
  const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
788
1223
 
789
1224
  return {
1225
+ identity: async () => {
1226
+ const cdp = await page.createCDPSession();
1227
+
1228
+ try {
1229
+ const { targetInfo } = await cdp.send("Target.getTargetInfo");
1230
+
1231
+ return Schema.decodeUnknownSync(BrowserRunPageIdentity)({
1232
+ contextId: targetInfo.browserContextId,
1233
+ targetId: targetInfo.targetId,
1234
+ });
1235
+ } finally {
1236
+ await cdp.detach();
1237
+ }
1238
+ },
790
1239
  close: async () => {
791
1240
  if (!page.browser().isConnected()) return;
792
1241
  try {
@@ -817,307 +1266,68 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
817
1266
  url: () => page.url(),
818
1267
  readText: async (selector, maximumBytes) => {
819
1268
  const observation = Schema.decodeUnknownSync(TextObservation)(
820
- await page.evaluate(
821
- (requestedSelector, maximum, maximumControls) => {
822
- const pageDocument = Reflect.get(globalThis, "document");
823
-
824
- const matches =
825
- requestedSelector === undefined
826
- ? undefined
827
- : Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
828
- requestedSelector,
829
- ]);
830
-
831
- const selectorMatchCount =
832
- matches === undefined || matches === null
833
- ? 1
834
- : Math.min(10_000, Reflect.get(matches, "length"));
835
-
836
- const element =
837
- matches === undefined || matches === null
838
- ? Reflect.get(pageDocument, "body")
839
- : Reflect.get(matches, 0);
840
-
841
- if (element === null) return { _tag: "MissingElement" };
842
- if (element === undefined) return { _tag: "MissingElement" };
843
- const innerText = Reflect.get(element, "innerText");
844
- const textContent = Reflect.get(element, "textContent");
845
-
846
- const pageText =
847
- typeof innerText === "string"
848
- ? innerText
849
- : typeof textContent === "string"
850
- ? textContent
851
- : "";
852
-
853
- const primaryControlSelector =
854
- 'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
855
-
856
- const optionSelector = "select option";
857
- const secondaryControlSelector = "a[href]";
858
- const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
859
-
860
- const selectorFor = (candidate: object) => {
861
- const parts: Array<string> = [];
862
- let current: object | null = candidate;
863
-
864
- while (current !== null && current !== undefined) {
865
- const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
866
-
867
- if (tagName === "") break;
868
- const parent: object | null = Reflect.get(current, "parentElement");
869
-
870
- if (parent === null) {
871
- parts.push(tagName);
872
- break;
873
- }
874
- let sibling = Reflect.get(current, "previousElementSibling");
875
- let index = 1;
876
-
877
- while (sibling !== null && sibling !== undefined) {
878
- if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName)
879
- index++;
880
- sibling = Reflect.get(sibling, "previousElementSibling");
881
- }
882
- parts.push(`${tagName}:nth-of-type(${index})`);
883
- current = parent;
884
- }
885
-
886
- return parts.reverse().join(" > ");
887
- };
888
-
889
- const visible = (candidate: object) => {
890
- const hidden = Reflect.get(candidate, "hidden");
891
-
892
- const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
893
- "aria-hidden",
894
- ]);
895
-
896
- const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
897
-
898
- return (
899
- hidden !== true &&
900
- ariaHidden !== "true" &&
901
- typeof rects === "object" &&
902
- rects !== null &&
903
- Reflect.get(rects, "length") > 0
904
- );
905
- };
906
-
907
- const candidates: Array<object> = [];
908
- let controlsTruncated = false;
909
-
910
- const consider = (candidate: object) => {
911
- const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
912
-
913
- // Collapsed native options have no client rects. Their owning
914
- // select determines visibility; observe text/selection, never value.
915
- const visibilityTarget =
916
- tagName === "option"
917
- ? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
918
- : candidate;
919
-
920
- const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
921
-
922
- if (
923
- !actionable ||
924
- typeof visibilityTarget !== "object" ||
925
- visibilityTarget === null ||
926
- !visible(visibilityTarget)
927
- )
928
- return;
929
- candidates.push(candidate);
930
- if (candidates.length > maximumControls) controlsTruncated = true;
931
- };
932
-
933
- const elementMatches = Reflect.get(element, "matches");
934
-
935
- if (
936
- typeof elementMatches === "function" &&
937
- Reflect.apply(elementMatches, element, [controlSelector])
938
- ) {
939
- consider(element);
940
- }
941
-
942
- const considerSelector = (candidateSelector: string) => {
943
- const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
944
- candidateSelector,
945
- ]);
946
-
947
- if (typeof descendants !== "object" || descendants === null) return;
948
- const descendantCount = Reflect.get(descendants, "length");
949
-
950
- for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
951
- consider(Reflect.get(descendants, index));
952
- }
953
- };
954
-
955
- considerSelector(primaryControlSelector);
956
- if (!controlsTruncated) considerSelector(optionSelector);
957
- if (!controlsTruncated) considerSelector(secondaryControlSelector);
958
-
959
- const controls = candidates.slice(0, maximumControls).map((candidate) => {
960
- const associated = Reflect.get(candidate, "control") ?? candidate;
961
- const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
962
- const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
963
-
964
- const role = String(
965
- Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
966
- ).toLowerCase();
967
-
968
- const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
969
- "aria-label",
970
- ]);
971
-
972
- const candidateText =
973
- tagName === "textarea" || tagName === "input" || tagName === "select"
974
- ? undefined
975
- : Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
976
-
977
- const associatedLabels = Reflect.get(associated, "labels");
978
-
979
- const associatedLabel =
980
- associatedLabels !== undefined &&
981
- associatedLabels !== null &&
982
- Reflect.get(associatedLabels, "length") > 0
983
- ? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
984
- : undefined;
985
-
986
- const label = String(
987
- typeof ariaLabel === "string" && ariaLabel !== ""
988
- ? ariaLabel
989
- : typeof candidateText === "string" && candidateText !== ""
990
- ? candidateText
991
- : (associatedLabel ?? ""),
992
- )
993
- .replace(/\s+/g, " ")
994
- .trim()
995
- .slice(0, 200);
996
-
997
- const checked = Reflect.get(associated, "checked");
998
-
999
- const selected =
1000
- tagName === "select"
1001
- ? Reflect.get(associated, "selectedIndex") >= 0
1002
- : Reflect.get(associated, "selected");
1003
-
1004
- const disabled = Reflect.get(associated, "disabled");
1005
- const required = Reflect.get(associated, "required");
1006
- const validity = Reflect.get(associated, "validity");
1007
- const form = Reflect.get(associated, "form");
1008
-
1009
- const formMatches =
1010
- form === null || form === undefined ? undefined : Reflect.get(form, "matches");
1011
-
1012
- const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
1013
- "aria-checked",
1014
- ]);
1015
-
1016
- const ariaSelected = Reflect.apply(
1017
- Reflect.get(candidate, "getAttribute"),
1018
- candidate,
1019
- ["aria-selected"],
1020
- );
1021
-
1022
- const ariaDisabled = Reflect.apply(
1023
- Reflect.get(candidate, "getAttribute"),
1024
- candidate,
1025
- ["aria-disabled"],
1026
- );
1027
-
1028
- return {
1029
- selector: selectorFor(candidate),
1030
- kind:
1031
- tagName === "label"
1032
- ? `label:${inputType || "control"}`
1033
- : tagName === "input"
1034
- ? `input:${inputType || "text"}`
1035
- : role !== ""
1036
- ? `role:${role}`
1037
- : tagName,
1038
- ...(label === "" ? {} : { label }),
1039
- ...(typeof checked === "boolean"
1040
- ? { checked }
1041
- : ariaChecked === "true" || ariaChecked === "false"
1042
- ? { checked: ariaChecked === "true" }
1043
- : {}),
1044
- ...(typeof selected === "boolean"
1045
- ? { selected }
1046
- : ariaSelected === "true" || ariaSelected === "false"
1047
- ? { selected: ariaSelected === "true" }
1048
- : {}),
1049
- ...(typeof disabled === "boolean"
1050
- ? { disabled }
1051
- : ariaDisabled === "true" || ariaDisabled === "false"
1052
- ? { disabled: ariaDisabled === "true" }
1053
- : {}),
1054
- ...(typeof required === "boolean" ? { required } : {}),
1055
- ...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
1056
- ? { valid: Reflect.get(validity, "valid") }
1057
- : {}),
1058
- ...(typeof formMatches === "function"
1059
- ? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
1060
- : {}),
1061
- };
1062
- });
1063
-
1064
- // JSON is the bounded wire representation of this existing text result.
1065
- // eslint-disable-next-line no-restricted-properties
1066
- const text = JSON.stringify({
1067
- pageText,
1068
- selectorMatchCount,
1069
- controls,
1070
- controlsTruncated,
1071
- });
1072
-
1073
- const observed = new TextEncoder().encode(text).byteLength;
1074
-
1075
- return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
1076
- },
1077
- selector,
1078
- maximumBytes,
1079
- MAX_OBSERVED_CONTROLS,
1080
- ),
1269
+ await page.evaluate(observePage, undefined, selector, maximumBytes, MAX_OBSERVED_CONTROLS),
1081
1270
  );
1082
1271
 
1083
- if (observation._tag === "Text") Schema.decodeSync(PageObservation)(observation.text);
1272
+ if (observation._tag === "Text")
1273
+ Schema.decodeSync(BrowserRunPageObservation)(observation.text);
1084
1274
 
1085
1275
  return observation;
1086
1276
  },
1087
- fill: (selector, value, signal, onDispatch) =>
1088
- runObservedPageAction(page, selector, signal, onDispatch, (element) =>
1089
- element.evaluate((element, nextValue) => {
1090
- // Bypass instance setters so React can detect the change when events fire.
1091
- let prototype = Reflect.getPrototypeOf(element);
1092
- let setValue: ((value: string) => void) | undefined;
1093
-
1094
- while (prototype !== null) {
1095
- const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
1096
-
1097
- if (typeof setter === "function") {
1098
- setValue = setter;
1099
- break;
1100
- }
1101
- prototype = Reflect.getPrototypeOf(prototype);
1102
- }
1103
- if (setValue === undefined) {
1104
- throw new Error("The selector did not resolve to a fillable field");
1105
- }
1106
- const focus = Reflect.get(element, "focus");
1277
+ fill: (selector, value, signal, onDispatch, onComplete, expectedTarget) =>
1278
+ runObservedPageAction(
1279
+ page,
1280
+ selector,
1281
+ signal,
1282
+ onDispatch,
1283
+ (element) =>
1284
+ expectedTarget !== undefined
1285
+ ? guardedPageInput(element, expectedTarget, "fill", value)
1286
+ : element.evaluate((element, nextValue) => {
1287
+ // Bypass instance setters so React can detect the change when events fire.
1288
+ let prototype = Reflect.getPrototypeOf(element);
1289
+ let setValue: ((value: string) => void) | undefined;
1290
+
1291
+ while (prototype !== null) {
1292
+ const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
1293
+
1294
+ if (typeof setter === "function") {
1295
+ setValue = setter;
1296
+ break;
1297
+ }
1298
+ prototype = Reflect.getPrototypeOf(prototype);
1299
+ }
1300
+ if (setValue === undefined) {
1301
+ throw new Error("The selector did not resolve to a fillable field");
1302
+ }
1303
+ const focus = Reflect.get(element, "focus");
1107
1304
 
1108
- if (typeof focus === "function") Reflect.apply(focus, element, []);
1109
- Reflect.apply(setValue, element, [nextValue]);
1110
- const dispatchEvent = Reflect.get(element, "dispatchEvent");
1305
+ if (typeof focus === "function") Reflect.apply(focus, element, []);
1306
+ Reflect.apply(setValue, element, [nextValue]);
1307
+ const dispatchEvent = Reflect.get(element, "dispatchEvent");
1111
1308
 
1112
- if (typeof dispatchEvent === "function") {
1113
- Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
1114
- Reflect.apply(dispatchEvent, element, [new Event("change", { bubbles: true })]);
1115
- }
1116
- }, value),
1309
+ if (typeof dispatchEvent === "function") {
1310
+ Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
1311
+ Reflect.apply(dispatchEvent, element, [new Event("change", { bubbles: true })]);
1312
+ }
1313
+ }, value),
1314
+ undefined,
1315
+ onComplete,
1117
1316
  ),
1118
- click: (selector, signal, onDispatch) =>
1119
- runObservedPageAction(page, selector, signal, onDispatch, (element) => element.click()),
1120
- selectFile: async (request, signal, onDispatch) => {
1317
+ click: (selector, signal, onDispatch, onComplete, expectedTarget) =>
1318
+ runObservedPageAction(
1319
+ page,
1320
+ selector,
1321
+ signal,
1322
+ onDispatch,
1323
+ (element) =>
1324
+ expectedTarget === undefined
1325
+ ? element.click()
1326
+ : guardedPageInput(element, expectedTarget, "click"),
1327
+ undefined,
1328
+ onComplete,
1329
+ ),
1330
+ selectFile: async (request, signal, onDispatch, onComplete) => {
1121
1331
  const selection = await prepareFileSelection(request, signal);
1122
1332
 
1123
1333
  try {
@@ -1128,6 +1338,7 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
1128
1338
  onDispatch,
1129
1339
  selection.select,
1130
1340
  selection.validate,
1341
+ onComplete,
1131
1342
  );
1132
1343
  } finally {
1133
1344
  await selection.close();
@@ -1176,6 +1387,26 @@ const makeProductionBrowser = (
1176
1387
  browser: Browser,
1177
1388
  viewport?: BrowserRunViewport,
1178
1389
  ): BrowserRunInteractiveBrowser => ({
1390
+ detach: () => browser.disconnect(),
1391
+ reattach: async (identity) => {
1392
+ const context = browser
1393
+ .browserContexts()
1394
+ .find((candidate) => candidate.id === identity.contextId);
1395
+
1396
+ if (context === undefined) return undefined;
1397
+ const pages = await context.pages();
1398
+
1399
+ if (pages.length > 64) throw new Error("The browser target inventory exceeds its bound");
1400
+ for (const candidate of pages) {
1401
+ const page = makeProductionPage(candidate);
1402
+ const current = await page.identity?.();
1403
+
1404
+ if (current?.targetId === identity.targetId)
1405
+ return { context: makeProductionContext(context), page };
1406
+ }
1407
+
1408
+ return undefined;
1409
+ },
1179
1410
  createContext: async () => makeProductionContext(await browser.createBrowserContext(), viewport),
1180
1411
  // Exact-session termination may already have closed the provider transport.
1181
1412
  close: async () => {
@@ -1203,10 +1434,34 @@ const protocolError = (message: string, cause?: unknown): InteractiveBrowserProt
1203
1434
  ...(cause === undefined ? {} : { cause }),
1204
1435
  });
1205
1436
 
1206
- const actionError = (operation: BrowserOperation, cause?: unknown): InteractiveBrowserActionError =>
1437
+ const failReportedProtocol = (
1438
+ operation: string,
1439
+ error: InteractiveBrowserProtocolError,
1440
+ evidence = error.evidence,
1441
+ ) =>
1442
+ reportBrowserCause(operation, Cause.fail(error)).pipe(
1443
+ Effect.andThen(
1444
+ Effect.fail(
1445
+ reportedBrowserError(
1446
+ InteractiveBrowserProtocolError.make({
1447
+ implementation: browserRunInteractiveImplementation,
1448
+ message: error.message,
1449
+ ...(evidence === undefined ? {} : { evidence }),
1450
+ }),
1451
+ ),
1452
+ ),
1453
+ ),
1454
+ );
1455
+
1456
+ const actionError = (
1457
+ operation: BrowserOperation,
1458
+ cause?: unknown,
1459
+ evidence?: InteractiveBrowserFailureEvidence,
1460
+ ): InteractiveBrowserActionError =>
1207
1461
  InteractiveBrowserActionError.make({
1208
1462
  implementation: browserRunInteractiveImplementation,
1209
1463
  operation,
1464
+ ...(evidence === undefined ? {} : { evidence }),
1210
1465
  message: `The interactive browser ${operation} operation failed`,
1211
1466
  ...(cause === undefined ? {} : { cause }),
1212
1467
  });
@@ -1216,14 +1471,14 @@ const undispatchedActionError = (operation: BrowserOperation): InteractiveBrowse
1216
1471
  implementation: browserRunInteractiveImplementation,
1217
1472
  operation,
1218
1473
  message: `The interactive browser ${operation} operation was not dispatched`,
1474
+ evidence: { stage: "preparation", dispatch: "not-dispatched", session: "attached" },
1219
1475
  });
1220
1476
 
1221
1477
  /** Recognizes a local pre-dispatch refusal without exposing selector or page content. */
1222
1478
  export const isBrowserRunUndispatchedActionError = (error: unknown): boolean =>
1223
1479
  Schema.is(InteractiveBrowserActionError)(error) &&
1224
1480
  error.implementation.identity === browserRunInteractiveImplementation.identity &&
1225
- (error.operation === "click" || error.operation === "fill") &&
1226
- error.message === `The interactive browser ${error.operation} operation was not dispatched`;
1481
+ error.evidence?.dispatch === "not-dispatched";
1227
1482
 
1228
1483
  const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
1229
1484
  InteractiveBrowserPolicyDeniedError.make({
@@ -1231,10 +1486,11 @@ const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
1231
1486
  message,
1232
1487
  });
1233
1488
 
1234
- const expiredError = (): InteractiveBrowserExpiredError =>
1489
+ const expiredError = (session: "lost" | "disconnected" = "lost"): InteractiveBrowserExpiredError =>
1235
1490
  InteractiveBrowserExpiredError.make({
1236
1491
  implementation: browserRunInteractiveImplementation,
1237
1492
  message: "The remote browser is no longer usable",
1493
+ evidence: { stage: "preparation", dispatch: "not-dispatched", session },
1238
1494
  });
1239
1495
 
1240
1496
  const causeText = (cause: unknown): string => {
@@ -1251,6 +1507,11 @@ const isRemoteClosure = (cause: unknown): boolean =>
1251
1507
  causeText(cause),
1252
1508
  );
1253
1509
 
1510
+ const inputMayStillRun = (cause: unknown): boolean =>
1511
+ isRemoteClosure(cause) ||
1512
+ (cause instanceof Error && cause.name === "TimeoutError") ||
1513
+ (cause instanceof BrowserRunFailure && cause.reason === "timeout");
1514
+
1254
1515
  const snapshotPolicy = Effect.fn("BrowserRunInteractive.snapshotPolicy")(function* (
1255
1516
  input: InteractiveBrowserPolicy,
1256
1517
  ): Effect.fn.Return<
@@ -1380,15 +1641,45 @@ interface HandleState {
1380
1641
  readonly closed: { value: boolean };
1381
1642
  readonly disconnected: { value: boolean };
1382
1643
  readonly uncertain: { value: boolean };
1644
+ readonly pendingInputs: Set<Promise<unknown>>;
1645
+ readonly actions: { value: number };
1383
1646
  readonly violation: { value: BrowserFailure | undefined };
1384
1647
  readonly pendingRequests: Set<Promise<void>>;
1385
1648
  }
1386
1649
 
1650
+ const trackPendingInput = <A>(state: HandleState, pending: Promise<A>): Promise<A> => {
1651
+ state.pendingInputs.add(pending);
1652
+ void pending.then(
1653
+ () => state.pendingInputs.delete(pending),
1654
+ (cause) => {
1655
+ state.pendingInputs.delete(pending);
1656
+ if (state.disconnected.value || inputMayStillRun(cause)) state.uncertain.value = true;
1657
+ if (isRemoteClosure(cause)) state.disconnected.value = true;
1658
+ },
1659
+ );
1660
+
1661
+ return pending;
1662
+ };
1663
+
1664
+ const makeHostInputObserver = Effect.fnUntraced(function* (state: HandleState, operation: string) {
1665
+ const reporterContext = yield* Effect.context<never>();
1666
+
1667
+ return <A>(pending: Promise<A>, signal: AbortSignal): Promise<A> => {
1668
+ void pending.then(undefined, (cause) => {
1669
+ if (signal.aborted)
1670
+ return Effect.runPromiseWith(reporterContext)(
1671
+ reportBrowserCause(operation, Cause.fail(cause)),
1672
+ );
1673
+ });
1674
+
1675
+ return trackPendingInput(state, pending);
1676
+ };
1677
+ });
1678
+
1387
1679
  const stateFailure = (state: HandleState): BrowserFailure | undefined => {
1388
1680
  if (state.violation.value !== undefined) return state.violation.value;
1389
- if (state.closed.value || state.disconnected.value || state.uncertain.value) {
1390
- return expiredError();
1391
- }
1681
+ if (state.closed.value) return expiredError();
1682
+ if (state.disconnected.value) return expiredError("disconnected");
1392
1683
 
1393
1684
  return undefined;
1394
1685
  };
@@ -1409,6 +1700,7 @@ interface HandleRuntime {
1409
1700
  effect: Effect.Effect<A, BrowserFailure>,
1410
1701
  preflight?: Effect.Effect<void, BrowserFailure>,
1411
1702
  consumeAction?: boolean,
1703
+ mutating?: boolean,
1412
1704
  ) => Effect.Effect<A, BrowserFailure>;
1413
1705
  }
1414
1706
 
@@ -1528,75 +1820,131 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1528
1820
  close: Effect.Effect<void, InteractiveBrowserError>,
1529
1821
  ): Effect.fn.Return<HandleRuntime> {
1530
1822
  const permits = yield* Semaphore.make(1);
1531
- const actions = yield* Ref.make(0);
1823
+ let currentOperation: BrowserOperation | undefined;
1824
+ let generation = 0;
1532
1825
 
1533
- const remote = <A>(operation: BrowserOperation, evaluate: (signal: AbortSignal) => Promise<A>) =>
1534
- Effect.tryPromise({
1535
- try: evaluate,
1536
- catch: (cause) => new BrowserRunRemoteFailure(cause),
1537
- }).pipe(
1538
- Effect.catch(
1539
- (
1540
- failure,
1541
- ): Effect.Effect<never, InteractiveBrowserActionError | InteractiveBrowserExpiredError> => {
1542
- const cause = failure.cause;
1826
+ let evidence: InteractiveBrowserFailureEvidence = {
1827
+ stage: "preparation",
1828
+ dispatch: "not-dispatched",
1829
+ session: "attached",
1830
+ };
1831
+
1832
+ const reporterContext = yield* Effect.context<never>();
1833
+
1834
+ const remote = <A>(
1835
+ operation: BrowserOperation,
1836
+ evaluate: (signal: AbortSignal) => Promise<A>,
1837
+ observed = false,
1838
+ ) =>
1839
+ Effect.suspend(() => {
1840
+ currentOperation = operation;
1841
+ const mutating = operation !== "read-text" && operation !== "screenshot";
1842
+ const currentGeneration = ++generation;
1843
+
1844
+ evidence = {
1845
+ stage: observed ? "preparation" : mutating ? "input" : "observation",
1846
+ dispatch: observed || !mutating ? "not-dispatched" : "running",
1847
+ session: "attached",
1848
+ };
1849
+
1850
+ return Effect.tryPromise({
1851
+ try: (signal) => {
1852
+ const pending = evaluate(signal);
1853
+
1854
+ if (mutating) trackPendingInput(state, pending);
1855
+ void pending.then(
1856
+ () => {
1857
+ if (mutating && generation === currentGeneration)
1858
+ evidence = { ...evidence, stage: "observation", dispatch: "completed" };
1859
+ },
1860
+ (cause) => {
1861
+ if (state.disconnected.value || isRemoteClosure(cause)) {
1862
+ state.disconnected.value = true;
1863
+ if (mutating) state.uncertain.value = true;
1864
+ }
1865
+ if (generation === currentGeneration && evidence.dispatch === "running")
1866
+ evidence = { ...evidence, dispatch: "unknown" };
1867
+ if (signal.aborted && !(cause instanceof BrowserRunActionUndispatched))
1868
+ void Effect.runPromiseWith(reporterContext)(
1869
+ reportBrowserCause("interactive.input.late", Cause.fail(cause)),
1870
+ );
1871
+ },
1872
+ );
1543
1873
 
1544
- if (cause instanceof BrowserRunActionUndispatched) {
1874
+ return pending;
1875
+ },
1876
+ catch: (cause) => new BrowserRunRemoteFailure(cause),
1877
+ }).pipe(
1878
+ Effect.catch((failure) => {
1879
+ if (failure.cause instanceof BrowserRunActionUndispatched)
1545
1880
  return Effect.logInfo("Browser interactive action was not dispatched").pipe(
1546
1881
  Effect.annotateLogs({
1547
1882
  "browser.action": operation,
1548
- "browser.selector_match_count": cause.matchCount,
1883
+ "browser.selector_match_count": failure.cause.matchCount,
1549
1884
  }),
1550
1885
  Effect.andThen(Effect.fail(undispatchedActionError(operation))),
1551
1886
  );
1552
- }
1553
- if (state.disconnected.value || isRemoteClosure(cause)) {
1887
+ if (mutating && inputMayStillRun(failure.cause)) state.uncertain.value = true;
1888
+ if (isRemoteClosure(failure.cause)) {
1554
1889
  state.disconnected.value = true;
1555
-
1556
- return Effect.fail(expiredError());
1890
+ if (mutating) state.uncertain.value = true;
1557
1891
  }
1558
-
1559
- return Effect.fail(actionError(operation, cause));
1560
- },
1561
- ),
1562
- );
1892
+ evidence = {
1893
+ ...evidence,
1894
+ dispatch: evidence.dispatch === "running" ? "unknown" : evidence.dispatch,
1895
+ session: state.disconnected.value ? "disconnected" : "attached",
1896
+ };
1897
+
1898
+ return reportBrowserCause(
1899
+ `interactive.${operation}.${evidence.stage}.${evidence.dispatch}`,
1900
+ Cause.fail(failure.cause),
1901
+ ).pipe(
1902
+ Effect.andThen(
1903
+ Effect.fail(reportedBrowserError(actionError(operation, undefined, evidence))),
1904
+ ),
1905
+ );
1906
+ }),
1907
+ );
1908
+ });
1563
1909
 
1564
1910
  const observedAction = (
1565
1911
  operation: "fill" | "click" | "select-file",
1566
- evaluate: (signal: AbortSignal, onDispatch: () => void) => Promise<unknown>,
1912
+ evaluate: (
1913
+ signal: AbortSignal,
1914
+ onDispatch: () => void,
1915
+ onComplete: () => void,
1916
+ ) => Promise<unknown>,
1567
1917
  ) =>
1568
- Effect.suspend(() => {
1569
- let dispatched = false;
1570
- let pending: Promise<unknown> | undefined;
1571
-
1572
- return remote(operation, (signal) => {
1573
- pending = evaluate(signal, () => {
1574
- dispatched = true;
1575
- });
1576
-
1577
- return pending;
1578
- }).pipe(
1579
- Effect.onInterrupt(() =>
1580
- Effect.gen(function* () {
1581
- state.uncertain.value = true;
1582
- // Retain evidence before Scope teardown, without claiming SDK
1583
- // cancellation or success. A late completion never makes this replayable.
1584
- yield* Effect.logWarning("Browser interactive action interrupted").pipe(
1585
- Effect.annotateLogs({
1586
- "browser.action": operation,
1587
- "browser.action_dispatched": dispatched,
1588
- "browser.action_outcome_unknown": dispatched,
1589
- }),
1590
- );
1591
- const completion = pending;
1918
+ remote(
1919
+ operation,
1920
+ (signal) => {
1921
+ const observedGeneration = generation;
1592
1922
 
1593
- if (completion !== undefined) {
1594
- yield* Effect.promise(() => boundedBestEffort(completion, 500));
1595
- }
1923
+ return evaluate(
1924
+ signal,
1925
+ () => {
1926
+ if (generation === observedGeneration)
1927
+ evidence = { ...evidence, stage: "input", dispatch: "running" };
1928
+ },
1929
+ () => {
1930
+ if (generation === observedGeneration)
1931
+ evidence = { ...evidence, stage: "observation", dispatch: "completed" };
1932
+ },
1933
+ );
1934
+ },
1935
+ true,
1936
+ ).pipe(
1937
+ Effect.onInterrupt(() =>
1938
+ Effect.logWarning("Browser interactive action interrupted").pipe(
1939
+ Effect.annotateLogs({
1940
+ "browser.action": operation,
1941
+ "browser.action_dispatched": evidence.dispatch !== "not-dispatched",
1942
+ "browser.action_outcome_unknown":
1943
+ evidence.dispatch === "running" || evidence.dispatch === "unknown",
1596
1944
  }),
1597
1945
  ),
1598
- );
1599
- });
1946
+ ),
1947
+ );
1600
1948
 
1601
1949
  const decodeActionObservation = Effect.fn("BrowserRunInteractive.decodeActionObservation")(
1602
1950
  function* (raw: unknown) {
@@ -1615,6 +1963,8 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1615
1963
  Effect.logInfo("Browser interactive action observed").pipe(
1616
1964
  Effect.annotateLogs({
1617
1965
  "browser.action": operation,
1966
+ "browser.input_millis": observation.inputMillis,
1967
+ "browser.observation_millis": observation.observationMillis,
1618
1968
  "browser.selector_match_count": observation.before.matchCount,
1619
1969
  ...(observation.before.kind === undefined
1620
1970
  ? {}
@@ -1671,21 +2021,52 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1671
2021
  effect: Effect.Effect<A, BrowserFailure>,
1672
2022
  preflight: Effect.Effect<void, BrowserFailure> = Effect.void,
1673
2023
  consumeAction = true,
2024
+ mutating = true,
1674
2025
  ) =>
1675
2026
  permits
1676
2027
  .withPermitsIfAvailable(1)(
1677
2028
  Effect.gen(function* () {
1678
2029
  const unavailable = stateFailure(state);
1679
2030
 
1680
- if (unavailable !== undefined) return yield* unavailable;
2031
+ if (unavailable !== undefined) {
2032
+ if (unavailable._tag === "InteractiveBrowserProtocolError") {
2033
+ yield* reportBrowserCause("interactive.policy", Cause.fail(unavailable));
2034
+
2035
+ const safe = reportedBrowserError(
2036
+ InteractiveBrowserProtocolError.make({
2037
+ implementation: browserRunInteractiveImplementation,
2038
+ message: unavailable.message,
2039
+ evidence: unavailable.evidence ?? {
2040
+ stage: "preparation",
2041
+ dispatch: "not-dispatched",
2042
+ session: "attached",
2043
+ },
2044
+ }),
2045
+ );
2046
+
2047
+ if (state.violation.value === unavailable) state.violation.value = safe;
2048
+
2049
+ return yield* safe;
2050
+ }
2051
+
2052
+ return yield* unavailable;
2053
+ }
2054
+ if (mutating && (state.uncertain.value || state.pendingInputs.size > 0))
2055
+ return yield* InteractiveBrowserBusyError.make({
2056
+ implementation: browserRunInteractiveImplementation,
2057
+ message: "Earlier browser input has not been proven stopped",
2058
+ });
2059
+ currentOperation = undefined;
2060
+ generation++;
1681
2061
  yield* preflight;
1682
2062
 
1683
2063
  if (consumeAction) {
1684
- const admitted = yield* Ref.modify(actions, (count) =>
1685
- count >= policy.maxActions
1686
- ? [{ allowed: false, observed: count + 1 }, count]
1687
- : [{ allowed: true, observed: count + 1 }, count + 1],
1688
- );
2064
+ const admitted = {
2065
+ allowed: state.actions.value < policy.maxActions,
2066
+ observed: state.actions.value + 1,
2067
+ };
2068
+
2069
+ if (admitted.allowed) state.actions.value++;
1689
2070
 
1690
2071
  if (!admitted.allowed) {
1691
2072
  return yield* InteractiveBrowserLimitError.make({
@@ -1699,45 +2080,66 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1699
2080
  }
1700
2081
 
1701
2082
  const completed = effect.pipe(
1702
- Effect.catch((error) => {
1703
- const failure = stateFailure(state);
1704
-
1705
- return Effect.fail(failure ?? error);
1706
- }),
1707
2083
  Effect.flatMap((result) =>
1708
2084
  awaitPendingRequests(state).pipe(
1709
- Effect.flatMap(() => {
1710
- const failure = stateFailure(state);
2085
+ Effect.andThen(
2086
+ Effect.suspend(() => {
2087
+ const failure = stateFailure(state);
1711
2088
 
1712
- return failure === undefined ? Effect.succeed(result) : Effect.fail(failure);
1713
- }),
2089
+ return failure === undefined ? Effect.succeed(result) : Effect.fail(failure);
2090
+ }),
2091
+ ),
1714
2092
  ),
1715
2093
  ),
1716
2094
  );
1717
2095
 
1718
- return yield* withinDeadline(completed, policy, startedAt, () => {
1719
- state.uncertain.value = true;
1720
- }).pipe(
1721
- Effect.onInterrupt(() =>
1722
- Effect.sync(() => {
1723
- state.uncertain.value = true;
1724
- }),
2096
+ return yield* withinDeadline(completed, policy, startedAt).pipe(
2097
+ Effect.mapError((error) =>
2098
+ currentOperation === undefined
2099
+ ? error
2100
+ : error._tag === "InteractiveBrowserLimitError"
2101
+ ? InteractiveBrowserLimitError.make({
2102
+ implementation: error.implementation,
2103
+ limit: error.limit,
2104
+ maximum: error.maximum,
2105
+ observed: error.observed,
2106
+ message: error.message,
2107
+ evidence,
2108
+ })
2109
+ : error._tag === "InteractiveBrowserPolicyDeniedError"
2110
+ ? InteractiveBrowserPolicyDeniedError.make({
2111
+ implementation: error.implementation,
2112
+ message: error.message,
2113
+ evidence,
2114
+ })
2115
+ : error,
1725
2116
  ),
1726
- Effect.catch((error) => {
1727
- if (Schema.is(InteractiveBrowserLimitError)(error) && error.limit === "elapsed") {
1728
- return Effect.fail(error);
1729
- }
1730
- const failure = stateFailure(state);
2117
+ Effect.catchIf(
2118
+ (error) =>
2119
+ currentOperation !== undefined &&
2120
+ error._tag !== "InteractiveBrowserActionError" &&
2121
+ error._tag !== "InteractiveBrowserLimitError" &&
2122
+ error._tag !== "InteractiveBrowserPolicyDeniedError" &&
2123
+ error._tag !== "InteractiveBrowserExpiredError",
2124
+ (error) => {
2125
+ const safe = reportedBrowserError(
2126
+ error._tag === "InteractiveBrowserProtocolError"
2127
+ ? InteractiveBrowserProtocolError.make({
2128
+ implementation: browserRunInteractiveImplementation,
2129
+ message: error.message,
2130
+ evidence,
2131
+ })
2132
+ : actionError(currentOperation ?? "read-text", undefined, evidence),
2133
+ );
1731
2134
 
1732
- if (
1733
- Schema.is(InteractiveBrowserActionError)(error) &&
1734
- !isBrowserRunUndispatchedActionError(error)
1735
- ) {
1736
- state.uncertain.value = true;
1737
- }
2135
+ if (state.violation.value === error) state.violation.value = safe;
1738
2136
 
1739
- return Effect.fail(failure ?? error);
1740
- }),
2137
+ return reportBrowserCause(
2138
+ `interactive.${currentOperation}.observation`,
2139
+ Cause.fail(error),
2140
+ ).pipe(Effect.andThen(Effect.fail(safe)));
2141
+ },
2142
+ ),
1741
2143
  );
1742
2144
  }),
1743
2145
  )
@@ -1780,7 +2182,7 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1780
2182
  );
1781
2183
 
1782
2184
  if (observation._tag === "MissingElement") {
1783
- return yield* actionError("read-text");
2185
+ return yield* actionError("read-text", undefined, evidence);
1784
2186
  }
1785
2187
  if (observation._tag === "OverLimit") {
1786
2188
  return yield* InteractiveBrowserLimitError.make({
@@ -1811,12 +2213,22 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1811
2213
  ),
1812
2214
  );
1813
2215
  }),
2216
+ Effect.void,
2217
+ true,
2218
+ false,
1814
2219
  ),
1815
2220
  fill: (request) =>
1816
2221
  run(
1817
2222
  Effect.gen(function* () {
1818
- const observation = yield* observedAction("fill", (signal, onDispatch) =>
1819
- page.fill(request.selector, request.value, signal, onDispatch),
2223
+ const observation = yield* observedAction("fill", (signal, onDispatch, onComplete) =>
2224
+ page.fill(
2225
+ request.selector,
2226
+ request.value,
2227
+ signal,
2228
+ onDispatch,
2229
+ onComplete,
2230
+ request.expectedTarget,
2231
+ ),
1820
2232
  ).pipe(Effect.flatMap(decodeActionObservation));
1821
2233
 
1822
2234
  yield* logActionObservation("fill", observation);
@@ -1827,8 +2239,8 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1827
2239
  click: (request) =>
1828
2240
  run(
1829
2241
  Effect.gen(function* () {
1830
- const observation = yield* observedAction("click", (signal, onDispatch) =>
1831
- page.click(request.selector, signal, onDispatch),
2242
+ const observation = yield* observedAction("click", (signal, onDispatch, onComplete) =>
2243
+ page.click(request.selector, signal, onDispatch, onComplete, request.expectedTarget),
1832
2244
  ).pipe(Effect.flatMap(decodeActionObservation));
1833
2245
 
1834
2246
  yield* logActionObservation("click", observation);
@@ -1842,8 +2254,10 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1842
2254
  Effect.flatMap((decoded) =>
1843
2255
  run(
1844
2256
  Effect.gen(function* () {
1845
- const observation = yield* observedAction("select-file", (signal, onDispatch) =>
1846
- page.selectFile(decoded, signal, onDispatch),
2257
+ const observation = yield* observedAction(
2258
+ "select-file",
2259
+ (signal, onDispatch, onComplete) =>
2260
+ page.selectFile(decoded, signal, onDispatch, onComplete),
1847
2261
  ).pipe(Effect.flatMap(decodeActionObservation));
1848
2262
 
1849
2263
  yield* logActionObservation("select-file", observation);
@@ -1894,6 +2308,8 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1894
2308
  );
1895
2309
  }),
1896
2310
  decodeActionResult(page, policy).pipe(Effect.asVoid),
2311
+ true,
2312
+ false,
1897
2313
  ),
1898
2314
  ),
1899
2315
  ),
@@ -1968,6 +2384,7 @@ const cdpCommand = <A>(
1968
2384
  Effect.scoped(
1969
2385
  Effect.gen(function* () {
1970
2386
  const cleanupContext = yield* Effect.context<never>();
2387
+ const observeInput = yield* makeHostInputObserver(state, "interactive.control.late");
1971
2388
 
1972
2389
  const cdp = yield* Effect.acquireRelease(
1973
2390
  Effect.tryPromise({
@@ -2006,7 +2423,11 @@ const cdpCommand = <A>(
2006
2423
  );
2007
2424
 
2008
2425
  const raw = yield* Effect.tryPromise({
2009
- try: () => cdp.send(command, parameters),
2426
+ try: (signal) => {
2427
+ const pending = cdp.send(command, parameters);
2428
+
2429
+ return command === "Cloudflare.getHandoffState" ? pending : observeInput(pending, signal);
2430
+ },
2010
2431
  catch: (cause) =>
2011
2432
  state.disconnected.value || isRemoteClosure(cause)
2012
2433
  ? expiredError()
@@ -2021,12 +2442,25 @@ const cdpCommand = <A>(
2021
2442
  ),
2022
2443
  );
2023
2444
  }),
2445
+ ).pipe(
2446
+ Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
2447
+ failReportedProtocol("interactive.control", error),
2448
+ ),
2024
2449
  );
2025
2450
 
2026
2451
  const makeHostService = (
2027
2452
  binding: BrowserRunInteractiveBinding["Service"],
2028
2453
  ): BrowserRunInteractiveHost["Service"] => {
2029
- const closeSession = binding.closeSession;
2454
+ const owners = new Map<string, symbol>();
2455
+
2456
+ const closeSession: BrowserRunInteractiveHost["Service"]["closeSession"] = (sessionId) =>
2457
+ binding.closeSession(sessionId).pipe(
2458
+ Effect.tap(() =>
2459
+ Effect.sync(() => {
2460
+ owners.delete(Redacted.value(sessionId));
2461
+ }),
2462
+ ),
2463
+ );
2030
2464
 
2031
2465
  const terminate = Effect.fn("BrowserRunInteractiveHost.terminate")(function* (
2032
2466
  sessionId: Redacted.Redacted<string>,
@@ -2094,10 +2528,12 @@ const makeHostService = (
2094
2528
 
2095
2529
  const acquire = Effect.fn("BrowserRunInteractiveHost.acquire")(function* (
2096
2530
  policy: InteractiveBrowserPolicy,
2531
+ resume?: BrowserRunInteractiveCheckpoint,
2532
+ pendingInput = false,
2097
2533
  ): Effect.fn.Return<BrowserRunInteractiveAcquisition, InteractiveBrowserError, Scope.Scope> {
2098
2534
  const scope = yield* Scope.Scope;
2099
2535
  const fixedPolicy = yield* snapshotPolicy(policy);
2100
- const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
2536
+ const startedAt = resume?.startedAt ?? (yield* Clock.currentTimeMillis);
2101
2537
 
2102
2538
  // Late SDK replies retain this pass's clock and private reporting scope.
2103
2539
  const runCleanup = <A, E>(effect: Effect.Effect<A, E>) =>
@@ -2122,20 +2558,23 @@ const makeHostService = (
2122
2558
  const state: HandleState = {
2123
2559
  closed: { value: false },
2124
2560
  disconnected: { value: false },
2125
- uncertain: { value: false },
2561
+ uncertain: { value: pendingInput || (resume !== undefined && resume.inputState !== "idle") },
2562
+ pendingInputs: new Set(),
2563
+ actions: { value: resume?.consumedActions ?? 0 },
2126
2564
  violation: { value: undefined },
2127
2565
  pendingRequests: new Set(),
2128
2566
  };
2129
2567
 
2130
2568
  const lifecycle = {
2131
2569
  managedTeardownInstalled: false,
2570
+ retained: resume !== undefined,
2132
2571
  };
2133
2572
 
2134
2573
  const closers: Array<CloseEntry> = [];
2135
2574
 
2136
2575
  const releaseBeforeManaged = (entry: CloseEntry): Effect.Effect<void> =>
2137
2576
  Effect.suspend(() =>
2138
- lifecycle.managedTeardownInstalled
2577
+ lifecycle.managedTeardownInstalled || lifecycle.retained
2139
2578
  ? Effect.void
2140
2579
  : entry.close.pipe(
2141
2580
  Effect.catchCause((cause) =>
@@ -2148,34 +2587,36 @@ const makeHostService = (
2148
2587
 
2149
2588
  const sessionIdValue = yield* Effect.acquireRelease(
2150
2589
  withinDeadline(
2151
- Effect.tryPromise({
2152
- try: (signal) =>
2153
- observeLate(
2154
- signal,
2155
- closeLateAcquisition(
2156
- signal,
2157
- () => binding.acquire(keepAliveMillis(fixedPolicy)),
2158
- (id) =>
2159
- runCleanup(
2160
- Schema.decodeUnknownEffect(BrowserRunSessionId)(id).pipe(
2161
- Effect.flatMap((value) => terminate(Redacted.make(value), [])),
2162
- ),
2590
+ resume === undefined
2591
+ ? Effect.tryPromise({
2592
+ try: (signal) =>
2593
+ observeLate(
2594
+ signal,
2595
+ closeLateAcquisition(
2596
+ signal,
2597
+ () => binding.acquire(keepAliveMillis(fixedPolicy)),
2598
+ (id) =>
2599
+ runCleanup(
2600
+ Schema.decodeUnknownEffect(BrowserRunSessionId)(id).pipe(
2601
+ Effect.flatMap((value) => terminate(Redacted.make(value), [])),
2602
+ ),
2603
+ ),
2163
2604
  ),
2605
+ ),
2606
+ catch: (cause) =>
2607
+ isCapacityRefusal(cause)
2608
+ ? InteractiveBrowserCapacityError.make({
2609
+ implementation: browserRunInteractiveImplementation,
2610
+ message: "Browser Run has no capacity for a new browser session",
2611
+ })
2612
+ : protocolError("Acquiring the Browser Run session failed", cause),
2613
+ }).pipe(
2614
+ Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
2615
+ Effect.catchTag("SchemaError", () =>
2616
+ protocolError("The Browser Run session identity was malformed"),
2164
2617
  ),
2165
- ),
2166
- catch: (cause) =>
2167
- isCapacityRefusal(cause)
2168
- ? InteractiveBrowserCapacityError.make({
2169
- implementation: browserRunInteractiveImplementation,
2170
- message: "Browser Run has no capacity for a new browser session",
2171
- })
2172
- : protocolError("Acquiring the Browser Run session failed", cause),
2173
- }).pipe(
2174
- Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
2175
- Effect.catchTag("SchemaError", () =>
2176
- protocolError("The Browser Run session identity was malformed"),
2177
- ),
2178
- ),
2618
+ )
2619
+ : Effect.succeed(Redacted.value(resume.sessionId)),
2179
2620
  fixedPolicy,
2180
2621
  startedAt,
2181
2622
  ),
@@ -2184,7 +2625,7 @@ const makeHostService = (
2184
2625
  state.closed.value = true;
2185
2626
  state.disconnected.value = true;
2186
2627
 
2187
- return lifecycle.managedTeardownInstalled
2628
+ return lifecycle.managedTeardownInstalled || lifecycle.retained
2188
2629
  ? Effect.void
2189
2630
  : terminate(Redacted.make(id), []).pipe(
2190
2631
  Effect.catchCause((cause) =>
@@ -2197,12 +2638,33 @@ const makeHostService = (
2197
2638
  { interruptible: true },
2198
2639
  );
2199
2640
 
2641
+ const owner = Symbol();
2642
+
2643
+ const releaseOwner = () => {
2644
+ if (owners.get(sessionIdValue) === owner) owners.delete(sessionIdValue);
2645
+ };
2646
+
2200
2647
  const connect = yield* Effect.cached(
2201
2648
  Effect.gen(function* (): Effect.fn.Return<
2202
2649
  BrowserRunInteractiveSession,
2203
2650
  InteractiveBrowserError,
2204
2651
  Scope.Scope
2205
2652
  > {
2653
+ if (owners.has(sessionIdValue))
2654
+ return yield* InteractiveBrowserBusyError.make({
2655
+ implementation: browserRunInteractiveImplementation,
2656
+ message: "The previous local browser owner must detach before resuming",
2657
+ });
2658
+ yield* Effect.acquireRelease(
2659
+ Effect.sync(() => {
2660
+ owners.set(sessionIdValue, owner);
2661
+ }),
2662
+ () =>
2663
+ Effect.sync(() => {
2664
+ if (!lifecycle.managedTeardownInstalled) releaseOwner();
2665
+ }),
2666
+ );
2667
+
2206
2668
  const browser = yield* Effect.acquireRelease(
2207
2669
  withinDeadline(
2208
2670
  Effect.tryPromise({
@@ -2212,22 +2674,60 @@ const makeHostService = (
2212
2674
  closeLateAcquisition(
2213
2675
  signal,
2214
2676
  () => binding.connect(sessionIdValue),
2215
- (acquired) => runCleanup(closeAcquired(acquired)),
2677
+ (acquired) =>
2678
+ runCleanup(
2679
+ resume === undefined
2680
+ ? closeAcquired(acquired)
2681
+ : closeEntry(
2682
+ acquired.detach ?? (async () => {}),
2683
+ "Detaching the browser failed",
2684
+ ).close,
2685
+ ),
2216
2686
  ),
2217
2687
  ),
2218
2688
  catch: (cause) =>
2219
- protocolError("Connecting to the Browser Run session failed", cause),
2220
- }),
2689
+ resume !== undefined &&
2690
+ cause instanceof BrowserRunFailure &&
2691
+ (cause.status === 404 || cause.status === 410)
2692
+ ? expiredError()
2693
+ : protocolError("Connecting to the Browser Run session failed", cause),
2694
+ }).pipe(
2695
+ Effect.timeoutOrElse({
2696
+ duration: "10 seconds",
2697
+ orElse: () =>
2698
+ Effect.fail(protocolError("Connecting to the Browser Run session timed out")),
2699
+ }),
2700
+ ),
2221
2701
  fixedPolicy,
2222
2702
  startedAt,
2223
2703
  ),
2224
2704
  (acquired) =>
2225
- releaseBeforeManaged(
2226
- closeEntry(acquired.close, "Closing the local browser connection failed"),
2227
- ),
2705
+ lifecycle.managedTeardownInstalled
2706
+ ? Effect.void
2707
+ : lifecycle.retained
2708
+ ? closeEntry(
2709
+ acquired.detach ?? (async () => {}),
2710
+ "Detaching the browser failed",
2711
+ ).close.pipe(
2712
+ Effect.tap(() => Effect.sync(releaseOwner)),
2713
+ Effect.catchCause((cause) => reportBrowserCause("interactive.detach", cause)),
2714
+ )
2715
+ : releaseBeforeManaged(
2716
+ closeEntry(acquired.close, "Closing the local browser connection failed"),
2717
+ ),
2228
2718
  { interruptible: true },
2229
2719
  );
2230
2720
 
2721
+ if (resume !== undefined) {
2722
+ const actualSession = yield* Effect.try({
2723
+ try: browser.sessionId,
2724
+ catch: (cause) =>
2725
+ protocolError("Reading the reattached browser identity failed", cause),
2726
+ });
2727
+
2728
+ if (actualSession !== sessionIdValue)
2729
+ return yield* protocolError("The reattached browser session identity did not match");
2730
+ }
2231
2731
  closers.push(closeEntry(browser.close, "Closing the interactive browser failed"));
2232
2732
 
2233
2733
  const disconnected = () => {
@@ -2266,67 +2766,97 @@ const makeHostService = (
2266
2766
  return yield* expiredError();
2267
2767
  }
2268
2768
 
2269
- const context = yield* Effect.acquireRelease(
2270
- withinDeadline(
2271
- Effect.tryPromise({
2272
- try: (signal) =>
2273
- observeLate(
2274
- signal,
2275
- closeLateAcquisition(signal, browser.createContext, (acquired) =>
2276
- runCleanup(
2277
- Effect.tryPromise({
2278
- try: () => acquired.close(),
2279
- catch: (cause) => actionError("close", cause),
2280
- }),
2769
+ const reattached =
2770
+ resume === undefined
2771
+ ? undefined
2772
+ : yield* withinDeadline(
2773
+ Effect.tryPromise({
2774
+ try: async () => {
2775
+ if (browser.reattach === undefined || browser.detach === undefined)
2776
+ throw new Error("The browser binding does not support exact reattachment");
2777
+
2778
+ return await browser.reattach(resume.page);
2779
+ },
2780
+ catch: (cause) =>
2781
+ protocolError("Reattaching the exact browser page failed", cause),
2782
+ }).pipe(
2783
+ Effect.timeoutOrElse({
2784
+ duration: "10 seconds",
2785
+ orElse: () =>
2786
+ Effect.fail(protocolError("Reattaching the exact browser page timed out")),
2787
+ }),
2788
+ ),
2789
+ fixedPolicy,
2790
+ startedAt,
2791
+ );
2792
+
2793
+ if (resume !== undefined && reattached === undefined) return yield* expiredError();
2794
+
2795
+ const context =
2796
+ reattached?.context ??
2797
+ (yield* Effect.acquireRelease(
2798
+ withinDeadline(
2799
+ Effect.tryPromise({
2800
+ try: (signal) =>
2801
+ observeLate(
2802
+ signal,
2803
+ closeLateAcquisition(signal, browser.createContext, (acquired) =>
2804
+ runCleanup(
2805
+ Effect.tryPromise({
2806
+ try: () => acquired.close(),
2807
+ catch: (cause) => actionError("close", cause),
2808
+ }),
2809
+ ),
2281
2810
  ),
2282
2811
  ),
2283
- ),
2284
- catch: (cause) =>
2285
- state.disconnected.value || isRemoteClosure(cause)
2286
- ? expiredError()
2287
- : protocolError("Creating the browser context failed", cause),
2288
- }),
2289
- fixedPolicy,
2290
- startedAt,
2291
- ),
2292
- (acquired) =>
2293
- releaseBeforeManaged(
2294
- closeEntry(acquired.close, "Closing the interactive browser context failed"),
2812
+ catch: (cause) =>
2813
+ state.disconnected.value || isRemoteClosure(cause)
2814
+ ? expiredError()
2815
+ : protocolError("Creating the browser context failed", cause),
2816
+ }),
2817
+ fixedPolicy,
2818
+ startedAt,
2295
2819
  ),
2296
- { interruptible: true },
2297
- );
2820
+ (acquired) =>
2821
+ releaseBeforeManaged(
2822
+ closeEntry(acquired.close, "Closing the interactive browser context failed"),
2823
+ ),
2824
+ { interruptible: true },
2825
+ ));
2298
2826
 
2299
2827
  closers.push(closeEntry(context.close, "Closing the interactive browser context failed"));
2300
2828
 
2301
- const page = yield* Effect.acquireRelease(
2302
- withinDeadline(
2303
- Effect.tryPromise({
2304
- try: (signal) =>
2305
- observeLate(
2306
- signal,
2307
- closeLateAcquisition(signal, context.newPage, (acquired) =>
2308
- runCleanup(
2309
- Effect.tryPromise({
2310
- try: () => acquired.close(),
2311
- catch: (cause) => actionError("close", cause),
2312
- }),
2829
+ const page =
2830
+ reattached?.page ??
2831
+ (yield* Effect.acquireRelease(
2832
+ withinDeadline(
2833
+ Effect.tryPromise({
2834
+ try: (signal) =>
2835
+ observeLate(
2836
+ signal,
2837
+ closeLateAcquisition(signal, context.newPage, (acquired) =>
2838
+ runCleanup(
2839
+ Effect.tryPromise({
2840
+ try: () => acquired.close(),
2841
+ catch: (cause) => actionError("close", cause),
2842
+ }),
2843
+ ),
2313
2844
  ),
2314
2845
  ),
2315
- ),
2316
- catch: (cause) =>
2317
- state.disconnected.value || isRemoteClosure(cause)
2318
- ? expiredError()
2319
- : protocolError("Creating the browser page failed", cause),
2320
- }),
2321
- fixedPolicy,
2322
- startedAt,
2323
- ),
2324
- (acquired) =>
2325
- releaseBeforeManaged(
2326
- closeEntry(acquired.close, "Closing the interactive browser page failed"),
2846
+ catch: (cause) =>
2847
+ state.disconnected.value || isRemoteClosure(cause)
2848
+ ? expiredError()
2849
+ : protocolError("Creating the browser page failed", cause),
2850
+ }),
2851
+ fixedPolicy,
2852
+ startedAt,
2327
2853
  ),
2328
- { interruptible: true },
2329
- );
2854
+ (acquired) =>
2855
+ releaseBeforeManaged(
2856
+ closeEntry(acquired.close, "Closing the interactive browser page failed"),
2857
+ ),
2858
+ { interruptible: true },
2859
+ ));
2330
2860
 
2331
2861
  closers.push(closeEntry(page.close, "Closing the interactive browser page failed"));
2332
2862
 
@@ -2376,6 +2906,34 @@ const makeHostService = (
2376
2906
 
2377
2907
  if (setupFailure !== undefined) return yield* setupFailure;
2378
2908
 
2909
+ const detach = yield* Effect.cached(
2910
+ Effect.uninterruptible(
2911
+ Effect.gen(function* () {
2912
+ if (browser.detach === undefined)
2913
+ return yield* protocolError("The browser binding does not support detachment");
2914
+ lifecycle.retained = true;
2915
+ state.closed.value = true;
2916
+ state.disconnected.value = true;
2917
+ if (state.pendingInputs.size > 0) state.uncertain.value = true;
2918
+ yield* closeEntry(async () => {
2919
+ page.offRequest(requestListener);
2920
+ browser.offDisconnected(disconnected);
2921
+ await browser.detach?.();
2922
+ }, "Detaching the browser transport failed").close;
2923
+ releaseOwner();
2924
+ }).pipe(
2925
+ Effect.catchTag("InteractiveBrowserActionError", (error) =>
2926
+ reportBrowserCause("interactive.detach", Cause.fail(error)).pipe(
2927
+ Effect.andThen(Effect.fail(reportedBrowserError(actionError("close")))),
2928
+ ),
2929
+ ),
2930
+ Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
2931
+ failReportedProtocol("interactive.detach", error),
2932
+ ),
2933
+ ),
2934
+ ),
2935
+ );
2936
+
2379
2937
  const teardown = yield* Effect.uninterruptible(
2380
2938
  Effect.gen(function* () {
2381
2939
  const cached = yield* Effect.cached(
@@ -2386,13 +2944,14 @@ const makeHostService = (
2386
2944
  );
2387
2945
 
2388
2946
  lifecycle.managedTeardownInstalled = true;
2947
+ lifecycle.retained = false;
2389
2948
  yield* Effect.addFinalizer(() =>
2390
2949
  Effect.uninterruptible(
2391
2950
  Effect.sync(() => {
2392
2951
  state.closed.value = true;
2393
2952
  state.disconnected.value = true;
2394
2953
  }).pipe(
2395
- Effect.andThen(cached),
2954
+ Effect.andThen(Effect.suspend(() => (lifecycle.retained ? detach : cached))),
2396
2955
  Effect.catchCause((cause) =>
2397
2956
  reportBrowserCause("interactive.close", cause).pipe(
2398
2957
  Effect.andThen(
@@ -2409,10 +2968,13 @@ const makeHostService = (
2409
2968
  );
2410
2969
 
2411
2970
  const close: Effect.Effect<void, InteractiveBrowserError> = Effect.uninterruptible(
2412
- Effect.sync(() => {
2971
+ Effect.suspend(() => {
2972
+ if (lifecycle.retained || owners.get(sessionIdValue) !== owner) return Effect.void;
2413
2973
  state.closed.value = true;
2414
2974
  state.disconnected.value = true;
2415
- }).pipe(Effect.andThen(teardown)),
2975
+
2976
+ return teardown;
2977
+ }),
2416
2978
  );
2417
2979
 
2418
2980
  const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
@@ -2429,15 +2991,61 @@ const makeHostService = (
2429
2991
  ),
2430
2992
  );
2431
2993
 
2994
+ const inputState = Effect.sync((): BrowserRunInputState =>
2995
+ state.uncertain.value ? "unknown" : state.pendingInputs.size > 0 ? "running" : "idle",
2996
+ );
2997
+
2998
+ const pageIdentity = yield* Effect.cached(
2999
+ Effect.tryPromise({
3000
+ try: async () => {
3001
+ if (page.identity === undefined)
3002
+ throw new Error("The browser binding does not expose page identity");
3003
+
3004
+ return await page.identity();
3005
+ },
3006
+ catch: (cause) =>
3007
+ protocolError("Reading the exact browser page identity failed", cause),
3008
+ }).pipe(
3009
+ Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
3010
+ failReportedProtocol("interactive.checkpoint", error),
3011
+ ),
3012
+ ),
3013
+ );
3014
+
3015
+ const observeResize = yield* makeHostInputObserver(state, "interactive.resize.late");
3016
+
2432
3017
  return {
2433
3018
  handle: runtime.handle,
3019
+ checkpoint: withinDeadline(
3020
+ Effect.gen(function* () {
3021
+ const identity = resume?.page ?? (yield* pageIdentity);
3022
+
3023
+ return BrowserRunInteractiveCheckpoint.make({
3024
+ sessionId: Redacted.make(sessionIdValue),
3025
+ page: identity,
3026
+ policy: InteractiveBrowserPolicy.make(fixedPolicy),
3027
+ startedAt,
3028
+ consumedActions: state.actions.value,
3029
+ inputState: yield* inputState,
3030
+ });
3031
+ }),
3032
+ fixedPolicy,
3033
+ startedAt,
3034
+ ),
3035
+ detach,
3036
+ inputState,
3037
+ drainInput: Effect.suspend(() =>
3038
+ Effect.promise(() =>
3039
+ boundedBestEffort(Promise.allSettled(state.pendingInputs), 500),
3040
+ ).pipe(Effect.andThen(inputState)),
3041
+ ),
2434
3042
  sessionId: Redacted.make(sessionIdValue),
2435
3043
  resizeViewport: (viewport) =>
2436
3044
  decodeViewport(viewport).pipe(
2437
3045
  Effect.flatMap((decoded) =>
2438
3046
  runtime.run(
2439
3047
  Effect.tryPromise({
2440
- try: () => page.setViewport(decoded),
3048
+ try: (signal) => observeResize(page.setViewport(decoded), signal),
2441
3049
  catch: (cause) => {
2442
3050
  if (state.disconnected.value || isRemoteClosure(cause)) {
2443
3051
  state.disconnected.value = true;
@@ -2447,7 +3055,11 @@ const makeHostService = (
2447
3055
 
2448
3056
  return protocolError("Resizing the browser viewport failed", cause);
2449
3057
  },
2450
- }),
3058
+ }).pipe(
3059
+ Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
3060
+ failReportedProtocol("interactive.resize", error),
3061
+ ),
3062
+ ),
2451
3063
  currentPagePreflight,
2452
3064
  false,
2453
3065
  ),
@@ -2479,6 +3091,8 @@ const makeHostService = (
2479
3091
  currentPagePreflight.pipe(
2480
3092
  Effect.andThen(requestFitsSession(decoded.expiresInMs)),
2481
3093
  ),
3094
+ true,
3095
+ true,
2482
3096
  ),
2483
3097
  ),
2484
3098
  ),
@@ -2535,6 +3149,8 @@ const makeHostService = (
2535
3149
  ),
2536
3150
  ),
2537
3151
  currentPagePreflight,
3152
+ true,
3153
+ false,
2538
3154
  ),
2539
3155
  close,
2540
3156
  };
@@ -2550,6 +3166,19 @@ const makeHostService = (
2550
3166
  return BrowserRunInteractiveHost.of({
2551
3167
  acquire,
2552
3168
  open: (policy) => acquire(policy).pipe(Effect.flatMap((acquired) => acquired.connect)),
3169
+ resume: (checkpoint, options) =>
3170
+ Schema.decodeEffect(Schema.toType(BrowserRunInteractiveCheckpoint))(checkpoint).pipe(
3171
+ Effect.mapError(() => policyError("The browser checkpoint is malformed")),
3172
+ Effect.flatMap((decoded) => acquire(decoded.policy, decoded, options.pendingInput)),
3173
+ Effect.flatMap((acquired) => acquired.connect),
3174
+ Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
3175
+ failReportedProtocol("interactive.resume", error, {
3176
+ stage: "preparation",
3177
+ dispatch: "not-dispatched",
3178
+ session: "disconnected",
3179
+ }),
3180
+ ),
3181
+ ),
2553
3182
  closeSession,
2554
3183
  cleanupSemantics: "confirmed-terminal",
2555
3184
  });