@effect-agent/platform-cloudflare 0.1.0-beta.107 → 0.1.0-beta.109

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,2 +1,2 @@
1
- import { A as ThreadPublicationOptions, C as portCall, D as CloudflareDurableRuntimeOptions, E as CloudflareDurableRuntimeInitializationError, M as layerConfig, N as layerHostConfig, O as CloudflareDurableRuntimeServices, P as layerInHost, S as make, T as CloudflareBootstrapServices, _ as decodeAdminVerifyRequest, a as AdminVerifyRequest, b as encodeAdminResponse, c as Instance, d as RetryExecuted, g as decodeAdminResponse, h as decodeAdminExplainRequest, i as AdminResponse, j as layer, k as ThreadObjectPorts, l as ObligationsScanned, m as VerifiedIntegrity, n as AdminFailed, o as Class, p as ThreadRpcOperation, r as AdminFailure, s as ExplainedRecovery, t as AdminExplainRequest, u as Options, v as decodeObligationThresholds, w as submit, x as handleRpc, y as decodeRetryCommand } from "./ThreadObject-CnqCuWlA.mjs";
1
+ import { A as ThreadPublicationOptions, C as portCall, D as CloudflareDurableRuntimeOptions, E as CloudflareDurableRuntimeInitializationError, M as layerConfig, N as layerHostConfig, O as CloudflareDurableRuntimeServices, P as layerInHost, S as make, T as CloudflareBootstrapServices, _ as decodeAdminVerifyRequest, a as AdminVerifyRequest, b as encodeAdminResponse, c as Instance, d as RetryExecuted, g as decodeAdminResponse, h as decodeAdminExplainRequest, i as AdminResponse, j as layer, k as ThreadObjectPorts, l as ObligationsScanned, m as VerifiedIntegrity, n as AdminFailed, o as Class, p as ThreadRpcOperation, r as AdminFailure, s as ExplainedRecovery, t as AdminExplainRequest, u as Options, v as decodeObligationThresholds, w as submit, x as handleRpc, y as decodeRetryCommand } from "./ThreadObject-xJ83Qjhu.mjs";
2
2
  export { AdminExplainRequest, AdminFailed, AdminFailure, AdminResponse, AdminVerifyRequest, type CloudflareBootstrapServices as BootstrapServices, Class, ExplainedRecovery, type CloudflareDurableRuntimeInitializationError as InitializationError, Instance, ObligationsScanned, Options, type ThreadPublicationOptions as PublicationOptions, RetryExecuted, type CloudflareDurableRuntimeOptions as RuntimeOptions, type CloudflareDurableRuntimeServices as Services, ThreadObjectPorts, ThreadRpcOperation, VerifiedIntegrity, decodeAdminExplainRequest, decodeAdminResponse, decodeAdminVerifyRequest, decodeObligationThresholds, decodeRetryCommand, encodeAdminResponse, handleRpc, layer, layerConfig, layerHostConfig, layerInHost, make, portCall, submit };
package/dist/index.d.mts CHANGED
@@ -8,6 +8,6 @@ import { t as CloudflareMemory_d_exports } from "./CloudflareMemory.mjs";
8
8
  import { t as CloudflareScheduling_d_exports } from "./CloudflareScheduling.mjs";
9
9
  import { t as CloudflareSubscriptions_d_exports } from "./CloudflareSubscriptions.mjs";
10
10
  import { t as CloudflareThreadClient_d_exports } from "./CloudflareThreadClient.mjs";
11
- import { f as ThreadObject_d_exports } from "./ThreadObject-CnqCuWlA.mjs";
11
+ import { f as ThreadObject_d_exports } from "./ThreadObject-xJ83Qjhu.mjs";
12
12
  import { t as WakeScheduler_d_exports } from "./WakeScheduler.mjs";
13
13
  export { Alarm_d_exports as Alarm, CloudflareAiGateway_d_exports as CloudflareAiGateway, CloudflareBindings_d_exports as CloudflareBindings, CloudflareBrowser_d_exports as CloudflareBrowser, CloudflareCodeMode_d_exports as CloudflareCodeMode, CloudflareConfig_d_exports as CloudflareConfig, CloudflareMemory_d_exports as CloudflareMemory, CloudflareScheduling_d_exports as CloudflareScheduling, CloudflareSubscriptions_d_exports as CloudflareSubscriptions, CloudflareThreadClient_d_exports as CloudflareThreadClient, ThreadObject_d_exports as ThreadObject, WakeScheduler_d_exports as WakeScheduler };
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@effect-agent/platform-cloudflare","version":"0.1.0-beta.107","dependencies":{"@effect-agent/storage-cloudflare":"0.1.0-beta.107","@effect/platform-browser":"4.0.0-rc.115","@effect/sql-sqlite-do":"4.0.0-rc.115","effect-agent":"0.1.0-beta.107"},"devDependencies":{"@cloudflare/puppeteer":"1.1.0","@cloudflare/vitest-pool-workers":"0.21.3","@cloudflare/workers-types":"5.20260825.1","@effect-agent/testing":"0.1.0-beta.107","@effect/platform-node":"4.0.0-rc.115","@effect/sql-d1":"4.0.0-rc.115","@effect/vitest":"4.0.0-rc.115","effect":"4.0.0-rc.115","effect-cf":"0.44.1","esbuild":"0.28.1","miniflare":"5.20260811.1-alpha","typescript":"7.0.2","vite-plus":"0.3.2","vitest":"4.1.11"},"peerDependencies":{"@cloudflare/puppeteer":"^1.1.0","effect":"^4.0.0-rc.115","effect-cf":"^0.44.1 || ^0.45.0"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./alarm":{"types":"./dist/Alarm.d.mts","default":"./dist/Alarm.mjs"},"./browser-rest-capture":{"types":"./dist/BrowserRestCapture.d.mts","default":"./dist/BrowserRestCapture.mjs"},"./browser-rest-crawl":{"types":"./dist/BrowserRestCrawl.d.mts","default":"./dist/BrowserRestCrawl.mjs"},"./cloudflare-bindings":{"types":"./dist/CloudflareBindings.d.mts","default":"./dist/CloudflareBindings.mjs"},"./cloudflare-browser":{"types":"./dist/CloudflareBrowser.d.mts","default":"./dist/CloudflareBrowser.mjs"},"./cloudflare-code-mode":{"types":"./dist/CloudflareCodeMode.d.mts","default":"./dist/CloudflareCodeMode.mjs"},"./cloudflare-config":{"types":"./dist/CloudflareConfig.d.mts","default":"./dist/CloudflareConfig.mjs"},"./cloudflare-memory":{"types":"./dist/CloudflareMemory.d.mts","default":"./dist/CloudflareMemory.mjs"},"./cloudflare-scheduling":{"types":"./dist/CloudflareScheduling.d.mts","default":"./dist/CloudflareScheduling.mjs"},"./cloudflare-subscriptions":{"types":"./dist/CloudflareSubscriptions.d.mts","default":"./dist/CloudflareSubscriptions.mjs"},"./cloudflare-thread-client":{"types":"./dist/CloudflareThreadClient.d.mts","default":"./dist/CloudflareThreadClient.mjs"},"./interactive-browser":{"types":"./dist/InteractiveBrowser.d.mts","default":"./dist/InteractiveBrowser.mjs"},"./protected-browser":{"types":"./dist/ProtectedBrowser.d.mts","default":"./dist/ProtectedBrowser.mjs"},"./thread-object":{"types":"./dist/ThreadObject.d.mts","default":"./dist/ThreadObject.mjs"},"./wake-scheduler":{"types":"./dist/WakeScheduler.d.mts","default":"./dist/WakeScheduler.mjs"},"./cloudflare-ai-gateway":{"types":"./dist/CloudflareAiGateway.d.mts","default":"./dist/CloudflareAiGateway.mjs"}},"description":"Cloudflare Layer assembly for Effect Agent: Durable Objects and Browser Run adapters.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/danieljvdm/effect-agent.git","directory":"packages/platform-cloudflare"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"},"peerDependenciesMeta":{"@cloudflare/puppeteer":{"optional":true}}}
1
+ {"name":"@effect-agent/platform-cloudflare","version":"0.1.0-beta.109","dependencies":{"@effect-agent/storage-cloudflare":"0.1.0-beta.109","@effect/platform-browser":"4.0.0-rc.115","@effect/sql-sqlite-do":"4.0.0-rc.115","effect-agent":"0.1.0-beta.109"},"devDependencies":{"@cloudflare/puppeteer":"1.1.0","@cloudflare/vitest-pool-workers":"0.21.3","@cloudflare/workers-types":"5.20260825.1","@effect-agent/testing":"0.1.0-beta.109","@effect/platform-node":"4.0.0-rc.115","@effect/sql-d1":"4.0.0-rc.115","@effect/vitest":"4.0.0-rc.115","effect":"4.0.0-rc.115","effect-cf":"0.44.1","esbuild":"0.28.1","miniflare":"5.20260811.1-alpha","typescript":"7.0.2","vite-plus":"0.3.2","vitest":"4.1.11"},"peerDependencies":{"@cloudflare/puppeteer":"^1.1.0","effect":"^4.0.0-rc.115","effect-cf":"^0.44.1 || ^0.45.0"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./alarm":{"types":"./dist/Alarm.d.mts","default":"./dist/Alarm.mjs"},"./browser-rest-capture":{"types":"./dist/BrowserRestCapture.d.mts","default":"./dist/BrowserRestCapture.mjs"},"./browser-rest-crawl":{"types":"./dist/BrowserRestCrawl.d.mts","default":"./dist/BrowserRestCrawl.mjs"},"./cloudflare-bindings":{"types":"./dist/CloudflareBindings.d.mts","default":"./dist/CloudflareBindings.mjs"},"./cloudflare-browser":{"types":"./dist/CloudflareBrowser.d.mts","default":"./dist/CloudflareBrowser.mjs"},"./cloudflare-code-mode":{"types":"./dist/CloudflareCodeMode.d.mts","default":"./dist/CloudflareCodeMode.mjs"},"./cloudflare-config":{"types":"./dist/CloudflareConfig.d.mts","default":"./dist/CloudflareConfig.mjs"},"./cloudflare-memory":{"types":"./dist/CloudflareMemory.d.mts","default":"./dist/CloudflareMemory.mjs"},"./cloudflare-scheduling":{"types":"./dist/CloudflareScheduling.d.mts","default":"./dist/CloudflareScheduling.mjs"},"./cloudflare-subscriptions":{"types":"./dist/CloudflareSubscriptions.d.mts","default":"./dist/CloudflareSubscriptions.mjs"},"./cloudflare-thread-client":{"types":"./dist/CloudflareThreadClient.d.mts","default":"./dist/CloudflareThreadClient.mjs"},"./interactive-browser":{"types":"./dist/InteractiveBrowser.d.mts","default":"./dist/InteractiveBrowser.mjs"},"./protected-browser":{"types":"./dist/ProtectedBrowser.d.mts","default":"./dist/ProtectedBrowser.mjs"},"./thread-object":{"types":"./dist/ThreadObject.d.mts","default":"./dist/ThreadObject.mjs"},"./wake-scheduler":{"types":"./dist/WakeScheduler.d.mts","default":"./dist/WakeScheduler.mjs"},"./cloudflare-ai-gateway":{"types":"./dist/CloudflareAiGateway.d.mts","default":"./dist/CloudflareAiGateway.mjs"}},"description":"Cloudflare Layer assembly for Effect Agent: Durable Objects and Browser Run adapters.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/danieljvdm/effect-agent.git","directory":"packages/platform-cloudflare"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"},"peerDependenciesMeta":{"@cloudflare/puppeteer":{"optional":true}}}
@@ -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,
@@ -45,6 +46,12 @@ import {
45
46
  import { PageScreenshotResult } from "effect-agent/page-screenshot";
46
47
  import { SandboxImplementation } from "effect-agent/sandbox";
47
48
 
49
+ import { BrowserRunBinding } from "./internal/browser-binding.ts";
50
+ import {
51
+ BrowserRunFailure,
52
+ reportBrowserCause,
53
+ reportedBrowserError,
54
+ } from "./internal/browser-failure.ts";
48
55
  import { makeFileSelection } from "./internal/browser-file-selection.ts";
49
56
  import {
50
57
  BrowserRunSessionLifecycle,
@@ -379,22 +386,26 @@ export class BrowserRunInteractiveBinding extends Context.Service<
379
386
  return Layer.effect(BrowserRunInteractiveBinding)(
380
387
  Effect.gen(function* () {
381
388
  const lifecycle = yield* BrowserRunSessionLifecycle;
389
+ const binding = yield* BrowserRunBinding;
382
390
 
383
391
  const viewport =
384
392
  options.viewport === undefined ? undefined : yield* decodeViewport(options.viewport);
385
393
 
386
394
  return {
387
395
  acquire: async (keepAliveMillis: number) =>
388
- (await puppeteer.acquire(options.browser, { keep_alive: keepAliveMillis })).sessionId,
396
+ binding.acquire(keepAliveMillis, "interactive.acquire"),
389
397
  connect: async (sessionId: string) =>
390
- makeProductionBrowser(await puppeteer.connect(options.browser, sessionId), viewport),
398
+ makeProductionBrowser(
399
+ await binding.connect(sessionId, "interactive.connect"),
400
+ viewport,
401
+ ),
391
402
  closeSession: (sessionId: Redacted.Redacted<string>) =>
392
403
  lifecycle
393
404
  .close(sessionId)
394
405
  .pipe(Effect.mapError((cause) => actionError("close", cause))),
395
406
  };
396
407
  }),
397
- );
408
+ ).pipe(Layer.provide(BrowserRunBinding.layer(options.browser)));
398
409
  }
399
410
  }
400
411
 
@@ -776,7 +787,14 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
776
787
  const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
777
788
 
778
789
  return {
779
- 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
+ },
780
798
  setBypassServiceWorker: (enabled) => page.setBypassServiceWorker(enabled),
781
799
  setRequestInterception: (enabled) => page.setRequestInterception(enabled),
782
800
  onRequest: (listener) => {
@@ -1144,7 +1162,14 @@ const makeProductionContext = (
1144
1162
 
1145
1163
  return makeProductionPage(page);
1146
1164
  },
1147
- 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
+ },
1148
1173
  });
1149
1174
 
1150
1175
  const makeProductionBrowser = (
@@ -1152,7 +1177,15 @@ const makeProductionBrowser = (
1152
1177
  viewport?: BrowserRunViewport,
1153
1178
  ): BrowserRunInteractiveBrowser => ({
1154
1179
  createContext: async () => makeProductionContext(await browser.createBrowserContext(), viewport),
1155
- 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
+ },
1156
1189
  sessionId: () => browser.sessionId(),
1157
1190
  isConnected: () => browser.isConnected(),
1158
1191
  onDisconnected: (listener) => {
@@ -1211,7 +1244,7 @@ const causeText = (cause: unknown): string => {
1211
1244
  };
1212
1245
 
1213
1246
  const isCapacityRefusal = (cause: unknown): boolean =>
1214
- /(^|\D)429(\D|$)|browser time limit|capacity|too many concurrent/i.test(causeText(cause));
1247
+ cause instanceof BrowserRunFailure && cause.status === 429;
1215
1248
 
1216
1249
  const isRemoteClosure = (cause: unknown): boolean =>
1217
1250
  /target closed|browser.*closed|session.*closed|connection.*closed|not connected|websocket.*closed/i.test(
@@ -1270,6 +1303,8 @@ const hostAllowed = (policy: InteractiveBrowserPolicySnapshot, value: string): b
1270
1303
  const keepAliveMillis = (policy: InteractiveBrowserPolicySnapshot): number =>
1271
1304
  Math.max(MIN_KEEP_ALIVE_MILLIS, Math.min(MAX_KEEP_ALIVE_MILLIS, policy.maxElapsedMillis));
1272
1305
 
1306
+ class LateBrowserAcquisition extends Error {}
1307
+
1273
1308
  const closeLateAcquisition = async <A>(
1274
1309
  signal: AbortSignal,
1275
1310
  acquire: () => Promise<A>,
@@ -1284,22 +1319,25 @@ const closeLateAcquisition = async <A>(
1284
1319
  try {
1285
1320
  await close(acquired);
1286
1321
  } catch {
1287
- // No caller remains to observe a late cleanup failure, and provider details
1288
- // must not escape through an unhandled rejection.
1322
+ // The captured runtime reports cleanup failures before this callback absorbs rejection.
1289
1323
  }
1290
- throw new Error("The interrupted browser acquisition completed late");
1324
+ throw new LateBrowserAcquisition("The interrupted browser acquisition completed late");
1291
1325
  };
1292
1326
 
1293
1327
  const closeWithWarning = (close: () => Promise<void>, warning: string): Effect.Effect<void> =>
1294
1328
  Effect.tryPromise({
1295
1329
  try: close,
1296
- catch: () => protocolError(warning),
1330
+ catch: (cause) => protocolError(warning, cause),
1297
1331
  }).pipe(
1298
1332
  Effect.timeoutOrElse({
1299
1333
  duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
1300
1334
  orElse: () => Effect.fail(protocolError(warning)),
1301
1335
  }),
1302
- Effect.catchCause(() => Effect.logWarning(warning)),
1336
+ Effect.catchCause((cause) =>
1337
+ reportBrowserCause("interactive.cleanup", cause).pipe(
1338
+ Effect.andThen(Effect.logWarning(warning)),
1339
+ ),
1340
+ ),
1303
1341
  );
1304
1342
 
1305
1343
  const deadlineError = Effect.fn("BrowserRunInteractive.deadlineError")(function* (
@@ -1459,15 +1497,21 @@ const makeRequestListener =
1459
1497
 
1460
1498
  try {
1461
1499
  settlement = allowed ? request.continue() : request.abort();
1462
- } catch {
1463
- 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
+ );
1464
1505
 
1465
1506
  return;
1466
1507
  }
1467
1508
 
1468
1509
  const observed = settlement
1469
- .catch(() => {
1470
- 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
+ );
1471
1515
  })
1472
1516
  .finally(() => {
1473
1517
  state.pendingRequests.delete(observed);
@@ -1923,10 +1967,31 @@ const cdpCommand = <A>(
1923
1967
  ): Effect.Effect<A, InteractiveBrowserError> =>
1924
1968
  Effect.scoped(
1925
1969
  Effect.gen(function* () {
1970
+ const cleanupContext = yield* Effect.context<never>();
1971
+
1926
1972
  const cdp = yield* Effect.acquireRelease(
1927
1973
  Effect.tryPromise({
1928
- try: (signal) =>
1929
- 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
+ },
1930
1995
  catch: (cause) =>
1931
1996
  state.disconnected.value || isRemoteClosure(cause)
1932
1997
  ? expiredError()
@@ -1949,7 +2014,11 @@ const cdpCommand = <A>(
1949
2014
  });
1950
2015
 
1951
2016
  return yield* Schema.decodeUnknownEffect(output)(raw).pipe(
1952
- 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
+ ),
1953
2022
  );
1954
2023
  }),
1955
2024
  );
@@ -1981,14 +2050,25 @@ const makeHostService = (
1981
2050
  // Remote termination is authoritative. Local cleanup must not veto it or extend the deadline.
1982
2051
  yield* runTeardown(entries).pipe(
1983
2052
  Effect.flatMap((failures) =>
1984
- Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning), {
1985
- discard: true,
1986
- }),
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
+ ),
1987
2063
  ),
1988
2064
  Effect.interruptible,
1989
2065
  Effect.timeout(`${remaining} millis`),
1990
- Effect.catchCause(() =>
1991
- 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
+ ),
1992
2072
  ),
1993
2073
  );
1994
2074
  });
@@ -1998,10 +2078,10 @@ const makeHostService = (
1998
2078
  ) {
1999
2079
  const sessionId = yield* Effect.try({
2000
2080
  try: browser.sessionId,
2001
- catch: () => actionError("close"),
2081
+ catch: (cause) => actionError("close", cause),
2002
2082
  }).pipe(
2003
2083
  Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
2004
- Effect.mapError(() => actionError("close")),
2084
+ Effect.mapError((cause) => actionError("close", cause)),
2005
2085
  Effect.onError(() =>
2006
2086
  closeWithWarning(browser.close, "Closing an unidentified browser failed"),
2007
2087
  ),
@@ -2018,8 +2098,26 @@ const makeHostService = (
2018
2098
  const scope = yield* Scope.Scope;
2019
2099
  const fixedPolicy = yield* snapshotPolicy(policy);
2020
2100
  const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
2021
- // Late SDK replies outlive the opening fiber; cleanup retains this pass's clock.
2022
- 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
+ };
2023
2121
 
2024
2122
  const state: HandleState = {
2025
2123
  closed: { value: false },
@@ -2039,22 +2137,31 @@ const makeHostService = (
2039
2137
  Effect.suspend(() =>
2040
2138
  lifecycle.managedTeardownInstalled
2041
2139
  ? Effect.void
2042
- : 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
+ ),
2043
2147
  );
2044
2148
 
2045
2149
  const sessionIdValue = yield* Effect.acquireRelease(
2046
2150
  withinDeadline(
2047
2151
  Effect.tryPromise({
2048
2152
  try: (signal) =>
2049
- closeLateAcquisition(
2153
+ observeLate(
2050
2154
  signal,
2051
- () => binding.acquire(keepAliveMillis(fixedPolicy)),
2052
- (id) =>
2053
- runCleanup(
2054
- Schema.decodeUnknownEffect(BrowserRunSessionId)(id).pipe(
2055
- 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
+ ),
2056
2163
  ),
2057
- ),
2164
+ ),
2058
2165
  ),
2059
2166
  catch: (cause) =>
2060
2167
  isCapacityRefusal(cause)
@@ -2080,7 +2187,11 @@ const makeHostService = (
2080
2187
  return lifecycle.managedTeardownInstalled
2081
2188
  ? Effect.void
2082
2189
  : terminate(Redacted.make(id), []).pipe(
2083
- 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
+ ),
2084
2195
  );
2085
2196
  }),
2086
2197
  { interruptible: true },
@@ -2096,10 +2207,13 @@ const makeHostService = (
2096
2207
  withinDeadline(
2097
2208
  Effect.tryPromise({
2098
2209
  try: (signal) =>
2099
- closeLateAcquisition(
2210
+ observeLate(
2100
2211
  signal,
2101
- () => binding.connect(sessionIdValue),
2102
- (acquired) => runCleanup(closeAcquired(acquired)),
2212
+ closeLateAcquisition(
2213
+ signal,
2214
+ () => binding.connect(sessionIdValue),
2215
+ (acquired) => runCleanup(closeAcquired(acquired)),
2216
+ ),
2103
2217
  ),
2104
2218
  catch: (cause) =>
2105
2219
  protocolError("Connecting to the Browser Run session failed", cause),
@@ -2156,7 +2270,17 @@ const makeHostService = (
2156
2270
  withinDeadline(
2157
2271
  Effect.tryPromise({
2158
2272
  try: (signal) =>
2159
- 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
+ ),
2160
2284
  catch: (cause) =>
2161
2285
  state.disconnected.value || isRemoteClosure(cause)
2162
2286
  ? expiredError()
@@ -2178,7 +2302,17 @@ const makeHostService = (
2178
2302
  withinDeadline(
2179
2303
  Effect.tryPromise({
2180
2304
  try: (signal) =>
2181
- 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
+ ),
2182
2316
  catch: (cause) =>
2183
2317
  state.disconnected.value || isRemoteClosure(cause)
2184
2318
  ? expiredError()
@@ -2244,7 +2378,12 @@ const makeHostService = (
2244
2378
 
2245
2379
  const teardown = yield* Effect.uninterruptible(
2246
2380
  Effect.gen(function* () {
2247
- 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
+ );
2248
2387
 
2249
2388
  lifecycle.managedTeardownInstalled = true;
2250
2389
  yield* Effect.addFinalizer(() =>
@@ -2254,8 +2393,12 @@ const makeHostService = (
2254
2393
  state.disconnected.value = true;
2255
2394
  }).pipe(
2256
2395
  Effect.andThen(cached),
2257
- Effect.catch(() =>
2258
- 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
+ ),
2259
2402
  ),
2260
2403
  ),
2261
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
+ }
@@ -0,0 +1,92 @@
1
+ import type { Effect } from "effect";
2
+ import { Cause, Data, ErrorReporter, Predicate, Schema } from "effect";
3
+
4
+ /** Source-authored stages and classifications only; never retain SDK text or session capabilities. */
5
+ export class BrowserRunFailure extends Data.TaggedError("BrowserRunFailure")<{
6
+ readonly operation: string;
7
+ readonly reason:
8
+ | "provider"
9
+ | "timeout"
10
+ | "malformed"
11
+ | "configuration"
12
+ | "authorization"
13
+ | "rate-limited"
14
+ | "pending";
15
+ readonly status?: number;
16
+ readonly cause?: BrowserRunFailure | { readonly name: string };
17
+ }> {
18
+ readonly [ErrorReporter.severity] = "Error" as const;
19
+ }
20
+
21
+ /** A public projection of an already reported private failure must not produce another issue. */
22
+ export const reportedBrowserError = <A extends object>(error: A): A =>
23
+ Object.assign(error, { [ErrorReporter.ignore]: true });
24
+
25
+ export const inheritBrowserReport = <A extends object>(original: unknown, error: A): A =>
26
+ ErrorReporter.isIgnored(original) ? reportedBrowserError(error) : error;
27
+
28
+ const reasonSchema = Schema.Literals([
29
+ "provider",
30
+ "timeout",
31
+ "malformed",
32
+ "configuration",
33
+ "authorization",
34
+ "rate-limited",
35
+ "pending",
36
+ ]);
37
+
38
+ const statusSchema = Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 }));
39
+
40
+ const failureNames = new Set([
41
+ "TypeError",
42
+ "RangeError",
43
+ "SyntaxError",
44
+ "TimeoutError",
45
+ "SchemaError",
46
+ ]);
47
+
48
+ export const browserFailure = (operation: string, cause?: unknown): BrowserRunFailure =>
49
+ cause instanceof BrowserRunFailure
50
+ ? cause
51
+ : new BrowserRunFailure({
52
+ operation,
53
+ reason:
54
+ Predicate.isObject(cause) && "reason" in cause && Schema.is(reasonSchema)(cause.reason)
55
+ ? cause.reason
56
+ : "provider",
57
+ ...(Predicate.isObject(cause) && "status" in cause && Schema.is(statusSchema)(cause.status)
58
+ ? { status: cause.status }
59
+ : {}),
60
+ ...(Predicate.isObject(cause) &&
61
+ "cause" in cause &&
62
+ cause.cause instanceof BrowserRunFailure
63
+ ? { cause: cause.cause }
64
+ : Predicate.isObject(cause) &&
65
+ "name" in cause &&
66
+ typeof cause.name === "string" &&
67
+ failureNames.has(cause.name)
68
+ ? { cause: { name: cause.name } }
69
+ : {}),
70
+ });
71
+
72
+ /** Keep mixed defects and interruptions distinct while discarding all foreign diagnostic content. */
73
+ export const reportBrowserCause = <E>(
74
+ operation: string,
75
+ cause: Cause.Cause<E>,
76
+ ): Effect.Effect<void> =>
77
+ ErrorReporter.report(
78
+ Cause.fromReasons(
79
+ cause.reasons
80
+ .filter((reason) => !Cause.isFailReason(reason) || !ErrorReporter.isIgnored(reason.error))
81
+ .map((reason) => {
82
+ switch (reason._tag) {
83
+ case "Fail":
84
+ return Cause.makeFailReason(browserFailure(operation, reason.error));
85
+ case "Die":
86
+ return Cause.makeDieReason(browserFailure(operation, reason.defect));
87
+ case "Interrupt":
88
+ return reason;
89
+ }
90
+ }),
91
+ ),
92
+ );