@effect-agent/platform-cloudflare 0.1.0-beta.52 → 0.1.0-beta.54

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.
Files changed (33) hide show
  1. package/dist/Alarm.d.mts +45 -6
  2. package/dist/Alarm.mjs +111 -47
  3. package/dist/Alarm.mjs.map +1 -1
  4. package/dist/BrowserRestCapture.mjs +1 -1
  5. package/dist/{CloudflareBrowser-1jeZx3_g.mjs → CloudflareBrowser-Bj22nNUT.mjs} +12 -13
  6. package/dist/CloudflareBrowser-Bj22nNUT.mjs.map +1 -0
  7. package/dist/CloudflareBrowser.mjs +1 -1
  8. package/dist/CloudflareThreadClient.d.mts +18 -17
  9. package/dist/CloudflareThreadClient.mjs +2 -1
  10. package/dist/CloudflareThreadClient.mjs.map +1 -1
  11. package/dist/ProtectedBrowser.d.mts +5 -3
  12. package/dist/ProtectedBrowser.mjs +180 -36
  13. package/dist/ProtectedBrowser.mjs.map +1 -1
  14. package/dist/{ThreadObject-BSRVxtPI.d.mts → ThreadObject-6_j7YgMV.d.mts} +48 -37
  15. package/dist/{ThreadObject-BY8axaWT.mjs → ThreadObject-D2eP-tEO.mjs} +44 -20
  16. package/dist/ThreadObject-D2eP-tEO.mjs.map +1 -0
  17. package/dist/ThreadObject.d.mts +2 -2
  18. package/dist/ThreadObject.mjs +1 -1
  19. package/dist/index.d.mts +1 -1
  20. package/dist/index.mjs +2 -2
  21. package/package.json +1 -1
  22. package/src/Alarm.ts +242 -72
  23. package/src/CloudflareThreadClient.ts +6 -0
  24. package/src/ThreadObject.ts +1 -0
  25. package/src/internal/browser-quick-action.ts +14 -11
  26. package/src/internal/layers.ts +93 -9
  27. package/src/internal/progress-wait.ts +17 -13
  28. package/src/protected-browser/binding.ts +2 -1
  29. package/src/protected-browser/inspect-frame.ts +62 -19
  30. package/src/protected-browser/native.ts +50 -5
  31. package/src/protected-browser/policy.ts +138 -13
  32. package/dist/CloudflareBrowser-1jeZx3_g.mjs.map +0 -1
  33. package/dist/ThreadObject-BY8axaWT.mjs.map +0 -1
package/src/Alarm.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from "@effect-agent/thread/DurableAgentRuntime";
7
7
  import { SubmissionLedger, type SubmissionSnapshot } from "@effect-agent/thread/SubmissionLedger";
8
8
  import {
9
+ Cause,
9
10
  Clock,
10
11
  Context,
11
12
  DateTime,
@@ -157,7 +158,7 @@ export class DurableAlarmService extends Context.Service<
157
158
  export class MaintenancePassReport extends Schema.Class<MaintenancePassReport>(
158
159
  "@effect-agent/platform-cloudflare/MaintenancePassReport",
159
160
  )({
160
- /** `caught-up` is generation-only; `actionable` ran recovery and at most one head Attempt. */
161
+ /** `caught-up` ran no runtime work (publication may be pending); `actionable` ran recovery. */
161
162
  phase: Schema.Literals(["caught-up", "actionable"]),
162
163
  /** Recovery decisions executed (or deferred) BEFORE any new claim in this pass. */
163
164
  recovered: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
@@ -196,6 +197,53 @@ export class ThreadMaintenanceFailpoint extends Context.Service<
196
197
  static readonly layer = Layer.succeed(this)({ hit: () => Effect.void });
197
198
  }
198
199
 
200
+ /**
201
+ * Durable host publication of canonical records and ledger approval/abort/resolution intents.
202
+ * The host owns schema-versioned cursors, destination idempotency and acknowledgement. Delivery
203
+ * is at least once. Hooks must not write the alarm slot or mutate the supplied raw source ports.
204
+ *
205
+ * `invalidate`, `prepareGeneration` and `pendingDeadline` must be bounded local operations.
206
+ * `prepareGeneration` durably invalidates a scan only when its generation changes; repeated
207
+ * calls must preserve partial scan progress. It runs with no source mutation in flight.
208
+ * `drain` performs bounded delivery and persists retries before returning. A pending deadline
209
+ * defers runtime recovery/Attempts, allowing committed host publications to drain first.
210
+ * Unexpected hook failures leave the prearmed generation for retry. Hooks acquire per-call
211
+ * resources with Effect.scoped; Layer construction owns incarnation resources (eviction need
212
+ * not run finalizers). Do not hold a local hook behind network I/O or call back into producers.
213
+ */
214
+ export interface ThreadPublicationService {
215
+ readonly invalidate: Effect.Effect<void, DurableAlarmError>;
216
+ readonly prepareGeneration: (generation: bigint) => Effect.Effect<void, DurableAlarmError>;
217
+ readonly drain: Effect.Effect<void, DurableAlarmError>;
218
+ readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
219
+ }
220
+
221
+ /** Opt in with `ThreadObject.layer(registrations, { publication: Layer.effect(ThreadPublication)(...) })`. */
222
+ export class ThreadPublication extends Context.Service<
223
+ ThreadPublication,
224
+ ThreadPublicationService
225
+ >()("@effect-agent/platform-cloudflare/ThreadPublication") {
226
+ static readonly layer = Layer.succeed(this)({
227
+ invalidate: Effect.void,
228
+ prepareGeneration: () => Effect.void,
229
+ drain: Effect.void,
230
+ pendingDeadline: Effect.succeed(Option.none()),
231
+ });
232
+ }
233
+
234
+ /** @internal A committed source operation must not become a failed operation because delivery failed. */
235
+ export const publishCommitted = Effect.gen(function* () {
236
+ const publication = yield* ThreadPublication;
237
+
238
+ yield* publication.invalidate.pipe(Effect.andThen(publication.drain));
239
+ }).pipe(
240
+ Effect.catchCause((cause) =>
241
+ Cause.hasInterrupts(cause)
242
+ ? Effect.interrupt
243
+ : Effect.logError("Thread publication deferred after source commit", cause),
244
+ ),
245
+ );
246
+
199
247
  const MaintenanceGeneration = Schema.BigIntFromString.check(
200
248
  Schema.isGreaterThanOrEqualToBigInt(0n),
201
249
  );
@@ -271,6 +319,84 @@ const stableExternalWait = (
271
319
  }
272
320
  };
273
321
 
322
+ /**
323
+ * Shared prearm/acknowledgement boundary for ingress and runtime-owned producers.
324
+ * `ThreadObject.layer` provides this same instance in its Services. Rebuilt runtime/maintenance
325
+ * Layers must reuse that instance; a second gate cannot observe the native producers' activity.
326
+ */
327
+ export class ThreadMutationGate extends Context.Service<
328
+ ThreadMutationGate,
329
+ {
330
+ readonly withMutation: <A, E, R>(
331
+ body: Effect.Effect<A, E, R>,
332
+ ) => Effect.Effect<A, E | DurableAlarmError, R>;
333
+ readonly withSnapshot: <A, E, R>(
334
+ body: (active: number) => Effect.Effect<A, E, R>,
335
+ ) => Effect.Effect<A, E, R>;
336
+ }
337
+ >()("@effect-agent/platform-cloudflare/internal/ThreadMutationGate") {
338
+ static readonly layer = Layer.effect(this)(
339
+ Effect.gen(function* () {
340
+ const { ctx } = yield* DurableObjectContext;
341
+ const config = yield* CloudflareDurableRuntimeConfig;
342
+ const failpoint = yield* ThreadMaintenanceFailpoint;
343
+ // A fresh incarnation has no live mutations; durable generations survive eviction.
344
+ const activeMutations = yield* Ref.make(0);
345
+ const generationGate = yield* Semaphore.make(1);
346
+ const minimumAlarmDelay = Math.max(1, Math.ceil(config.alarmBackoffBase / 2));
347
+
348
+ const runTransaction = <A>(operation: string, transaction: () => Promise<A>) =>
349
+ Effect.tryPromise({ try: transaction, catch: alarmFailure(operation) });
350
+
351
+ const beginMutation = Effect.fn("ThreadMaintenance.beginMutation")(function* () {
352
+ yield* failpoint.hit("maintenance:dirty:before");
353
+ const now = yield* Clock.currentTimeMillis;
354
+
355
+ yield* runTransaction("advance maintenance generation", () =>
356
+ ctx.storage.transaction(async (transaction) => {
357
+ const { state } = await readMaintenanceState(transaction);
358
+
359
+ const next = ThreadMaintenanceState.make({
360
+ ...state,
361
+ dirty: state.dirty + 1n,
362
+ });
363
+
364
+ await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
365
+ // The earliest configured retry bounds a newly actionable mutation without relying
366
+ // on its best-effort immediate wake hint.
367
+ await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
368
+ }),
369
+ );
370
+ yield* failpoint.hit("maintenance:dirty:after");
371
+ yield* Ref.update(activeMutations, (active) => active + 1);
372
+ });
373
+
374
+ const endMutation = generationGate.withPermit(
375
+ Ref.update(activeMutations, (active) => Math.max(0, active - 1)),
376
+ );
377
+
378
+ const withMutation = <A, E, R>(
379
+ body: Effect.Effect<A, E, R>,
380
+ ): Effect.Effect<A, E | DurableAlarmError, R> =>
381
+ Effect.acquireUseRelease(
382
+ generationGate.withPermit(beginMutation()),
383
+ () =>
384
+ failpoint.hit("maintenance:mutation:armed").pipe(
385
+ Effect.andThen(body),
386
+ Effect.tap(() => failpoint.hit("maintenance:mutation:finished")),
387
+ ),
388
+ () => endMutation,
389
+ );
390
+
391
+ return ThreadMutationGate.of({
392
+ withMutation,
393
+ withSnapshot: (body) =>
394
+ generationGate.withPermit(Effect.flatMap(Ref.get(activeMutations), body)),
395
+ });
396
+ }),
397
+ );
398
+ }
399
+
274
400
  export type MaintenancePassFailure =
275
401
  | DurableWorkerFailure
276
402
  | DurableBindingFailure
@@ -313,6 +439,8 @@ export class ThreadMaintenance extends Context.Service<
313
439
  static readonly layer: Layer.Layer<
314
440
  ThreadMaintenance,
315
441
  never,
442
+ | ThreadMutationGate
443
+ | ThreadPublication
316
444
  | DurableAgentRuntime
317
445
  | SubmissionLedger
318
446
  | DurableAlarmService
@@ -335,68 +463,13 @@ export class ThreadMaintenance extends Context.Service<
335
463
  * restarts at zero and merely re-arms sooner than a long-lived one would have.
336
464
  */
337
465
  const stalls = yield* Ref.make(0);
338
- /**
339
- * Incarnation-local mutation count guarded with the generation transactions below. It is
340
- * deliberately not durable: after eviction every begun mutation has stopped, while its
341
- * pre-armed dirty generation remains durable for recovery. The short gate never spans the
342
- * caller's mutation or cross-Object I/O.
343
- */
344
- const activeMutations = yield* Ref.make(0);
345
- const generationGate = yield* Semaphore.make(1);
346
- // At-least-once deliveries are idempotent, but overlapping pass bodies could otherwise
347
- // acknowledge state while a sibling pass is still mutating it. Port/RPC mutations do not
348
- // take this permit, so cross-Object I/O cannot deadlock the maintenance serialization.
466
+ const mutations = yield* ThreadMutationGate;
467
+ const publication = yield* ThreadPublication;
349
468
  const maintenancePassGate = yield* Semaphore.make(1);
350
469
  const minimumAlarmDelay = Math.max(1, Math.ceil(config.alarmBackoffBase / 2));
351
470
 
352
- const runTransaction = <A>(
353
- operation: string,
354
- transaction: () => Promise<A>,
355
- ): Effect.Effect<A, DurableAlarmError> =>
356
- Effect.tryPromise({
357
- try: transaction,
358
- catch: alarmFailure(operation),
359
- });
360
-
361
- const beginMutation = Effect.fn("ThreadMaintenance.beginMutation")(function* () {
362
- yield* failpoint.hit("maintenance:dirty:before");
363
- const now = yield* Clock.currentTimeMillis;
364
-
365
- yield* runTransaction("advance maintenance generation", () =>
366
- ctx.storage.transaction(async (transaction) => {
367
- const { state } = await readMaintenanceState(transaction);
368
-
369
- const next = ThreadMaintenanceState.make({
370
- ...state,
371
- dirty: state.dirty + 1n,
372
- });
373
-
374
- await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
375
- // The earliest configured retry bounds a newly actionable mutation without relying
376
- // on its best-effort immediate wake hint.
377
- await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
378
- }),
379
- );
380
- yield* failpoint.hit("maintenance:dirty:after");
381
- yield* Ref.update(activeMutations, (active) => active + 1);
382
- });
383
-
384
- const endMutation = generationGate.withPermit(
385
- Ref.update(activeMutations, (active) => Math.max(0, active - 1)),
386
- );
387
-
388
- const withMutation = <A, E, R>(
389
- body: Effect.Effect<A, E, R>,
390
- ): Effect.Effect<A, E | DurableAlarmError, R> =>
391
- Effect.acquireUseRelease(
392
- generationGate.withPermit(beginMutation()),
393
- () =>
394
- failpoint.hit("maintenance:mutation:armed").pipe(
395
- Effect.andThen(body),
396
- Effect.tap(() => failpoint.hit("maintenance:mutation:finished")),
397
- ),
398
- () => endMutation,
399
- );
471
+ const runTransaction = <A>(operation: string, transaction: () => Promise<A>) =>
472
+ Effect.tryPromise({ try: transaction, catch: alarmFailure(operation) });
400
473
 
401
474
  const ensureAlarm = Effect.fn("ThreadMaintenance.ensureAlarm")(function* () {
402
475
  yield* failpoint.hit("maintenance:ensure:before");
@@ -414,6 +487,18 @@ export class ThreadMaintenance extends Context.Service<
414
487
  }
415
488
  }),
416
489
  );
490
+ const deadline = yield* publication.pendingDeadline;
491
+
492
+ if (Option.isSome(deadline)) {
493
+ yield* runTransaction("ensure publication alarm", () =>
494
+ ctx.storage.transaction((transaction) =>
495
+ ensureTransactionAlarmBy(
496
+ transaction,
497
+ Math.max(now + minimumAlarmDelay, deadline.value),
498
+ ),
499
+ ),
500
+ );
501
+ }
417
502
  yield* failpoint.hit("maintenance:ensure:after");
418
503
  });
419
504
 
@@ -429,7 +514,8 @@ export class ThreadMaintenance extends Context.Service<
429
514
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
430
515
  }
431
516
  if (state.processed >= state.dirty) {
432
- await transaction.deleteAlarm();
517
+ // Prearm even a publication-only pass before invoking any host hook.
518
+ await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
433
519
 
434
520
  return { _tag: "CaughtUp" as const, nonterminal: state.nonterminal };
435
521
  }
@@ -437,7 +523,11 @@ export class ThreadMaintenance extends Context.Service<
437
523
  // LATER to its bounded backoff, which does not cancel the running handler.
438
524
  await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
439
525
 
440
- return { _tag: "Actionable" as const, generation: state.dirty };
526
+ return {
527
+ _tag: "Actionable" as const,
528
+ generation: state.dirty,
529
+ nonterminal: state.nonterminal,
530
+ };
441
531
  }),
442
532
  );
443
533
 
@@ -474,23 +564,74 @@ export class ThreadMaintenance extends Context.Service<
474
564
  alarm: report.alarm,
475
565
  }).pipe(Effect.as(report));
476
566
 
477
- const started = yield* generationGate.withPermit(
567
+ const started = yield* mutations.withSnapshot((activeAtStart) =>
478
568
  Effect.gen(function* () {
479
- const activeAtStart = yield* Ref.get(activeMutations);
480
569
  const generation = yield* beginPass();
481
570
 
571
+ if (generation._tag === "Actionable" && activeAtStart === 0) {
572
+ // The gate excludes a producer starting between the snapshot and certification.
573
+ yield* publication.prepareGeneration(generation.generation);
574
+ }
575
+
482
576
  return { ...generation, activeAtStart };
483
577
  }),
484
578
  );
485
579
 
486
- if (started._tag === "CaughtUp") {
580
+ const deadline = yield* publication.pendingDeadline;
581
+
582
+ if (
583
+ started._tag === "Actionable" ||
584
+ (Option.isSome(deadline) && deadline.value <= (yield* Clock.currentTimeMillis))
585
+ ) {
586
+ yield* publication.drain;
587
+ }
588
+ const pending = yield* publication.pendingDeadline;
589
+
590
+ if (started._tag === "CaughtUp" || Option.isSome(pending)) {
591
+ yield* failpoint.hit("maintenance:finish:before");
592
+
593
+ const disposition = yield* mutations.withSnapshot((active) =>
594
+ Effect.gen(function* () {
595
+ // Re-read under the producer gate: a concurrent append/host mutation cannot be
596
+ // cleared using a stale empty deadline. Dirty generations bound all producer races.
597
+ const latest = yield* publication.pendingDeadline;
598
+ const now = yield* Clock.currentTimeMillis;
599
+
600
+ return yield* runTransaction("finish publication pass", () =>
601
+ ctx.storage.transaction(async (transaction) => {
602
+ const { state } = await readMaintenanceState(transaction);
603
+
604
+ const nativeDeadline =
605
+ active > 0 || state.dirty > state.processed
606
+ ? now + config.wakeScanInterval
607
+ : Infinity;
608
+
609
+ const next = Option.isSome(latest)
610
+ ? Math.min(nativeDeadline, latest.value)
611
+ : nativeDeadline;
612
+
613
+ if (Number.isFinite(next)) {
614
+ await transaction.setAlarm(Math.max(now + minimumAlarmDelay, next));
615
+
616
+ return "rearmed" as const;
617
+ }
618
+ await transaction.deleteAlarm();
619
+
620
+ return "cleared" as const;
621
+ }),
622
+ );
623
+ }),
624
+ );
625
+
626
+ yield* failpoint.hit("maintenance:finish:after");
627
+
487
628
  return yield* annotate(
488
629
  MaintenancePassReport.make({
489
630
  phase: "caught-up",
490
631
  recovered: 0,
491
632
  settled: 0,
492
633
  nonterminal: started.nonterminal,
493
- alarm: "cleared",
634
+ alarm: disposition,
494
635
  }),
495
636
  );
496
637
  }
@@ -528,9 +669,9 @@ export class ThreadMaintenance extends Context.Service<
528
669
 
529
670
  yield* failpoint.hit("maintenance:finish:before");
530
671
 
531
- const alarmDisposition = yield* generationGate.withPermit(
672
+ const alarmDisposition = yield* mutations.withSnapshot((active) =>
532
673
  Effect.gen(function* () {
533
- const active = yield* Ref.get(activeMutations);
674
+ const publicationDeadline = yield* publication.pendingDeadline;
534
675
 
535
676
  return yield* runTransaction("finish maintenance pass", () =>
536
677
  ctx.storage.transaction(async (transaction) => {
@@ -556,7 +697,14 @@ export class ThreadMaintenance extends Context.Service<
556
697
  // Replace the crash-fallback slot with this pass's bounded backoff. The target
557
698
  // is never earlier than the begin-pass fallback, so workerd does not cancel
558
699
  // this running alarm handler before its report/span can complete.
559
- await transaction.setAlarm(now + delay);
700
+ await transaction.setAlarm(
701
+ Option.isSome(publicationDeadline)
702
+ ? Math.max(
703
+ now + minimumAlarmDelay,
704
+ Math.min(now + delay, publicationDeadline.value),
705
+ )
706
+ : now + delay,
707
+ );
560
708
 
561
709
  return "rearmed" as const;
562
710
  }
@@ -566,7 +714,22 @@ export class ThreadMaintenance extends Context.Service<
566
714
  // unseen effects are never acknowledged. Do not accelerate that future alarm
567
715
  // from inside the current handler: workerd cancels a running handler when it
568
716
  // writes an earlier slot.
569
- await ensureTransactionAlarmBy(transaction, now + config.wakeScanInterval);
717
+ await ensureTransactionAlarmBy(
718
+ transaction,
719
+ Option.isSome(publicationDeadline)
720
+ ? Math.max(
721
+ now + minimumAlarmDelay,
722
+ Math.min(now + config.wakeScanInterval, publicationDeadline.value),
723
+ )
724
+ : now + config.wakeScanInterval,
725
+ );
726
+
727
+ return "rearmed" as const;
728
+ }
729
+ if (Option.isSome(publicationDeadline)) {
730
+ await transaction.setAlarm(
731
+ Math.max(now + minimumAlarmDelay, publicationDeadline.value),
732
+ );
570
733
 
571
734
  return "rearmed" as const;
572
735
  }
@@ -615,8 +778,15 @@ export class ThreadMaintenance extends Context.Service<
615
778
  }),
616
779
  }),
617
780
  ),
618
- ensureAlarm: ensureAlarm(),
619
- withMutation,
781
+ ensureAlarm: mutations.withSnapshot(() => ensureAlarm()),
782
+ withMutation: (body) =>
783
+ mutations.withMutation(
784
+ body.pipe(
785
+ Effect.tap(() =>
786
+ publishCommitted.pipe(Effect.provideService(ThreadPublication, publication)),
787
+ ),
788
+ ),
789
+ ),
620
790
  });
621
791
  }),
622
792
  );
@@ -61,6 +61,7 @@ import { cloudflareFailureSignals, safeCauseMessage } from "./internal/boundary.
61
61
 
62
62
  /** Ceiling for host protocol diagnostic strings. */
63
63
  const MAX_HOST_DIAGNOSTIC_LENGTH = 4_096;
64
+ const PROGRESS_CANCELLATION_TIMEOUT = "1 second";
64
65
 
65
66
  const BoundedDiagnostic = Schema.String.check(Schema.isMaxLength(MAX_HOST_DIAGNOSTIC_LENGTH));
66
67
 
@@ -379,6 +380,7 @@ export class CloudflareThreadClient extends Context.Service<
379
380
  /**
380
381
  * Wait without polling until progress after `afterSequence` is already durable or hinted.
381
382
  * The result is deliberately void: canonical records remain authoritative and must be read.
383
+ * Interruption waits at most one second for best-effort remote cancellation.
382
384
  */
383
385
  readonly awaitProgress: (
384
386
  threadId: ThreadId,
@@ -556,6 +558,10 @@ export class CloudflareThreadClient extends Context.Service<
556
558
  encodeCancelProgressRequest(CancelProgressRequest.make({ waiterId })).pipe(
557
559
  Effect.mapError(() => undefined),
558
560
  Effect.flatMap((encoded) => call(threadId, "cancelProgress", encoded)),
561
+ // Finalizers are uninterruptible; only the foreign RPC branch must remain
562
+ // interruptible so a lost cancellation reply cannot prevent local shutdown.
563
+ Effect.interruptible,
564
+ Effect.timeout(PROGRESS_CANCELLATION_TIMEOUT),
559
565
  Effect.asVoid,
560
566
  Effect.ignore,
561
567
  );
@@ -103,6 +103,7 @@ import { ProgressWaitRegistry } from "./internal/progress-wait.ts";
103
103
  export {
104
104
  layer,
105
105
  layerConfig,
106
+ type ThreadPublicationOptions as PublicationOptions,
106
107
  type CloudflareDurableRuntimeOptions as RuntimeOptions,
107
108
  type CloudflareDurableRuntimeServices as Services,
108
109
  type CloudflareDurableRuntimeInitializationError as InitializationError,
@@ -271,14 +271,21 @@ const navigationError = (message: string, cause?: unknown): PageCaptureNavigatio
271
271
  const privateResponseCause = (bodyText: string): Error | undefined =>
272
272
  bodyText.length === 0 ? undefined : new Error(boundedDiagnostic(bodyText));
273
273
 
274
+ /** Foreign cancellation must not keep a response Scope open indefinitely. */
275
+ const cancelResponse = (cancel: () => Promise<void>, warning: string): Effect.Effect<void> =>
276
+ Effect.tryPromise({ try: cancel, catch: () => undefined }).pipe(
277
+ Effect.interruptible,
278
+ Effect.timeoutOrElse({
279
+ duration: "1 second",
280
+ orElse: () => Effect.fail(undefined),
281
+ }),
282
+ Effect.catch(() => Effect.logWarning(warning)),
283
+ );
284
+
274
285
  const releaseResponseReader = (
275
286
  reader: ReadableStreamDefaultReader<Uint8Array>,
276
287
  ): Effect.Effect<void> =>
277
- Effect.tryPromise({
278
- try: () => reader.cancel(),
279
- catch: (cause) => protocolError("Canceling the Quick Action response failed", cause),
280
- }).pipe(
281
- Effect.catch((error) => Effect.logWarning(error.message)),
288
+ cancelResponse(() => reader.cancel(), "Canceling the Quick Action response failed").pipe(
282
289
  Effect.ensuring(
283
290
  Effect.try({
284
291
  try: () => reader.releaseLock(),
@@ -655,16 +662,12 @@ const screenshotOptions = (request: PageScreenshotRequest): BrowserRunScreenshot
655
662
  };
656
663
 
657
664
  const cancelBody = (body: ReadableStream<Uint8Array>): Effect.Effect<void> =>
658
- Effect.tryPromise({
659
- try: () => body.cancel(),
660
- catch: () => undefined,
661
- }).pipe(Effect.catch(() => Effect.void));
665
+ cancelResponse(() => body.cancel(), "Canceling the screenshot response failed");
662
666
 
663
667
  const releaseScreenshotReader = (
664
668
  reader: ReadableStreamDefaultReader<Uint8Array>,
665
669
  ): Effect.Effect<void> =>
666
- Effect.tryPromise({ try: () => reader.cancel(), catch: () => undefined }).pipe(
667
- Effect.catch(() => Effect.logWarning("Canceling the screenshot response failed")),
670
+ cancelResponse(() => reader.cancel(), "Canceling the screenshot response failed").pipe(
668
671
  Effect.ensuring(
669
672
  Effect.try({
670
673
  try: () => reader.releaseLock(),
@@ -47,8 +47,8 @@ import {
47
47
  type OperationAuthorizerService,
48
48
  } from "@effect-agent/thread/OperationAuthorizer";
49
49
  import { ProducerId } from "@effect-agent/thread/Records";
50
- import { type SubmissionLedger } from "@effect-agent/thread/SubmissionLedger";
51
- import { type ThreadStore } from "@effect-agent/thread/ThreadStore";
50
+ import { LedgerError, SubmissionLedger } from "@effect-agent/thread/SubmissionLedger";
51
+ import { ThreadStoreError, ThreadStore } from "@effect-agent/thread/ThreadStore";
52
52
  import { ToolReconciler } from "@effect-agent/thread/ToolReconciler";
53
53
  import { type WakeScheduler } from "@effect-agent/thread/WakeScheduler";
54
54
  import { BrowserCrypto } from "@effect/platform-browser";
@@ -58,6 +58,9 @@ import { Context, Duration, Effect, Layer, Schema } from "effect";
58
58
 
59
59
  import {
60
60
  ThreadMaintenance,
61
+ ThreadMutationGate,
62
+ ThreadPublication,
63
+ publishCommitted,
61
64
  ThreadMaintenanceFailpoint,
62
65
  DurableAlarmService,
63
66
  type ThreadMaintenanceFailpointHandler,
@@ -168,6 +171,8 @@ export type CloudflareDurableRuntimeServices =
168
171
  | WakeScheduler
169
172
  | DurableAlarmService
170
173
  | ThreadMaintenance
174
+ | ThreadMutationGate
175
+ | ThreadPublication
171
176
  | ThreadObjectPorts
172
177
  | ProgressWaitRegistry;
173
178
 
@@ -311,15 +316,33 @@ export const layerConfig = (
311
316
  }),
312
317
  );
313
318
 
319
+ export interface ThreadPublicationOptions<E = never, R = never> {
320
+ /**
321
+ * Optional host outbox consumer, built once per incarnation with RAW LOCAL ThreadStore and
322
+ * SubmissionLedger services. Yield DurableObjectContext and ThreadObjectIdentity for native
323
+ * bindings and identity. Initialization is local-only, inside the constructor gate; setup
324
+ * errors and additional requirements remain in the returned Layer. Layer.effect owns Scope.
325
+ * Canonical appends and durable approval, abort and unknown-resolution intents invalidate
326
+ * publication after commit. Custom host facts must use ThreadMaintenance.withMutation.
327
+ */
328
+ readonly publication?: Layer.Layer<ThreadPublication, E, R>;
329
+ }
330
+
314
331
  /**
315
332
  * Register typed Agents and version declarations. Hashing and dependency capture happen in
316
333
  * this Layer's Scope, after application Layers have been provided. Every Agent's instruction,
317
334
  * Tool, Schema, and model requirements remain visible until satisfied by Layer composition.
318
335
  * Use Layer.unwrap for registration values that need effectful application setup.
319
336
  */
320
- export const layer = <const Entries extends ReadonlyArray<AgentRegistration>>(
337
+ export const layer = <const Entries extends ReadonlyArray<AgentRegistration>, E = never, R = never>(
321
338
  registrations: Entries,
322
- ) => Layer.unwrap(Effect.map(compileRegistrations(registrations), layerFromBindings));
339
+ options: ThreadPublicationOptions<E, R> = {},
340
+ ) =>
341
+ Layer.unwrap(
342
+ Effect.map(compileRegistrations(registrations), (bindings) =>
343
+ layerFromBindings(bindings, options),
344
+ ),
345
+ );
323
346
 
324
347
  /**
325
348
  * Assemble the durable runtime from already-resolved Agent Bindings.
@@ -327,12 +350,16 @@ export const layer = <const Entries extends ReadonlyArray<AgentRegistration>>(
327
350
  * Supply host services through `ThreadObject.make` or `ThreadObject.layerConfig` and
328
351
  * the Durable Object context and namespace Layers when composing a custom host.
329
352
  */
330
- export const layerFromBindings = (
353
+ export const layerFromBindings = <E = never, R = never>(
331
354
  bindings: ReadonlyArray<ResolvedBinding>,
355
+ options: ThreadPublicationOptions<E, R> = {},
332
356
  ): Layer.Layer<
333
357
  CloudflareDurableRuntimeServices,
334
- DoStorageInitializationError,
335
- DurableObjectContext | ThreadObjectNamespace | CloudflareBootstrapServices
358
+ DoStorageInitializationError | E,
359
+ | DurableObjectContext
360
+ | ThreadObjectNamespace
361
+ | CloudflareBootstrapServices
362
+ | Exclude<R, ThreadStore | SubmissionLedger>
336
363
  > =>
337
364
  Layer.unwrap(
338
365
  Effect.gen(function* () {
@@ -355,10 +382,67 @@ export const layerFromBindings = (
355
382
 
356
383
  // The same local ports serve routed decorators and owner-side RPC execution.
357
384
  // The RPC executor must never receive routed ports and bounce requests between Objects.
358
- const localPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
385
+ const rawLocalPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
359
386
  Layer.provide(infrastructure),
360
387
  );
361
388
 
389
+ const publication = (options.publication ?? ThreadPublication.layer).pipe(
390
+ Layer.provide(rawLocalPorts),
391
+ );
392
+
393
+ const localPorts =
394
+ options.publication === undefined
395
+ ? rawLocalPorts
396
+ : Layer.effectContext(
397
+ Effect.gen(function* () {
398
+ const store = yield* ThreadStore;
399
+ const ledger = yield* SubmissionLedger;
400
+ const mutations = yield* ThreadMutationGate;
401
+ const publish = yield* Effect.context<ThreadPublication>();
402
+ const afterCommit = publishCommitted.pipe(Effect.provide(publish));
403
+
404
+ // Every runtime-owned producer prearms too: a crash between commit and invalidation
405
+ // leaves a NEW, uncertified generation. Source errors keep their native port types.
406
+ const observedStore = ThreadStore.of({
407
+ ...store,
408
+ append: (request) =>
409
+ mutations
410
+ .withMutation(store.append(request).pipe(Effect.tap(() => afterCommit)))
411
+ .pipe(
412
+ Effect.catchTag("DurableAlarmError", (cause) =>
413
+ ThreadStoreError.make({
414
+ operation: "prearm publication append",
415
+ message: cause.message,
416
+ cause,
417
+ }),
418
+ ),
419
+ ),
420
+ });
421
+
422
+ const observeIntent = <A, Failure>(body: Effect.Effect<A, Failure>) =>
423
+ mutations.withMutation(body.pipe(Effect.tap(() => afterCommit))).pipe(
424
+ Effect.catchTag("DurableAlarmError", (cause) =>
425
+ LedgerError.make({
426
+ operation: "prearm publication intent",
427
+ message: "The publication generation could not be armed",
428
+ cause,
429
+ }),
430
+ ),
431
+ );
432
+
433
+ return Context.make(ThreadStore, observedStore).pipe(
434
+ Context.add(SubmissionLedger, {
435
+ ...ledger,
436
+ recordApprovalDecision: (request) =>
437
+ observeIntent(ledger.recordApprovalDecision(request)),
438
+ requestAbort: (request) => observeIntent(ledger.requestAbort(request)),
439
+ recordUnknownResolution: (request) =>
440
+ observeIntent(ledger.recordUnknownResolution(request)),
441
+ }),
442
+ );
443
+ }),
444
+ ).pipe(Layer.provide(rawLocalPorts));
445
+
362
446
  const portsEndpointLayer = Layer.effect(ThreadObjectPorts)(
363
447
  Effect.gen(function* () {
364
448
  const local = yield* Effect.context<SubmissionLedger | ThreadStore>();
@@ -386,6 +470,6 @@ export const layerFromBindings = (
386
470
  runtimeStack,
387
471
  ThreadMaintenance.layer.pipe(Layer.provide(runtimeStack)),
388
472
  portsEndpointLayer,
389
- );
473
+ ).pipe(Layer.provideMerge(publication), Layer.provideMerge(ThreadMutationGate.layer));
390
474
  }),
391
475
  );