@effect-agent/platform-cloudflare 0.1.0-beta.106 → 0.1.0-beta.108

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.
@@ -1,6 +1,6 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
 
3
- import puppeteer, {
3
+ import {
4
4
  type Browser,
5
5
  type BrowserContext,
6
6
  type CDPSession,
@@ -8,6 +8,7 @@ import puppeteer, {
8
8
  type Page,
9
9
  } from "@cloudflare/puppeteer";
10
10
  import {
11
+ Cause,
11
12
  Context,
12
13
  Clock,
13
14
  Duration,
@@ -21,6 +22,8 @@ import {
21
22
  } from "effect";
22
23
  import {
23
24
  BrowserActionResult,
25
+ BrowserSelectFileRequest,
26
+ BrowserFileSelectionResult,
24
27
  BrowserScreenshotRequest,
25
28
  BrowserScrollRequest,
26
29
  BrowserNavigationResult,
@@ -43,6 +46,13 @@ import {
43
46
  import { PageScreenshotResult } from "effect-agent/page-screenshot";
44
47
  import { SandboxImplementation } from "effect-agent/sandbox";
45
48
 
49
+ import { BrowserRunBinding } from "./internal/browser-binding.ts";
50
+ import {
51
+ BrowserRunFailure,
52
+ reportBrowserCause,
53
+ reportedBrowserError,
54
+ } from "./internal/browser-failure.ts";
55
+ import { makeFileSelection } from "./internal/browser-file-selection.ts";
46
56
  import {
47
57
  BrowserRunSessionLifecycle,
48
58
  type BrowserRunLifecycleOptions,
@@ -328,6 +338,11 @@ export interface BrowserRunInteractivePage {
328
338
  signal: AbortSignal,
329
339
  onDispatch: () => void,
330
340
  ) => Promise<unknown>;
341
+ readonly selectFile: (
342
+ request: BrowserSelectFileRequest,
343
+ signal: AbortSignal,
344
+ onDispatch: () => void,
345
+ ) => Promise<unknown>;
331
346
  readonly screenshot: (fullPage: boolean) => Promise<unknown>;
332
347
  readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
333
348
  readonly setViewport: (viewport: BrowserRunViewport) => Promise<void>;
@@ -371,22 +386,26 @@ export class BrowserRunInteractiveBinding extends Context.Service<
371
386
  return Layer.effect(BrowserRunInteractiveBinding)(
372
387
  Effect.gen(function* () {
373
388
  const lifecycle = yield* BrowserRunSessionLifecycle;
389
+ const binding = yield* BrowserRunBinding;
374
390
 
375
391
  const viewport =
376
392
  options.viewport === undefined ? undefined : yield* decodeViewport(options.viewport);
377
393
 
378
394
  return {
379
395
  acquire: async (keepAliveMillis: number) =>
380
- (await puppeteer.acquire(options.browser, { keep_alive: keepAliveMillis })).sessionId,
396
+ binding.acquire(keepAliveMillis, "interactive.acquire"),
381
397
  connect: async (sessionId: string) =>
382
- makeProductionBrowser(await puppeteer.connect(options.browser, sessionId), viewport),
398
+ makeProductionBrowser(
399
+ await binding.connect(sessionId, "interactive.connect"),
400
+ viewport,
401
+ ),
383
402
  closeSession: (sessionId: Redacted.Redacted<string>) =>
384
403
  lifecycle
385
404
  .close(sessionId)
386
405
  .pipe(Effect.mapError((cause) => actionError("close", cause))),
387
406
  };
388
407
  }),
389
- );
408
+ ).pipe(Layer.provide(BrowserRunBinding.layer(options.browser)));
390
409
  }
391
410
  }
392
411
 
@@ -706,6 +725,7 @@ const runObservedPageAction = async (
706
725
  signal: AbortSignal,
707
726
  onDispatch: () => void,
708
727
  action: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<void>,
728
+ validate?: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<boolean>,
709
729
  ): Promise<unknown> => {
710
730
  if (signal.aborted) throw new BrowserRunActionUndispatched(0);
711
731
 
@@ -737,6 +757,8 @@ const runObservedPageAction = async (
737
757
 
738
758
  signal.addEventListener("abort", onAbort, { once: true });
739
759
  try {
760
+ if (validate !== undefined && !(await validate(matches[0])))
761
+ throw new BrowserRunActionUndispatched(1);
740
762
  tracker = makeActionRequestTracker(page, signal);
741
763
  // No await between the final cancellation fence and SDK dispatch. Once
742
764
  // dispatched, interruption is uncertain, even if the SDK later resolves.
@@ -761,10 +783,18 @@ const runObservedPageAction = async (
761
783
  };
762
784
 
763
785
  const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
786
+ const prepareFileSelection = makeFileSelection(page);
764
787
  const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
765
788
 
766
789
  return {
767
- close: () => page.close(),
790
+ close: async () => {
791
+ if (!page.browser().isConnected()) return;
792
+ try {
793
+ await page.close();
794
+ } catch (cause) {
795
+ if (!isRemoteClosure(cause)) throw cause;
796
+ }
797
+ },
768
798
  setBypassServiceWorker: (enabled) => page.setBypassServiceWorker(enabled),
769
799
  setRequestInterception: (enabled) => page.setRequestInterception(enabled),
770
800
  onRequest: (listener) => {
@@ -1087,6 +1117,22 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
1087
1117
  ),
1088
1118
  click: (selector, signal, onDispatch) =>
1089
1119
  runObservedPageAction(page, selector, signal, onDispatch, (element) => element.click()),
1120
+ selectFile: async (request, signal, onDispatch) => {
1121
+ const selection = await prepareFileSelection(request, signal);
1122
+
1123
+ try {
1124
+ return await runObservedPageAction(
1125
+ page,
1126
+ request.selector,
1127
+ signal,
1128
+ onDispatch,
1129
+ selection.select,
1130
+ selection.validate,
1131
+ );
1132
+ } finally {
1133
+ await selection.close();
1134
+ }
1135
+ },
1090
1136
  // Puppeteer materializes the complete image before returning. The adapter
1091
1137
  // validates the 8 MiB Schema ceiling and pass limit immediately afterward.
1092
1138
  screenshot: (fullPage) => page.screenshot({ type: "png", fullPage }),
@@ -1116,7 +1162,14 @@ const makeProductionContext = (
1116
1162
 
1117
1163
  return makeProductionPage(page);
1118
1164
  },
1119
- close: () => context.close(),
1165
+ close: async () => {
1166
+ if (!context.browser().isConnected()) return;
1167
+ try {
1168
+ await context.close();
1169
+ } catch (cause) {
1170
+ if (!isRemoteClosure(cause)) throw cause;
1171
+ }
1172
+ },
1120
1173
  });
1121
1174
 
1122
1175
  const makeProductionBrowser = (
@@ -1124,7 +1177,15 @@ const makeProductionBrowser = (
1124
1177
  viewport?: BrowserRunViewport,
1125
1178
  ): BrowserRunInteractiveBrowser => ({
1126
1179
  createContext: async () => makeProductionContext(await browser.createBrowserContext(), viewport),
1127
- close: () => browser.close(),
1180
+ // Exact-session termination may already have closed the provider transport.
1181
+ close: async () => {
1182
+ if (!browser.isConnected()) return;
1183
+ try {
1184
+ await browser.close();
1185
+ } catch (cause) {
1186
+ if (!isRemoteClosure(cause)) throw cause;
1187
+ }
1188
+ },
1128
1189
  sessionId: () => browser.sessionId(),
1129
1190
  isConnected: () => browser.isConnected(),
1130
1191
  onDisconnected: (listener) => {
@@ -1183,7 +1244,7 @@ const causeText = (cause: unknown): string => {
1183
1244
  };
1184
1245
 
1185
1246
  const isCapacityRefusal = (cause: unknown): boolean =>
1186
- /(^|\D)429(\D|$)|browser time limit|capacity|too many concurrent/i.test(causeText(cause));
1247
+ cause instanceof BrowserRunFailure && cause.status === 429;
1187
1248
 
1188
1249
  const isRemoteClosure = (cause: unknown): boolean =>
1189
1250
  /target closed|browser.*closed|session.*closed|connection.*closed|not connected|websocket.*closed/i.test(
@@ -1242,6 +1303,8 @@ const hostAllowed = (policy: InteractiveBrowserPolicySnapshot, value: string): b
1242
1303
  const keepAliveMillis = (policy: InteractiveBrowserPolicySnapshot): number =>
1243
1304
  Math.max(MIN_KEEP_ALIVE_MILLIS, Math.min(MAX_KEEP_ALIVE_MILLIS, policy.maxElapsedMillis));
1244
1305
 
1306
+ class LateBrowserAcquisition extends Error {}
1307
+
1245
1308
  const closeLateAcquisition = async <A>(
1246
1309
  signal: AbortSignal,
1247
1310
  acquire: () => Promise<A>,
@@ -1256,22 +1319,25 @@ const closeLateAcquisition = async <A>(
1256
1319
  try {
1257
1320
  await close(acquired);
1258
1321
  } catch {
1259
- // No caller remains to observe a late cleanup failure, and provider details
1260
- // must not escape through an unhandled rejection.
1322
+ // The captured runtime reports cleanup failures before this callback absorbs rejection.
1261
1323
  }
1262
- throw new Error("The interrupted browser acquisition completed late");
1324
+ throw new LateBrowserAcquisition("The interrupted browser acquisition completed late");
1263
1325
  };
1264
1326
 
1265
1327
  const closeWithWarning = (close: () => Promise<void>, warning: string): Effect.Effect<void> =>
1266
1328
  Effect.tryPromise({
1267
1329
  try: close,
1268
- catch: () => protocolError(warning),
1330
+ catch: (cause) => protocolError(warning, cause),
1269
1331
  }).pipe(
1270
1332
  Effect.timeoutOrElse({
1271
1333
  duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
1272
1334
  orElse: () => Effect.fail(protocolError(warning)),
1273
1335
  }),
1274
- Effect.catchCause(() => Effect.logWarning(warning)),
1336
+ Effect.catchCause((cause) =>
1337
+ reportBrowserCause("interactive.cleanup", cause).pipe(
1338
+ Effect.andThen(Effect.logWarning(warning)),
1339
+ ),
1340
+ ),
1275
1341
  );
1276
1342
 
1277
1343
  const deadlineError = Effect.fn("BrowserRunInteractive.deadlineError")(function* (
@@ -1431,15 +1497,21 @@ const makeRequestListener =
1431
1497
 
1432
1498
  try {
1433
1499
  settlement = allowed ? request.continue() : request.abort();
1434
- } catch {
1435
- state.violation.value = protocolError("Resolving an intercepted browser request failed");
1500
+ } catch (cause) {
1501
+ state.violation.value = protocolError(
1502
+ "Resolving an intercepted browser request failed",
1503
+ cause,
1504
+ );
1436
1505
 
1437
1506
  return;
1438
1507
  }
1439
1508
 
1440
1509
  const observed = settlement
1441
- .catch(() => {
1442
- state.violation.value = protocolError("Resolving an intercepted browser request failed");
1510
+ .catch((cause) => {
1511
+ state.violation.value = protocolError(
1512
+ "Resolving an intercepted browser request failed",
1513
+ cause,
1514
+ );
1443
1515
  })
1444
1516
  .finally(() => {
1445
1517
  state.pendingRequests.delete(observed);
@@ -1490,7 +1562,7 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1490
1562
  );
1491
1563
 
1492
1564
  const observedAction = (
1493
- operation: "fill" | "click",
1565
+ operation: "fill" | "click" | "select-file",
1494
1566
  evaluate: (signal: AbortSignal, onDispatch: () => void) => Promise<unknown>,
1495
1567
  ) =>
1496
1568
  Effect.suspend(() => {
@@ -1537,7 +1609,7 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1537
1609
  );
1538
1610
 
1539
1611
  const logActionObservation = (
1540
- operation: "fill" | "click",
1612
+ operation: "fill" | "click" | "select-file",
1541
1613
  observation: typeof ActionObservation.Type,
1542
1614
  ) =>
1543
1615
  Effect.logInfo("Browser interactive action observed").pipe(
@@ -1764,6 +1836,29 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
1764
1836
  return yield* decodeActionResult(page, policy);
1765
1837
  }),
1766
1838
  ),
1839
+ selectFile: (request) =>
1840
+ Schema.decodeEffect(BrowserSelectFileRequest)(request).pipe(
1841
+ Effect.mapError(() => policyError("The browser file selection request is malformed")),
1842
+ Effect.flatMap((decoded) =>
1843
+ run(
1844
+ Effect.gen(function* () {
1845
+ const observation = yield* observedAction("select-file", (signal, onDispatch) =>
1846
+ page.selectFile(decoded, signal, onDispatch),
1847
+ ).pipe(Effect.flatMap(decodeActionObservation));
1848
+
1849
+ yield* logActionObservation("select-file", observation);
1850
+ const result = yield* decodeActionResult(page, policy);
1851
+
1852
+ return BrowserFileSelectionResult.make({
1853
+ url: result.url,
1854
+ fileName: decoded.fileName,
1855
+ mediaType: decoded.mediaType,
1856
+ size: decoded.bytes.length,
1857
+ });
1858
+ }),
1859
+ ),
1860
+ ),
1861
+ ),
1767
1862
  screenshot: (request) =>
1768
1863
  Schema.decodeEffect(BrowserScreenshotRequest)(request).pipe(
1769
1864
  Effect.mapError(() => policyError("The browser screenshot request is malformed")),
@@ -1872,10 +1967,31 @@ const cdpCommand = <A>(
1872
1967
  ): Effect.Effect<A, InteractiveBrowserError> =>
1873
1968
  Effect.scoped(
1874
1969
  Effect.gen(function* () {
1970
+ const cleanupContext = yield* Effect.context<never>();
1971
+
1875
1972
  const cdp = yield* Effect.acquireRelease(
1876
1973
  Effect.tryPromise({
1877
- try: (signal) =>
1878
- closeLateAcquisition(signal, page.createCdpSession, (acquired) => acquired.detach()),
1974
+ try: (signal) => {
1975
+ const pending = closeLateAcquisition(signal, page.createCdpSession, (acquired) =>
1976
+ Effect.runPromiseWith(cleanupContext)(
1977
+ Effect.tryPromise({
1978
+ try: () => acquired.detach(),
1979
+ catch: (cause) => actionError("close", cause),
1980
+ }).pipe(
1981
+ Effect.tapCause((cause) => reportBrowserCause("interactive.lateDetach", cause)),
1982
+ ),
1983
+ ),
1984
+ );
1985
+
1986
+ void pending.then(undefined, (cause) => {
1987
+ if (signal.aborted && !(cause instanceof LateBrowserAcquisition))
1988
+ return Effect.runPromiseWith(cleanupContext)(
1989
+ reportBrowserCause("interactive.connect", Cause.fail(cause)),
1990
+ );
1991
+ });
1992
+
1993
+ return pending;
1994
+ },
1879
1995
  catch: (cause) =>
1880
1996
  state.disconnected.value || isRemoteClosure(cause)
1881
1997
  ? expiredError()
@@ -1898,7 +2014,11 @@ const cdpCommand = <A>(
1898
2014
  });
1899
2015
 
1900
2016
  return yield* Schema.decodeUnknownEffect(output)(raw).pipe(
1901
- Effect.mapError(() => protocolError(malformedMessage)),
2017
+ Effect.catch((error) =>
2018
+ reportBrowserCause("interactive.command.decode", Cause.fail(error)).pipe(
2019
+ Effect.andThen(Effect.fail(reportedBrowserError(protocolError(malformedMessage)))),
2020
+ ),
2021
+ ),
1902
2022
  );
1903
2023
  }),
1904
2024
  );
@@ -1930,14 +2050,25 @@ const makeHostService = (
1930
2050
  // Remote termination is authoritative. Local cleanup must not veto it or extend the deadline.
1931
2051
  yield* runTeardown(entries).pipe(
1932
2052
  Effect.flatMap((failures) =>
1933
- Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning), {
1934
- discard: true,
1935
- }),
2053
+ Effect.forEach(
2054
+ failures,
2055
+ (failure) =>
2056
+ reportBrowserCause("interactive.cleanup", Cause.fail(failure.error)).pipe(
2057
+ Effect.andThen(Effect.logWarning(failure.warning)),
2058
+ ),
2059
+ {
2060
+ discard: true,
2061
+ },
2062
+ ),
1936
2063
  ),
1937
2064
  Effect.interruptible,
1938
2065
  Effect.timeout(`${remaining} millis`),
1939
- Effect.catchCause(() =>
1940
- Effect.logWarning("Local browser teardown incomplete after confirmed termination"),
2066
+ Effect.catchCause((cause) =>
2067
+ reportBrowserCause("interactive.cleanup", cause).pipe(
2068
+ Effect.andThen(
2069
+ Effect.logWarning("Local browser teardown incomplete after confirmed termination"),
2070
+ ),
2071
+ ),
1941
2072
  ),
1942
2073
  );
1943
2074
  });
@@ -1947,10 +2078,10 @@ const makeHostService = (
1947
2078
  ) {
1948
2079
  const sessionId = yield* Effect.try({
1949
2080
  try: browser.sessionId,
1950
- catch: () => actionError("close"),
2081
+ catch: (cause) => actionError("close", cause),
1951
2082
  }).pipe(
1952
2083
  Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
1953
- Effect.mapError(() => actionError("close")),
2084
+ Effect.mapError((cause) => actionError("close", cause)),
1954
2085
  Effect.onError(() =>
1955
2086
  closeWithWarning(browser.close, "Closing an unidentified browser failed"),
1956
2087
  ),
@@ -1967,8 +2098,26 @@ const makeHostService = (
1967
2098
  const scope = yield* Scope.Scope;
1968
2099
  const fixedPolicy = yield* snapshotPolicy(policy);
1969
2100
  const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
1970
- // Late SDK replies outlive the opening fiber; cleanup retains this pass's clock.
1971
- const runCleanup = Effect.runPromiseWith(Context.make(Clock.Clock, yield* Clock.Clock));
2101
+
2102
+ // Late SDK replies retain this pass's clock and private reporting scope.
2103
+ const runCleanup = <A, E>(effect: Effect.Effect<A, E>) =>
2104
+ Effect.runPromiseWith(cleanupContext)(
2105
+ effect.pipe(
2106
+ Effect.tapCause((cause) => reportBrowserCause("interactive.lateCleanup", cause)),
2107
+ ),
2108
+ );
2109
+
2110
+ const cleanupContext = yield* Effect.context<never>();
2111
+
2112
+ const observeLate = <A>(signal: AbortSignal, pending: Promise<A>): Promise<A> => {
2113
+ // Observe the original Promise without creating another rejected continuation.
2114
+ void pending.then(undefined, (cause) => {
2115
+ if (signal.aborted && !(cause instanceof LateBrowserAcquisition))
2116
+ return runCleanup(reportBrowserCause("interactive.acquire", Cause.fail(cause)));
2117
+ });
2118
+
2119
+ return pending;
2120
+ };
1972
2121
 
1973
2122
  const state: HandleState = {
1974
2123
  closed: { value: false },
@@ -1988,22 +2137,31 @@ const makeHostService = (
1988
2137
  Effect.suspend(() =>
1989
2138
  lifecycle.managedTeardownInstalled
1990
2139
  ? Effect.void
1991
- : entry.close.pipe(Effect.catchCause(() => Effect.logWarning(entry.warning))),
2140
+ : entry.close.pipe(
2141
+ Effect.catchCause((cause) =>
2142
+ reportBrowserCause("interactive.cleanup", cause).pipe(
2143
+ Effect.andThen(Effect.logWarning(entry.warning)),
2144
+ ),
2145
+ ),
2146
+ ),
1992
2147
  );
1993
2148
 
1994
2149
  const sessionIdValue = yield* Effect.acquireRelease(
1995
2150
  withinDeadline(
1996
2151
  Effect.tryPromise({
1997
2152
  try: (signal) =>
1998
- closeLateAcquisition(
2153
+ observeLate(
1999
2154
  signal,
2000
- () => binding.acquire(keepAliveMillis(fixedPolicy)),
2001
- (id) =>
2002
- runCleanup(
2003
- Schema.decodeUnknownEffect(BrowserRunSessionId)(id).pipe(
2004
- Effect.flatMap((value) => terminate(Redacted.make(value), [])),
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
+ ),
2005
2163
  ),
2006
- ),
2164
+ ),
2007
2165
  ),
2008
2166
  catch: (cause) =>
2009
2167
  isCapacityRefusal(cause)
@@ -2029,7 +2187,11 @@ const makeHostService = (
2029
2187
  return lifecycle.managedTeardownInstalled
2030
2188
  ? Effect.void
2031
2189
  : terminate(Redacted.make(id), []).pipe(
2032
- Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
2190
+ Effect.catchCause((cause) =>
2191
+ reportBrowserCause("interactive.close", cause).pipe(
2192
+ Effect.andThen(Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
2193
+ ),
2194
+ ),
2033
2195
  );
2034
2196
  }),
2035
2197
  { interruptible: true },
@@ -2045,10 +2207,13 @@ const makeHostService = (
2045
2207
  withinDeadline(
2046
2208
  Effect.tryPromise({
2047
2209
  try: (signal) =>
2048
- closeLateAcquisition(
2210
+ observeLate(
2049
2211
  signal,
2050
- () => binding.connect(sessionIdValue),
2051
- (acquired) => runCleanup(closeAcquired(acquired)),
2212
+ closeLateAcquisition(
2213
+ signal,
2214
+ () => binding.connect(sessionIdValue),
2215
+ (acquired) => runCleanup(closeAcquired(acquired)),
2216
+ ),
2052
2217
  ),
2053
2218
  catch: (cause) =>
2054
2219
  protocolError("Connecting to the Browser Run session failed", cause),
@@ -2105,7 +2270,17 @@ const makeHostService = (
2105
2270
  withinDeadline(
2106
2271
  Effect.tryPromise({
2107
2272
  try: (signal) =>
2108
- closeLateAcquisition(signal, browser.createContext, (acquired) => acquired.close()),
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
+ }),
2281
+ ),
2282
+ ),
2283
+ ),
2109
2284
  catch: (cause) =>
2110
2285
  state.disconnected.value || isRemoteClosure(cause)
2111
2286
  ? expiredError()
@@ -2127,7 +2302,17 @@ const makeHostService = (
2127
2302
  withinDeadline(
2128
2303
  Effect.tryPromise({
2129
2304
  try: (signal) =>
2130
- closeLateAcquisition(signal, context.newPage, (acquired) => acquired.close()),
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
+ }),
2313
+ ),
2314
+ ),
2315
+ ),
2131
2316
  catch: (cause) =>
2132
2317
  state.disconnected.value || isRemoteClosure(cause)
2133
2318
  ? expiredError()
@@ -2193,7 +2378,12 @@ const makeHostService = (
2193
2378
 
2194
2379
  const teardown = yield* Effect.uninterruptible(
2195
2380
  Effect.gen(function* () {
2196
- const cached = yield* Effect.cached(terminate(Redacted.make(sessionIdValue), closers));
2381
+ const cached = yield* Effect.cached(
2382
+ terminate(Redacted.make(sessionIdValue), closers).pipe(
2383
+ Effect.tapCause((cause) => reportBrowserCause("interactive.close", cause)),
2384
+ Effect.mapError(reportedBrowserError),
2385
+ ),
2386
+ );
2197
2387
 
2198
2388
  lifecycle.managedTeardownInstalled = true;
2199
2389
  yield* Effect.addFinalizer(() =>
@@ -2203,8 +2393,12 @@ const makeHostService = (
2203
2393
  state.disconnected.value = true;
2204
2394
  }).pipe(
2205
2395
  Effect.andThen(cached),
2206
- Effect.catch(() =>
2207
- Effect.logWarning("Whole-browser cleanup remains unconfirmed"),
2396
+ Effect.catchCause((cause) =>
2397
+ reportBrowserCause("interactive.close", cause).pipe(
2398
+ Effect.andThen(
2399
+ Effect.logWarning("Whole-browser cleanup remains unconfirmed"),
2400
+ ),
2401
+ ),
2208
2402
  ),
2209
2403
  ),
2210
2404
  ),
@@ -0,0 +1,82 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import puppeteer, { type Browser } from "@cloudflare/puppeteer";
3
+ import { Context, Layer, Schema } from "effect";
4
+
5
+ import { browserFailure, BrowserRunFailure } from "./browser-failure.ts";
6
+
7
+ const Acquired = Schema.Struct({ sessionId: Schema.String.check(Schema.isUUID()) });
8
+
9
+ /** Native transport port shared by both scoped adapters; the Layer owns the foreign binding. */
10
+ export class BrowserRunBinding extends Context.Service<
11
+ BrowserRunBinding,
12
+ {
13
+ readonly acquire: (
14
+ keepAliveMillis: number,
15
+ operation: "protected.acquire" | "interactive.acquire",
16
+ ) => Promise<string>;
17
+ readonly connect: (sessionId: string, operation: string) => Promise<Browser>;
18
+ }
19
+ >()("@effect-agent/platform-cloudflare/internal/BrowserRunBinding") {
20
+ static layer(browser: Pick<BrowserRun, "fetch">) {
21
+ return Layer.succeed(this)({
22
+ // One allocation path, with recording explicitly disabled for private sessions.
23
+ acquire: async (keepAliveMillis, operation) => {
24
+ const response = await browser.fetch(
25
+ `https://browser-rendering.cloudflare.com/v1/devtools/browser?keep_alive=${keepAliveMillis}&recording=false`,
26
+ { method: "POST" },
27
+ );
28
+
29
+ if (response.status !== 200) {
30
+ const failure = new BrowserRunFailure({
31
+ operation,
32
+ reason: "provider",
33
+ status: response.status,
34
+ });
35
+
36
+ try {
37
+ await response.body?.cancel();
38
+ } catch {
39
+ // Body cancellation cannot replace the acquisition refusal.
40
+ }
41
+ throw failure;
42
+ }
43
+
44
+ return Schema.decodeUnknownSync(Acquired)(await response.json()).sessionId;
45
+ },
46
+ // The SDK embeds bodies and session IDs in errors; retain status before that loss.
47
+ connect: async (sessionId, operation) => {
48
+ let failure: BrowserRunFailure | undefined;
49
+
50
+ try {
51
+ return await puppeteer.connect(
52
+ {
53
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
54
+ try {
55
+ const response = await browser.fetch(input, init);
56
+
57
+ if (response.status !== 101) {
58
+ failure = new BrowserRunFailure({
59
+ operation,
60
+ reason: "provider",
61
+ status: response.status,
62
+ });
63
+ await response.body?.cancel();
64
+ throw failure;
65
+ }
66
+
67
+ return response;
68
+ } catch (cause) {
69
+ failure ??= browserFailure(operation, cause);
70
+ throw failure;
71
+ }
72
+ },
73
+ },
74
+ sessionId,
75
+ );
76
+ } catch (cause) {
77
+ throw failure ?? browserFailure(operation, cause);
78
+ }
79
+ },
80
+ });
81
+ }
82
+ }