@effect-agent/platform-cloudflare 0.1.0-beta.36 → 0.1.0-beta.38

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.
@@ -32,6 +32,7 @@ import {
32
32
  } from "@effect-agent/sandbox";
33
33
  import {
34
34
  Context,
35
+ Clock,
35
36
  Duration,
36
37
  Effect,
37
38
  Layer,
@@ -43,6 +44,9 @@ import {
43
44
  type Scope,
44
45
  } from "effect";
45
46
 
47
+ import { BrowserRunSessionLifecycle } from "./browser-session-lifecycle.ts";
48
+ export { BrowserRunCleanupError, BrowserRunSessionLifecycle } from "./browser-session-lifecycle.ts";
49
+
46
50
  export const browserRunInteractiveImplementation = SandboxImplementation.make({
47
51
  isolation: "isolated",
48
52
  identity: "cloudflare-browser-run-interactive",
@@ -57,7 +61,10 @@ const MAX_LIVE_VIEW_EXPIRY_MILLIS = 60 * 60_000;
57
61
  const MAX_HANDOFF_TIMEOUT_MILLIS = 30 * 60_000;
58
62
  const MAX_HOST_TEXT_LENGTH = 8 * 1024;
59
63
  const CLEANUP_STEP_TIMEOUT_MILLIS = 10_000;
60
- const CLOSE_SESSION_TIMEOUT_MILLIS = 10_000;
64
+ const ACTION_NETWORK_QUIET_MILLIS = 200;
65
+ const ACTION_NETWORK_SETTLE_MILLIS = 2_000;
66
+ const ACTION_POST_STATE_MILLIS = 250;
67
+ const MAX_OBSERVED_CONTROLS = 64;
61
68
  const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0));
62
69
  const BoundedHostText = Schema.String.check(
63
70
  Schema.isMinLength(1),
@@ -69,6 +76,55 @@ const TextObservation = Schema.Union([
69
76
  Schema.Struct({ _tag: Schema.Literal("MissingElement") }),
70
77
  Schema.Struct({ _tag: Schema.Literal("OverLimit"), observed: Schema.Natural }),
71
78
  ]);
79
+ const ActionTargetState = Schema.Struct({
80
+ matchCount: Schema.Natural,
81
+ invalidSelector: Schema.optionalKey(Schema.Boolean),
82
+ kind: Schema.optionalKey(
83
+ Schema.Literals(["button", "checkbox", "radio", "select", "text", "link", "other"]),
84
+ ),
85
+ checked: Schema.optionalKey(Schema.Boolean),
86
+ selected: Schema.optionalKey(Schema.Boolean),
87
+ disabled: Schema.optionalKey(Schema.Boolean),
88
+ required: Schema.optionalKey(Schema.Boolean),
89
+ valid: Schema.optionalKey(Schema.Boolean),
90
+ formValid: Schema.optionalKey(Schema.Boolean),
91
+ });
92
+ const ActionNetworkState = Schema.Struct({
93
+ total: Schema.Natural,
94
+ status2xx: Schema.Natural,
95
+ status3xx: Schema.Natural,
96
+ status4xx: Schema.Natural,
97
+ status5xx: Schema.Natural,
98
+ failed: Schema.Natural,
99
+ pending: Schema.Natural,
100
+ settleTimedOut: Schema.Boolean,
101
+ });
102
+ const ActionObservation = Schema.Struct({
103
+ before: ActionTargetState,
104
+ after: Schema.optionalKey(ActionTargetState),
105
+ afterUnavailable: Schema.Boolean,
106
+ network: ActionNetworkState,
107
+ });
108
+ const PageObservation = Schema.fromJsonString(
109
+ Schema.Struct({
110
+ pageText: BoundedRemoteText,
111
+ selectorMatchCount: Schema.Natural,
112
+ controlsTruncated: Schema.Boolean,
113
+ controls: Schema.Array(
114
+ Schema.Struct({
115
+ selector: BoundedRemoteText,
116
+ kind: BoundedRemoteText,
117
+ label: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200))),
118
+ checked: Schema.optionalKey(Schema.Boolean),
119
+ selected: Schema.optionalKey(Schema.Boolean),
120
+ disabled: Schema.optionalKey(Schema.Boolean),
121
+ required: Schema.optionalKey(Schema.Boolean),
122
+ valid: Schema.optionalKey(Schema.Boolean),
123
+ formValid: Schema.optionalKey(Schema.Boolean),
124
+ }),
125
+ ).check(Schema.isMaxLength(MAX_OBSERVED_CONTROLS)),
126
+ }),
127
+ );
72
128
  const PngBytes = Schema.Uint8Array.check(
73
129
  Schema.isMaxLength(MAX_SCREENSHOT_BYTES),
74
130
  Schema.makeFilter(
@@ -132,6 +188,38 @@ const HandoffStateObservation = Schema.Union([
132
188
  }),
133
189
  ]);
134
190
 
191
+ /**
192
+ * Host presentation state in CSS pixels. Dimensions are integers in 1..2048,
193
+ * density is finite in 1..2 (default 1), and neither scaled dimension may exceed
194
+ * 2048 pixels. Mobile, touch, and orientation emulation are not supported.
195
+ */
196
+ export class BrowserRunViewport extends Schema.Class<BrowserRunViewport>("BrowserRunViewport")(
197
+ Schema.Struct({
198
+ width: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
199
+ height: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
200
+ deviceScaleFactor: Schema.optionalKey(
201
+ Schema.Finite.check(Schema.isBetween({ minimum: 1, maximum: 2 })),
202
+ ),
203
+ }).check(
204
+ Schema.makeFilter(
205
+ (viewport) =>
206
+ Math.max(viewport.width, viewport.height) * (viewport.deviceScaleFactor ?? 1) <= 2_048,
207
+ { title: "a viewport with scaled dimensions no larger than 2048 pixels" },
208
+ ),
209
+ ),
210
+ ) {}
211
+
212
+ const decodeViewport = (input: BrowserRunViewport) =>
213
+ Schema.decodeUnknownEffect(BrowserRunViewport)(input, { onExcessProperty: "error" }).pipe(
214
+ Effect.mapError(() => policyError("The browser viewport is malformed")),
215
+ // Copy only presentation fields, including for Schema class instances.
216
+ Effect.map((viewport) => ({
217
+ width: viewport.width,
218
+ height: viewport.height,
219
+ deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
220
+ })),
221
+ );
222
+
135
223
  /** Host-only request for a redacted Cloudflare Live View URL. */
136
224
  export class BrowserRunLiveViewRequest extends Schema.Class<BrowserRunLiveViewRequest>(
137
225
  "BrowserRunLiveViewRequest",
@@ -209,10 +297,20 @@ export interface BrowserRunInteractivePage {
209
297
  readonly goto: (url: string) => Promise<void>;
210
298
  readonly url: () => unknown;
211
299
  readonly readText: (selector: string | undefined, maximumBytes: number) => Promise<unknown>;
212
- readonly fill: (selector: string, value: string) => Promise<void>;
213
- readonly click: (selector: string) => Promise<void>;
300
+ readonly fill: (
301
+ selector: string,
302
+ value: string,
303
+ signal: AbortSignal,
304
+ onDispatch: () => void,
305
+ ) => Promise<unknown>;
306
+ readonly click: (
307
+ selector: string,
308
+ signal: AbortSignal,
309
+ onDispatch: () => void,
310
+ ) => Promise<unknown>;
214
311
  readonly screenshot: (fullPage: boolean) => Promise<unknown>;
215
312
  readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
313
+ readonly setViewport: (viewport: BrowserRunViewport) => Promise<void>;
216
314
  readonly createCdpSession: () => Promise<BrowserRunInteractiveCdpSession>;
217
315
  }
218
316
 
@@ -235,28 +333,55 @@ export class BrowserRunInteractiveBinding extends Context.Service<
235
333
  BrowserRunInteractiveBinding,
236
334
  {
237
335
  readonly launch: (keepAliveMillis: number) => Promise<BrowserRunInteractiveBrowser>;
238
- readonly connect: (sessionId: string) => Promise<BrowserRunInteractiveBrowser>;
336
+ /** Success proves whole-browser termination or exact-session absence. */
337
+ readonly closeSession: (
338
+ sessionId: Redacted.Redacted<string>,
339
+ ) => Effect.Effect<void, InteractiveBrowserError>;
239
340
  }
240
341
  >()("@effect-agent/platform-cloudflare/BrowserRunInteractiveBinding") {
241
342
  static layer(options: {
242
343
  readonly browser: BrowserRun;
243
- }): Layer.Layer<BrowserRunInteractiveBinding> {
244
- return Layer.succeed(BrowserRunInteractiveBinding)({
245
- launch: async (keepAliveMillis) =>
246
- makeProductionBrowser(
247
- await puppeteer.launch(options.browser, {
248
- keep_alive: keepAliveMillis,
249
- }),
250
- ),
251
- connect: async (sessionId) =>
252
- makeProductionBrowser(await puppeteer.connect(options.browser, sessionId)),
253
- });
344
+ readonly viewport?: BrowserRunViewport;
345
+ }): Layer.Layer<
346
+ BrowserRunInteractiveBinding,
347
+ InteractiveBrowserPolicyDeniedError,
348
+ BrowserRunSessionLifecycle
349
+ > {
350
+ return Layer.effect(BrowserRunInteractiveBinding)(
351
+ Effect.gen(function* () {
352
+ const lifecycle = yield* BrowserRunSessionLifecycle;
353
+ const viewport =
354
+ options.viewport === undefined ? undefined : yield* decodeViewport(options.viewport);
355
+ return {
356
+ launch: async (keepAliveMillis: number) =>
357
+ makeProductionBrowser(
358
+ await puppeteer.launch(options.browser, {
359
+ keep_alive: keepAliveMillis,
360
+ ...(viewport === undefined ? {} : { defaultViewport: { ...viewport } }),
361
+ }),
362
+ ),
363
+ closeSession: (sessionId: Redacted.Redacted<string>) =>
364
+ lifecycle
365
+ .close(sessionId)
366
+ .pipe(Effect.mapError((cause) => actionError("close", cause))),
367
+ };
368
+ }),
369
+ );
254
370
  }
255
371
  }
256
372
 
257
373
  export interface BrowserRunInteractiveSession {
258
374
  readonly handle: BrowserHandle;
259
375
  readonly sessionId: Redacted.Redacted<string>;
376
+ /**
377
+ * Resize without charging an agent action or changing emulation modes. Shares
378
+ * the handle's fail-fast lock, page-policy preflight, and elapsed deadline.
379
+ * A timeout or interruption leaves the session unusable; no resize is retried.
380
+ * The host must authorize callers. No viewer ownership or durable state is added.
381
+ */
382
+ readonly resizeViewport: (
383
+ viewport: BrowserRunViewport,
384
+ ) => Effect.Effect<void, InteractiveBrowserError>;
260
385
  readonly getLiveView: (
261
386
  request: BrowserRunLiveViewRequest,
262
387
  ) => Effect.Effect<BrowserRunLiveViewResult, InteractiveBrowserError>;
@@ -271,6 +396,7 @@ export interface BrowserRunInteractiveSession {
271
396
  export class BrowserRunInteractiveHost extends Context.Service<
272
397
  BrowserRunInteractiveHost,
273
398
  {
399
+ readonly cleanupSemantics?: "confirmed-terminal";
274
400
  readonly open: (
275
401
  policy: InteractiveBrowserPolicy,
276
402
  ) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
@@ -294,6 +420,288 @@ const makeProductionCdpSession = (session: CDPSession): BrowserRunInteractiveCdp
294
420
  detach: () => session.detach(),
295
421
  });
296
422
 
423
+ class BrowserRunActionUndispatched extends Error {
424
+ readonly matchCount: number;
425
+
426
+ constructor(matchCount: number) {
427
+ super("The browser action selector did not resolve to exactly one element");
428
+ this.matchCount = matchCount;
429
+ }
430
+ }
431
+
432
+ const readActionTarget = (page: Page, selector: string): Promise<unknown> =>
433
+ page.evaluate((requestedSelector) => {
434
+ const pageDocument = Reflect.get(globalThis, "document");
435
+ let matches;
436
+ try {
437
+ matches = Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
438
+ requestedSelector,
439
+ ]);
440
+ } catch (cause) {
441
+ if (cause instanceof Error && cause.name === "SyntaxError") {
442
+ return { matchCount: 0, invalidSelector: true };
443
+ }
444
+ throw cause;
445
+ }
446
+ if (typeof matches !== "object" || matches === null) throw new Error("Invalid query result");
447
+ const matchCount = Math.min(10_000, Reflect.get(matches, "length"));
448
+ const element = Reflect.get(matches, 0);
449
+ if (element === undefined) return { matchCount };
450
+
451
+ const associated = Reflect.get(element, "control") ?? element;
452
+ const tagName = String(Reflect.get(associated, "tagName") ?? "").toLowerCase();
453
+ const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
454
+ const role = String(
455
+ Reflect.apply(Reflect.get(element, "getAttribute"), element, ["role"]) ?? "",
456
+ ).toLowerCase();
457
+ const kind =
458
+ tagName === "button" || role === "button"
459
+ ? "button"
460
+ : inputType === "checkbox" || role === "checkbox" || role === "switch"
461
+ ? "checkbox"
462
+ : inputType === "radio" || role === "radio"
463
+ ? "radio"
464
+ : tagName === "select"
465
+ ? "select"
466
+ : tagName === "input" || tagName === "textarea"
467
+ ? "text"
468
+ : tagName === "a"
469
+ ? "link"
470
+ : "other";
471
+ const checked = Reflect.get(associated, "checked");
472
+ const selected =
473
+ tagName === "select"
474
+ ? Reflect.get(associated, "selectedIndex") >= 0
475
+ : Reflect.get(associated, "selected");
476
+ const disabled = Reflect.get(associated, "disabled");
477
+ const required = Reflect.get(associated, "required");
478
+ const validity = Reflect.get(associated, "validity");
479
+ const form = Reflect.get(associated, "form");
480
+ const formMatches =
481
+ form === null || form === undefined ? undefined : Reflect.get(form, "matches");
482
+ const ariaChecked = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
483
+ "aria-checked",
484
+ ]);
485
+ const ariaDisabled = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
486
+ "aria-disabled",
487
+ ]);
488
+ const ariaSelected = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
489
+ "aria-selected",
490
+ ]);
491
+
492
+ return {
493
+ matchCount,
494
+ kind,
495
+ ...(typeof checked === "boolean"
496
+ ? { checked }
497
+ : ariaChecked === "true" || ariaChecked === "false"
498
+ ? { checked: ariaChecked === "true" }
499
+ : {}),
500
+ ...(typeof selected === "boolean"
501
+ ? { selected }
502
+ : ariaSelected === "true" || ariaSelected === "false"
503
+ ? { selected: ariaSelected === "true" }
504
+ : {}),
505
+ disabled: typeof disabled === "boolean" ? disabled : ariaDisabled === "true",
506
+ ...(typeof required === "boolean" ? { required } : {}),
507
+ ...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
508
+ ? { valid: Reflect.get(validity, "valid") }
509
+ : {}),
510
+ ...(typeof formMatches === "function"
511
+ ? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
512
+ : {}),
513
+ };
514
+ }, selector);
515
+
516
+ // SDK promises cannot be cancelled. Clear our timer and observe late rejections;
517
+ // the owning browser Scope is responsible for terminating the remote session.
518
+ const boundedBestEffort = async <A>(
519
+ promise: Promise<A>,
520
+ millis: number,
521
+ ): Promise<A | undefined> => {
522
+ let timer: ReturnType<typeof setTimeout> | number | undefined;
523
+ try {
524
+ return await Promise.race([
525
+ promise.catch(() => undefined),
526
+ new Promise<undefined>((resolve) => {
527
+ timer = setTimeout(resolve, millis);
528
+ }),
529
+ ]);
530
+ } finally {
531
+ clearTimeout(timer);
532
+ }
533
+ };
534
+
535
+ const readActionTargetAfter = (page: Page, selector: string) =>
536
+ boundedBestEffort(
537
+ readActionTarget(page, selector).then(Schema.decodeUnknownSync(ActionTargetState)),
538
+ ACTION_POST_STATE_MILLIS,
539
+ );
540
+
541
+ const makeActionRequestTracker = (page: Page, signal: AbortSignal) => {
542
+ const pending = new Set<HTTPRequest>();
543
+ let total = 0;
544
+ let status2xx = 0;
545
+ let status3xx = 0;
546
+ let status4xx = 0;
547
+ let status5xx = 0;
548
+ let failed = 0;
549
+ let lastChange = performance.now();
550
+ let closed = false;
551
+ let wake: (() => void) | undefined;
552
+ let timer: ReturnType<typeof setTimeout> | number | undefined;
553
+
554
+ const relevant = (request: HTTPRequest) => {
555
+ const resourceType = request.resourceType();
556
+ return resourceType === "fetch" || resourceType === "xhr";
557
+ };
558
+ const onRequest = (request: HTTPRequest) => {
559
+ if (!relevant(request)) return;
560
+ pending.add(request);
561
+ total++;
562
+ lastChange = performance.now();
563
+ };
564
+ const onFinished = (request: HTTPRequest) => {
565
+ if (!pending.delete(request)) return;
566
+ let status: number | undefined;
567
+ try {
568
+ status = request.response()?.status();
569
+ } catch {
570
+ // Provider response details remain intentionally unavailable to diagnostics.
571
+ }
572
+ if (status !== undefined) {
573
+ if (status >= 200 && status < 300) status2xx++;
574
+ else if (status >= 300 && status < 400) status3xx++;
575
+ else if (status >= 400 && status < 500) status4xx++;
576
+ else if (status >= 500 && status < 600) status5xx++;
577
+ }
578
+ lastChange = performance.now();
579
+ };
580
+ const onFailed = (request: HTTPRequest) => {
581
+ if (!pending.delete(request)) return;
582
+ failed++;
583
+ lastChange = performance.now();
584
+ };
585
+ const close = () => {
586
+ if (closed) return;
587
+ closed = true;
588
+ page.off("request", onRequest);
589
+ page.off("requestfinished", onFinished);
590
+ page.off("requestfailed", onFailed);
591
+ signal.removeEventListener("abort", close);
592
+ clearTimeout(timer);
593
+ wake?.();
594
+ };
595
+
596
+ try {
597
+ page.on("request", onRequest);
598
+ page.on("requestfinished", onFinished);
599
+ page.on("requestfailed", onFailed);
600
+ signal.addEventListener("abort", close, { once: true });
601
+ if (signal.aborted) close();
602
+ } catch (cause) {
603
+ close();
604
+ throw cause;
605
+ }
606
+
607
+ const wait = async () => {
608
+ const startedAt = performance.now();
609
+ let settleTimedOut = false;
610
+ while (!closed) {
611
+ const now = performance.now();
612
+ if (pending.size === 0 && now - lastChange >= ACTION_NETWORK_QUIET_MILLIS) break;
613
+ if (now - startedAt >= ACTION_NETWORK_SETTLE_MILLIS) {
614
+ settleTimedOut = true;
615
+ break;
616
+ }
617
+ await new Promise<void>((resolve) => {
618
+ wake = resolve;
619
+ timer = setTimeout(() => resolve(), 50);
620
+ });
621
+ wake = undefined;
622
+ }
623
+ return {
624
+ total,
625
+ status2xx,
626
+ status3xx,
627
+ status4xx,
628
+ status5xx,
629
+ failed,
630
+ pending: pending.size,
631
+ settleTimedOut,
632
+ };
633
+ };
634
+
635
+ return {
636
+ wait,
637
+ close,
638
+ markActionSettled: () => {
639
+ lastChange = performance.now();
640
+ },
641
+ };
642
+ };
643
+
644
+ const disposeActionHandles = async (handles: ReadonlyArray<{ dispose: () => Promise<void> }>) => {
645
+ await boundedBestEffort(
646
+ Promise.allSettled(handles.map(async (handle) => handle.dispose())),
647
+ ACTION_POST_STATE_MILLIS,
648
+ );
649
+ };
650
+
651
+ const runObservedPageAction = async (
652
+ page: Page,
653
+ selector: string,
654
+ signal: AbortSignal,
655
+ onDispatch: () => void,
656
+ action: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<void>,
657
+ ): Promise<unknown> => {
658
+ if (signal.aborted) throw new BrowserRunActionUndispatched(0);
659
+ const before = Schema.decodeUnknownSync(ActionTargetState)(
660
+ await readActionTarget(page, selector),
661
+ );
662
+ if (before.matchCount !== 1 || before.invalidSelector === true) {
663
+ throw new BrowserRunActionUndispatched(before.matchCount);
664
+ }
665
+ const matches = await page.$$(selector);
666
+ if (matches.length !== 1 || matches[0] === undefined) {
667
+ await disposeActionHandles(matches);
668
+ throw new BrowserRunActionUndispatched(Math.min(10_000, matches.length));
669
+ }
670
+ if (signal.aborted) {
671
+ await disposeActionHandles(matches);
672
+ throw new BrowserRunActionUndispatched(1);
673
+ }
674
+
675
+ let tracker: ReturnType<typeof makeActionRequestTracker> | undefined;
676
+ let disposing: Promise<void> | undefined;
677
+ const dispose = () => (disposing ??= disposeActionHandles(matches));
678
+ const onAbort = () => {
679
+ void dispose();
680
+ };
681
+ signal.addEventListener("abort", onAbort, { once: true });
682
+ try {
683
+ tracker = makeActionRequestTracker(page, signal);
684
+ // No await between the final cancellation fence and SDK dispatch. Once
685
+ // dispatched, interruption is uncertain, even if the SDK later resolves.
686
+ if (signal.aborted) throw new BrowserRunActionUndispatched(1);
687
+ onDispatch();
688
+ await action(matches[0]);
689
+ tracker.markActionSettled();
690
+ const network = await tracker.wait();
691
+ const after = signal.aborted ? undefined : await readActionTargetAfter(page, selector);
692
+ return {
693
+ before,
694
+ ...(after === undefined ? {} : { after }),
695
+ afterUnavailable: after === undefined,
696
+ network,
697
+ };
698
+ } finally {
699
+ tracker?.close();
700
+ signal.removeEventListener("abort", onAbort);
701
+ await dispose();
702
+ }
703
+ };
704
+
297
705
  const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
298
706
  const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
299
707
  return {
@@ -313,38 +721,234 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
313
721
  }
314
722
  },
315
723
  goto: async (url) => {
316
- await page.goto(url, { waitUntil: "networkidle0", timeout: 0 });
724
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
317
725
  },
318
726
  url: () => page.url(),
319
- readText: (selector, maximumBytes) =>
320
- page.evaluate(
321
- (requestedSelector, maximum) => {
322
- const pageDocument = Reflect.get(globalThis, "document");
323
- const element =
324
- requestedSelector === undefined
325
- ? Reflect.get(pageDocument, "body")
326
- : Reflect.apply(Reflect.get(pageDocument, "querySelector"), pageDocument, [
327
- requestedSelector,
328
- ]);
329
- if (element === null) return { _tag: "MissingElement" };
330
- const innerText = Reflect.get(element, "innerText");
331
- const textContent = Reflect.get(element, "textContent");
332
- const text =
333
- typeof innerText === "string"
334
- ? innerText
335
- : typeof textContent === "string"
336
- ? textContent
337
- : "";
338
- const observed = new TextEncoder().encode(text).byteLength;
339
- return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
340
- },
341
- selector,
342
- maximumBytes,
343
- ),
344
- fill: async (selector, value) => {
345
- await page.$eval(
346
- selector,
347
- (element, nextValue) => {
727
+ readText: async (selector, maximumBytes) => {
728
+ const observation = Schema.decodeUnknownSync(TextObservation)(
729
+ await page.evaluate(
730
+ (requestedSelector, maximum, maximumControls) => {
731
+ const pageDocument = Reflect.get(globalThis, "document");
732
+ const matches =
733
+ requestedSelector === undefined
734
+ ? undefined
735
+ : Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
736
+ requestedSelector,
737
+ ]);
738
+ const selectorMatchCount =
739
+ matches === undefined || matches === null
740
+ ? 1
741
+ : Math.min(10_000, Reflect.get(matches, "length"));
742
+ const element =
743
+ matches === undefined || matches === null
744
+ ? Reflect.get(pageDocument, "body")
745
+ : Reflect.get(matches, 0);
746
+ if (element === null) return { _tag: "MissingElement" };
747
+ if (element === undefined) return { _tag: "MissingElement" };
748
+ const innerText = Reflect.get(element, "innerText");
749
+ const textContent = Reflect.get(element, "textContent");
750
+ const pageText =
751
+ typeof innerText === "string"
752
+ ? innerText
753
+ : typeof textContent === "string"
754
+ ? textContent
755
+ : "";
756
+ const primaryControlSelector =
757
+ 'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
758
+ const optionSelector = "select option";
759
+ const secondaryControlSelector = "a[href]";
760
+ const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
761
+ const selectorFor = (candidate: object) => {
762
+ const parts: Array<string> = [];
763
+ let current: object | null = candidate;
764
+ while (current !== null && current !== undefined) {
765
+ const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
766
+ if (tagName === "") break;
767
+ const parent: object | null = Reflect.get(current, "parentElement");
768
+ if (parent === null) {
769
+ parts.push(tagName);
770
+ break;
771
+ }
772
+ let sibling = Reflect.get(current, "previousElementSibling");
773
+ let index = 1;
774
+ while (sibling !== null && sibling !== undefined) {
775
+ if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName)
776
+ index++;
777
+ sibling = Reflect.get(sibling, "previousElementSibling");
778
+ }
779
+ parts.push(`${tagName}:nth-of-type(${index})`);
780
+ current = parent;
781
+ }
782
+ return parts.reverse().join(" > ");
783
+ };
784
+ const visible = (candidate: object) => {
785
+ const hidden = Reflect.get(candidate, "hidden");
786
+ const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
787
+ "aria-hidden",
788
+ ]);
789
+ const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
790
+ return (
791
+ hidden !== true &&
792
+ ariaHidden !== "true" &&
793
+ typeof rects === "object" &&
794
+ rects !== null &&
795
+ Reflect.get(rects, "length") > 0
796
+ );
797
+ };
798
+ const candidates: Array<object> = [];
799
+ let controlsTruncated = false;
800
+ const consider = (candidate: object) => {
801
+ const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
802
+ // Collapsed native options have no client rects. Their owning
803
+ // select determines visibility; observe text/selection, never value.
804
+ const visibilityTarget =
805
+ tagName === "option"
806
+ ? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
807
+ : candidate;
808
+ const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
809
+ if (
810
+ !actionable ||
811
+ typeof visibilityTarget !== "object" ||
812
+ visibilityTarget === null ||
813
+ !visible(visibilityTarget)
814
+ )
815
+ return;
816
+ candidates.push(candidate);
817
+ if (candidates.length > maximumControls) controlsTruncated = true;
818
+ };
819
+ const elementMatches = Reflect.get(element, "matches");
820
+ if (
821
+ typeof elementMatches === "function" &&
822
+ Reflect.apply(elementMatches, element, [controlSelector])
823
+ ) {
824
+ consider(element);
825
+ }
826
+ const considerSelector = (candidateSelector: string) => {
827
+ const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
828
+ candidateSelector,
829
+ ]);
830
+ if (typeof descendants !== "object" || descendants === null) return;
831
+ const descendantCount = Reflect.get(descendants, "length");
832
+ for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
833
+ consider(Reflect.get(descendants, index));
834
+ }
835
+ };
836
+ considerSelector(primaryControlSelector);
837
+ if (!controlsTruncated) considerSelector(optionSelector);
838
+ if (!controlsTruncated) considerSelector(secondaryControlSelector);
839
+ const controls = candidates.slice(0, maximumControls).map((candidate) => {
840
+ const associated = Reflect.get(candidate, "control") ?? candidate;
841
+ const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
842
+ const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
843
+ const role = String(
844
+ Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
845
+ ).toLowerCase();
846
+ const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
847
+ "aria-label",
848
+ ]);
849
+ const candidateText =
850
+ tagName === "textarea" || tagName === "input" || tagName === "select"
851
+ ? undefined
852
+ : Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
853
+ const associatedLabels = Reflect.get(associated, "labels");
854
+ const associatedLabel =
855
+ associatedLabels !== undefined &&
856
+ associatedLabels !== null &&
857
+ Reflect.get(associatedLabels, "length") > 0
858
+ ? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
859
+ : undefined;
860
+ const label = String(
861
+ typeof ariaLabel === "string" && ariaLabel !== ""
862
+ ? ariaLabel
863
+ : typeof candidateText === "string" && candidateText !== ""
864
+ ? candidateText
865
+ : (associatedLabel ?? ""),
866
+ )
867
+ .replace(/\s+/g, " ")
868
+ .trim()
869
+ .slice(0, 200);
870
+ const checked = Reflect.get(associated, "checked");
871
+ const selected =
872
+ tagName === "select"
873
+ ? Reflect.get(associated, "selectedIndex") >= 0
874
+ : Reflect.get(associated, "selected");
875
+ const disabled = Reflect.get(associated, "disabled");
876
+ const required = Reflect.get(associated, "required");
877
+ const validity = Reflect.get(associated, "validity");
878
+ const form = Reflect.get(associated, "form");
879
+ const formMatches =
880
+ form === null || form === undefined ? undefined : Reflect.get(form, "matches");
881
+ const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
882
+ "aria-checked",
883
+ ]);
884
+ const ariaSelected = Reflect.apply(
885
+ Reflect.get(candidate, "getAttribute"),
886
+ candidate,
887
+ ["aria-selected"],
888
+ );
889
+ const ariaDisabled = Reflect.apply(
890
+ Reflect.get(candidate, "getAttribute"),
891
+ candidate,
892
+ ["aria-disabled"],
893
+ );
894
+
895
+ return {
896
+ selector: selectorFor(candidate),
897
+ kind:
898
+ tagName === "label"
899
+ ? `label:${inputType || "control"}`
900
+ : tagName === "input"
901
+ ? `input:${inputType || "text"}`
902
+ : role !== ""
903
+ ? `role:${role}`
904
+ : tagName,
905
+ ...(label === "" ? {} : { label }),
906
+ ...(typeof checked === "boolean"
907
+ ? { checked }
908
+ : ariaChecked === "true" || ariaChecked === "false"
909
+ ? { checked: ariaChecked === "true" }
910
+ : {}),
911
+ ...(typeof selected === "boolean"
912
+ ? { selected }
913
+ : ariaSelected === "true" || ariaSelected === "false"
914
+ ? { selected: ariaSelected === "true" }
915
+ : {}),
916
+ ...(typeof disabled === "boolean"
917
+ ? { disabled }
918
+ : ariaDisabled === "true" || ariaDisabled === "false"
919
+ ? { disabled: ariaDisabled === "true" }
920
+ : {}),
921
+ ...(typeof required === "boolean" ? { required } : {}),
922
+ ...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
923
+ ? { valid: Reflect.get(validity, "valid") }
924
+ : {}),
925
+ ...(typeof formMatches === "function"
926
+ ? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
927
+ : {}),
928
+ };
929
+ });
930
+ // JSON is the bounded wire representation of this existing text result.
931
+ // eslint-disable-next-line no-restricted-properties
932
+ const text = JSON.stringify({
933
+ pageText,
934
+ selectorMatchCount,
935
+ controls,
936
+ controlsTruncated,
937
+ });
938
+ const observed = new TextEncoder().encode(text).byteLength;
939
+ return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
940
+ },
941
+ selector,
942
+ maximumBytes,
943
+ MAX_OBSERVED_CONTROLS,
944
+ ),
945
+ );
946
+ if (observation._tag === "Text") Schema.decodeUnknownSync(PageObservation)(observation.text);
947
+ return observation;
948
+ },
949
+ fill: (selector, value, signal, onDispatch) =>
950
+ runObservedPageAction(page, selector, signal, onDispatch, (element) =>
951
+ element.evaluate((element, nextValue) => {
348
952
  // Bypass instance setters so React can detect the change when events fire.
349
953
  let prototype = Reflect.getPrototypeOf(element);
350
954
  let setValue: ((value: string) => void) | undefined;
@@ -367,11 +971,10 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
367
971
  Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
368
972
  Reflect.apply(dispatchEvent, element, [new Event("change", { bubbles: true })]);
369
973
  }
370
- },
371
- value,
372
- );
373
- },
374
- click: (selector) => page.click(selector),
974
+ }, value),
975
+ ),
976
+ click: (selector, signal, onDispatch) =>
977
+ runObservedPageAction(page, selector, signal, onDispatch, (element) => element.click()),
375
978
  // Puppeteer materializes the complete image before returning. The adapter
376
979
  // validates the 8 MiB Schema ceiling and pass limit immediately afterward.
377
980
  screenshot: (fullPage) => page.screenshot({ type: "png", fullPage }),
@@ -385,6 +988,7 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
385
988
  deltaY,
386
989
  ),
387
990
  createCdpSession: async () => makeProductionCdpSession(await page.createCDPSession()),
991
+ setViewport: (viewport) => page.setViewport(viewport),
388
992
  };
389
993
  };
390
994
 
@@ -421,6 +1025,20 @@ const actionError = (operation: BrowserOperation, cause?: unknown): InteractiveB
421
1025
  ...(cause === undefined ? {} : { cause }),
422
1026
  });
423
1027
 
1028
+ const undispatchedActionError = (operation: BrowserOperation): InteractiveBrowserActionError =>
1029
+ InteractiveBrowserActionError.make({
1030
+ implementation: browserRunInteractiveImplementation,
1031
+ operation,
1032
+ message: `The interactive browser ${operation} operation was not dispatched`,
1033
+ });
1034
+
1035
+ /** Recognizes a local pre-dispatch refusal without exposing selector or page content. */
1036
+ export const isBrowserRunUndispatchedActionError = (error: unknown): boolean =>
1037
+ Schema.is(InteractiveBrowserActionError)(error) &&
1038
+ error.implementation.identity === browserRunInteractiveImplementation.identity &&
1039
+ (error.operation === "click" || error.operation === "fill") &&
1040
+ error.message === `The interactive browser ${error.operation} operation was not dispatched`;
1041
+
424
1042
  const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
425
1043
  InteractiveBrowserPolicyDeniedError.make({
426
1044
  implementation: browserRunInteractiveImplementation,
@@ -590,9 +1208,18 @@ interface HandleRuntime {
590
1208
  readonly run: <A>(
591
1209
  effect: Effect.Effect<A, BrowserFailure>,
592
1210
  preflight?: Effect.Effect<void, BrowserFailure>,
1211
+ consumeAction?: boolean,
593
1212
  ) => Effect.Effect<A, BrowserFailure>;
594
1213
  }
595
1214
 
1215
+ class BrowserRunRemoteFailure {
1216
+ readonly cause: unknown;
1217
+
1218
+ constructor(cause: unknown) {
1219
+ this.cause = cause;
1220
+ }
1221
+ }
1222
+
596
1223
  const awaitPendingRequests = (state: HandleState): Effect.Effect<void> =>
597
1224
  Effect.suspend(() => {
598
1225
  const pending = [...state.pendingRequests];
@@ -685,21 +1312,141 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
685
1312
  const permits = yield* Semaphore.make(1);
686
1313
  const actions = yield* Ref.make(0);
687
1314
 
688
- const remote = <A>(operation: BrowserOperation, evaluate: () => Promise<A>) =>
1315
+ const remote = <A>(operation: BrowserOperation, evaluate: (signal: AbortSignal) => Promise<A>) =>
689
1316
  Effect.tryPromise({
690
1317
  try: evaluate,
691
- catch: (cause) => {
692
- if (state.disconnected.value || isRemoteClosure(cause)) {
693
- state.disconnected.value = true;
694
- return expiredError();
695
- }
696
- return actionError(operation, cause);
697
- },
1318
+ catch: (cause) => new BrowserRunRemoteFailure(cause),
1319
+ }).pipe(
1320
+ Effect.catch(
1321
+ (
1322
+ failure,
1323
+ ): Effect.Effect<never, InteractiveBrowserActionError | InteractiveBrowserExpiredError> => {
1324
+ const cause = failure.cause;
1325
+ if (cause instanceof BrowserRunActionUndispatched) {
1326
+ return Effect.logInfo("Browser interactive action was not dispatched").pipe(
1327
+ Effect.annotateLogs({
1328
+ "browser.action": operation,
1329
+ "browser.selector_match_count": cause.matchCount,
1330
+ }),
1331
+ Effect.andThen(Effect.fail(undispatchedActionError(operation))),
1332
+ );
1333
+ }
1334
+ if (state.disconnected.value || isRemoteClosure(cause)) {
1335
+ state.disconnected.value = true;
1336
+ return Effect.fail(expiredError());
1337
+ }
1338
+ return Effect.fail(actionError(operation, cause));
1339
+ },
1340
+ ),
1341
+ );
1342
+
1343
+ const observedAction = (
1344
+ operation: "fill" | "click",
1345
+ evaluate: (signal: AbortSignal, onDispatch: () => void) => Promise<unknown>,
1346
+ ) =>
1347
+ Effect.suspend(() => {
1348
+ let dispatched = false;
1349
+ let pending: Promise<unknown> | undefined;
1350
+ return remote(operation, (signal) => {
1351
+ pending = evaluate(signal, () => {
1352
+ dispatched = true;
1353
+ });
1354
+ return pending;
1355
+ }).pipe(
1356
+ Effect.onInterrupt(() =>
1357
+ Effect.gen(function* () {
1358
+ state.uncertain.value = true;
1359
+ // Retain evidence before Scope teardown, without claiming SDK
1360
+ // cancellation or success. A late completion never makes this replayable.
1361
+ yield* Effect.logWarning("Browser interactive action interrupted").pipe(
1362
+ Effect.annotateLogs({
1363
+ "browser.action": operation,
1364
+ "browser.action_dispatched": dispatched,
1365
+ "browser.action_outcome_unknown": dispatched,
1366
+ }),
1367
+ );
1368
+ const completion = pending;
1369
+ if (completion !== undefined) {
1370
+ yield* Effect.promise(() => boundedBestEffort(completion, 500));
1371
+ }
1372
+ }),
1373
+ ),
1374
+ );
698
1375
  });
699
1376
 
1377
+ const decodeActionObservation = Effect.fn("BrowserRunInteractive.decodeActionObservation")(
1378
+ function* (raw: unknown) {
1379
+ return yield* Schema.decodeUnknownEffect(ActionObservation)(raw).pipe(
1380
+ Effect.mapError((cause) =>
1381
+ protocolError("The browser returned a malformed action observation", cause),
1382
+ ),
1383
+ );
1384
+ },
1385
+ );
1386
+
1387
+ const logActionObservation = (
1388
+ operation: "fill" | "click",
1389
+ observation: typeof ActionObservation.Type,
1390
+ ) =>
1391
+ Effect.logInfo("Browser interactive action observed").pipe(
1392
+ Effect.annotateLogs({
1393
+ "browser.action": operation,
1394
+ "browser.selector_match_count": observation.before.matchCount,
1395
+ ...(observation.before.kind === undefined
1396
+ ? {}
1397
+ : { "browser.target_kind": observation.before.kind }),
1398
+ ...(observation.before.checked === undefined
1399
+ ? {}
1400
+ : { "browser.target_checked_before": observation.before.checked }),
1401
+ ...(observation.after?.checked === undefined
1402
+ ? {}
1403
+ : { "browser.target_checked_after": observation.after.checked }),
1404
+ ...(observation.before.selected === undefined
1405
+ ? {}
1406
+ : { "browser.target_selected_before": observation.before.selected }),
1407
+ ...(observation.after?.selected === undefined
1408
+ ? {}
1409
+ : { "browser.target_selected_after": observation.after.selected }),
1410
+ ...(observation.before.disabled === undefined
1411
+ ? {}
1412
+ : { "browser.target_disabled_before": observation.before.disabled }),
1413
+ ...(observation.after?.disabled === undefined
1414
+ ? {}
1415
+ : { "browser.target_disabled_after": observation.after.disabled }),
1416
+ ...(observation.before.required === undefined
1417
+ ? {}
1418
+ : { "browser.target_required_before": observation.before.required }),
1419
+ ...(observation.after?.required === undefined
1420
+ ? {}
1421
+ : { "browser.target_required_after": observation.after.required }),
1422
+ ...(observation.before.valid === undefined
1423
+ ? {}
1424
+ : { "browser.target_valid_before": observation.before.valid }),
1425
+ ...(observation.after?.valid === undefined
1426
+ ? {}
1427
+ : { "browser.target_valid_after": observation.after.valid }),
1428
+ ...(observation.before.formValid === undefined
1429
+ ? {}
1430
+ : { "browser.form_valid_before": observation.before.formValid }),
1431
+ ...(observation.after?.formValid === undefined
1432
+ ? {}
1433
+ : { "browser.form_valid_after": observation.after.formValid }),
1434
+ "browser.target_after_unavailable": observation.afterUnavailable,
1435
+ "browser.fetch_xhr_total": observation.network.total,
1436
+ "browser.fetch_xhr_2xx": observation.network.status2xx,
1437
+ "browser.fetch_xhr_3xx": observation.network.status3xx,
1438
+ "browser.fetch_xhr_4xx": observation.network.status4xx,
1439
+ "browser.fetch_xhr_5xx": observation.network.status5xx,
1440
+ "browser.fetch_xhr_failed": observation.network.failed,
1441
+ "browser.fetch_xhr_pending": observation.network.pending,
1442
+ "browser.network_settle_timed_out": observation.network.settleTimedOut,
1443
+ }),
1444
+ );
1445
+
700
1446
  const run = <A>(
701
1447
  effect: Effect.Effect<A, BrowserFailure>,
702
1448
  preflight: Effect.Effect<void, BrowserFailure> = Effect.void,
1449
+ consumeAction = true,
703
1450
  ) =>
704
1451
  permits
705
1452
  .withPermitsIfAvailable(1)(
@@ -708,19 +1455,21 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
708
1455
  if (unavailable !== undefined) return yield* unavailable;
709
1456
  yield* preflight;
710
1457
 
711
- const admitted = yield* Ref.modify(actions, (count) =>
712
- count >= policy.maxActions
713
- ? [{ allowed: false, observed: count + 1 }, count]
714
- : [{ allowed: true, observed: count + 1 }, count + 1],
715
- );
716
- if (!admitted.allowed) {
717
- return yield* InteractiveBrowserLimitError.make({
718
- implementation: browserRunInteractiveImplementation,
719
- limit: "actions",
720
- maximum: policy.maxActions,
721
- observed: admitted.observed,
722
- message: "The browser action limit was reached",
723
- });
1458
+ if (consumeAction) {
1459
+ const admitted = yield* Ref.modify(actions, (count) =>
1460
+ count >= policy.maxActions
1461
+ ? [{ allowed: false, observed: count + 1 }, count]
1462
+ : [{ allowed: true, observed: count + 1 }, count + 1],
1463
+ );
1464
+ if (!admitted.allowed) {
1465
+ return yield* InteractiveBrowserLimitError.make({
1466
+ implementation: browserRunInteractiveImplementation,
1467
+ limit: "actions",
1468
+ maximum: policy.maxActions,
1469
+ observed: admitted.observed,
1470
+ message: "The browser action limit was reached",
1471
+ });
1472
+ }
724
1473
  }
725
1474
 
726
1475
  const completed = effect.pipe(
@@ -750,6 +1499,12 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
750
1499
  return Effect.fail(error);
751
1500
  }
752
1501
  const failure = stateFailure(state);
1502
+ if (
1503
+ Schema.is(InteractiveBrowserActionError)(error) &&
1504
+ !isBrowserRunUndispatchedActionError(error)
1505
+ ) {
1506
+ state.uncertain.value = true;
1507
+ }
753
1508
  return Effect.fail(failure ?? error);
754
1509
  }),
755
1510
  );
@@ -825,15 +1580,23 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
825
1580
  ),
826
1581
  fill: (request) =>
827
1582
  run(
828
- remote("fill", () => page.fill(request.selector, request.value)).pipe(
829
- Effect.andThen(decodeActionResult(page, policy)),
830
- ),
1583
+ Effect.gen(function* () {
1584
+ const observation = yield* observedAction("fill", (signal, onDispatch) =>
1585
+ page.fill(request.selector, request.value, signal, onDispatch),
1586
+ ).pipe(Effect.flatMap(decodeActionObservation));
1587
+ yield* logActionObservation("fill", observation);
1588
+ return yield* decodeActionResult(page, policy);
1589
+ }),
831
1590
  ),
832
1591
  click: (request) =>
833
1592
  run(
834
- remote("click", () => page.click(request.selector)).pipe(
835
- Effect.andThen(decodeActionResult(page, policy)),
836
- ),
1593
+ Effect.gen(function* () {
1594
+ const observation = yield* observedAction("click", (signal, onDispatch) =>
1595
+ page.click(request.selector, signal, onDispatch),
1596
+ ).pipe(Effect.flatMap(decodeActionObservation));
1597
+ yield* logActionObservation("click", observation);
1598
+ return yield* decodeActionResult(page, policy);
1599
+ }),
837
1600
  ),
838
1601
  screenshot: (request) =>
839
1602
  Schema.decodeUnknownEffect(BrowserScreenshotRequest)(request).pipe(
@@ -970,6 +1733,55 @@ const cdpCommand = <A>(
970
1733
  const makeHostService = (
971
1734
  binding: BrowserRunInteractiveBinding["Service"],
972
1735
  ): BrowserRunInteractiveHost["Service"] => {
1736
+ const closeSession = binding.closeSession;
1737
+ const terminate = Effect.fn("BrowserRunInteractiveHost.terminate")(function* (
1738
+ sessionId: Redacted.Redacted<string>,
1739
+ entries: ReadonlyArray<CloseEntry>,
1740
+ ) {
1741
+ const deadline = (yield* Clock.currentTimeMillis) + 10_000;
1742
+ yield* closeSession(sessionId).pipe(
1743
+ Effect.interruptible,
1744
+ Effect.timeoutOrElse({
1745
+ duration: "10 seconds",
1746
+ orElse: () => Effect.fail(actionError("close")),
1747
+ }),
1748
+ );
1749
+ const remaining = deadline - (yield* Clock.currentTimeMillis);
1750
+ if (remaining <= 0)
1751
+ return yield* Effect.logWarning(
1752
+ "Local browser teardown skipped after confirmed termination deadline",
1753
+ );
1754
+ // Remote termination is authoritative. Local cleanup must not veto it or extend the deadline.
1755
+ yield* runTeardown(entries).pipe(
1756
+ Effect.flatMap((failures) =>
1757
+ Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning), {
1758
+ discard: true,
1759
+ }),
1760
+ ),
1761
+ Effect.interruptible,
1762
+ Effect.timeout(`${remaining} millis`),
1763
+ Effect.catchCause(() =>
1764
+ Effect.logWarning("Local browser teardown incomplete after confirmed termination"),
1765
+ ),
1766
+ );
1767
+ });
1768
+ const closeAcquired = Effect.fn("BrowserRunInteractiveHost.closeAcquired")(function* (
1769
+ browser: BrowserRunInteractiveBrowser,
1770
+ ) {
1771
+ const sessionId = yield* Effect.try({
1772
+ try: browser.sessionId,
1773
+ catch: () => actionError("close"),
1774
+ }).pipe(
1775
+ Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
1776
+ Effect.mapError(() => actionError("close")),
1777
+ Effect.onError(() =>
1778
+ closeWithWarning(browser.close, "Closing an unidentified browser failed"),
1779
+ ),
1780
+ );
1781
+ yield* terminate(Redacted.make(sessionId), [
1782
+ closeEntry(browser.close, "Closing the local browser connection failed"),
1783
+ ]);
1784
+ });
973
1785
  const open = Effect.fn("BrowserRunInteractiveHost.open")(function* (
974
1786
  policy: InteractiveBrowserPolicy,
975
1787
  ): Effect.fn.Return<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope> {
@@ -984,7 +1796,6 @@ const makeHostService = (
984
1796
  };
985
1797
  const lifecycle = {
986
1798
  managedTeardownInstalled: false,
987
- explicitCloseInvoked: false,
988
1799
  };
989
1800
  const closers: Array<CloseEntry> = [];
990
1801
  const releaseBeforeManaged = (entry: CloseEntry): Effect.Effect<void> =>
@@ -1001,7 +1812,7 @@ const makeHostService = (
1001
1812
  closeLateAcquisition(
1002
1813
  signal,
1003
1814
  () => binding.launch(keepAliveMillis(fixedPolicy)),
1004
- (acquired) => acquired.close(),
1815
+ (acquired) => Effect.runPromise(closeAcquired(acquired)),
1005
1816
  ),
1006
1817
  catch: (cause) =>
1007
1818
  isCapacityRefusal(cause)
@@ -1016,9 +1827,11 @@ const makeHostService = (
1016
1827
  ),
1017
1828
  (acquired) => {
1018
1829
  state.disconnected.value = true;
1019
- return releaseBeforeManaged(
1020
- closeEntry(acquired.close, "Closing the interactive browser failed"),
1021
- );
1830
+ return lifecycle.managedTeardownInstalled
1831
+ ? Effect.void
1832
+ : closeAcquired(acquired).pipe(
1833
+ Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
1834
+ );
1022
1835
  },
1023
1836
  { interruptible: true },
1024
1837
  );
@@ -1152,7 +1965,7 @@ const makeHostService = (
1152
1965
 
1153
1966
  const teardown = yield* Effect.uninterruptible(
1154
1967
  Effect.gen(function* () {
1155
- const cached = yield* Effect.cached(runTeardown(closers));
1968
+ const cached = yield* Effect.cached(terminate(Redacted.make(sessionIdValue), closers));
1156
1969
  lifecycle.managedTeardownInstalled = true;
1157
1970
  yield* Effect.addFinalizer(() =>
1158
1971
  Effect.uninterruptible(
@@ -1161,13 +1974,7 @@ const makeHostService = (
1161
1974
  state.disconnected.value = true;
1162
1975
  }).pipe(
1163
1976
  Effect.andThen(cached),
1164
- Effect.flatMap((failures) =>
1165
- lifecycle.explicitCloseInvoked
1166
- ? Effect.void
1167
- : Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning)).pipe(
1168
- Effect.asVoid,
1169
- ),
1170
- ),
1977
+ Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
1171
1978
  ),
1172
1979
  ),
1173
1980
  );
@@ -1177,15 +1984,9 @@ const makeHostService = (
1177
1984
 
1178
1985
  const close: Effect.Effect<void, InteractiveBrowserError> = Effect.uninterruptible(
1179
1986
  Effect.sync(() => {
1180
- lifecycle.explicitCloseInvoked = true;
1181
1987
  state.closed.value = true;
1182
1988
  state.disconnected.value = true;
1183
- }).pipe(
1184
- Effect.andThen(teardown),
1185
- Effect.flatMap((failures) =>
1186
- failures[0] === undefined ? Effect.void : Effect.fail(failures[0].error),
1187
- ),
1188
- ),
1989
+ }).pipe(Effect.andThen(teardown)),
1189
1990
  );
1190
1991
 
1191
1992
  const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
@@ -1204,6 +2005,25 @@ const makeHostService = (
1204
2005
  return {
1205
2006
  handle: runtime.handle,
1206
2007
  sessionId: Redacted.make(sessionIdValue),
2008
+ resizeViewport: (viewport) =>
2009
+ decodeViewport(viewport).pipe(
2010
+ Effect.flatMap((decoded) =>
2011
+ runtime.run(
2012
+ Effect.tryPromise({
2013
+ try: () => page.setViewport(decoded),
2014
+ catch: (cause) => {
2015
+ if (state.disconnected.value || isRemoteClosure(cause)) {
2016
+ state.disconnected.value = true;
2017
+ return expiredError();
2018
+ }
2019
+ return protocolError("Resizing the browser viewport failed", cause);
2020
+ },
2021
+ }),
2022
+ currentPagePreflight,
2023
+ false,
2024
+ ),
2025
+ ),
2026
+ ),
1207
2027
  getLiveView: (request) =>
1208
2028
  Schema.decodeUnknownEffect(BrowserRunLiveViewRequest)(request).pipe(
1209
2029
  Effect.mapError(() => policyError("The Live View request is malformed")),
@@ -1289,52 +2109,11 @@ const makeHostService = (
1289
2109
  };
1290
2110
  });
1291
2111
 
1292
- const closeSession = Effect.fn("BrowserRunInteractiveHost.closeSession")(function* (
1293
- sessionId: Redacted.Redacted<string>,
1294
- ) {
1295
- const decoded = yield* Schema.decodeUnknownEffect(Schema.Redacted(BrowserRunSessionId))(
1296
- sessionId,
1297
- ).pipe(
1298
- Effect.mapError(() => policyError("The Browser Run cleanup session identity is malformed")),
1299
- );
1300
- return yield* Effect.scoped(
1301
- Effect.gen(function* () {
1302
- const closeAttempted = { value: false };
1303
- const browser = yield* Effect.acquireRelease(
1304
- Effect.tryPromise({
1305
- try: (signal) =>
1306
- closeLateAcquisition(
1307
- signal,
1308
- () => binding.connect(Redacted.value(decoded)),
1309
- (acquired) => acquired.close(),
1310
- ),
1311
- catch: (cause) => actionError("close", cause),
1312
- }),
1313
- (acquired) =>
1314
- closeAttempted.value
1315
- ? Effect.void
1316
- : closeWithWarning(acquired.close, "Closing the leaked Browser Run session failed"),
1317
- { interruptible: true },
1318
- );
1319
- return yield* Effect.tryPromise({
1320
- try: () => {
1321
- // Mark and start are synchronous so interruption cannot suppress the
1322
- // Scope fallback before the one remote close attempt begins.
1323
- closeAttempted.value = true;
1324
- return browser.close();
1325
- },
1326
- catch: (cause) => actionError("close", cause),
1327
- });
1328
- }),
1329
- ).pipe(
1330
- Effect.timeoutOrElse({
1331
- duration: Duration.millis(CLOSE_SESSION_TIMEOUT_MILLIS),
1332
- orElse: () => Effect.fail(actionError("close")),
1333
- }),
1334
- );
2112
+ return BrowserRunInteractiveHost.of({
2113
+ open,
2114
+ closeSession,
2115
+ cleanupSemantics: "confirmed-terminal",
1335
2116
  });
1336
-
1337
- return BrowserRunInteractiveHost.of({ open, closeSession });
1338
2117
  };
1339
2118
 
1340
2119
  /** Cloudflare host controls and private session identity for one scoped Browser Run pass. */