@effect-agent/platform-cloudflare 0.1.0-beta.24 → 0.1.0-beta.26

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.
@@ -18,56 +18,52 @@ import {
18
18
  type CodeExecutorExecute,
19
19
  type CodeExecutionRequest,
20
20
  } from "@effect-agent/sandbox";
21
- import { WorkerEntrypoint } from "cloudflare:workers";
22
- import { Duration, Effect, Exit, Fiber, Layer, Option, Queue, Schema } from "effect";
21
+ import { RpcTarget } from "cloudflare:workers";
22
+ import { Clock, Duration, Effect, Exit, Fiber, Layer, Option, Queue, Schema } from "effect";
23
+
24
+ import { safeCauseDiagnostic, safeCauseMessage } from "./boundary.ts";
23
25
 
24
26
  /**
25
27
  * The Cloudflare Dynamic Worker `CodeExecutor` adapter (C4 of ADR-0017;
26
28
  * DEPLOY-011). Each pass loads one fresh Worker through the Worker Loader
27
29
  * with `globalOutbound: null`, so generated code has no ambient network,
28
- * bindings, or secrets; its only authority is the pass-scoped host stub that
29
- * routes back to `CodeModeHostEntrypoint` and, from there, into the pass's
30
- * `CodeExecutionHost` service. Platform CPU limits stop synchronous runaway
31
- * programs; the executor-owned wall-clock deadline interrupts asynchronously
32
- * suspended passes. Deployment class `E` only: the adapter records no
33
- * persistent state and a later pass may run in a completely different
34
- * isolate.
30
+ * bindings, or secrets; its only authority is the pass-scoped RPC target that
31
+ * routes back into the owning event's `CodeExecutionHost` service. Platform
32
+ * CPU limits stop synchronous runaway programs; the executor-owned wall-clock
33
+ * deadline interrupts asynchronously suspended passes. Deployment class `E`
34
+ * only: the adapter records no persistent state and a later pass may run in a
35
+ * completely different isolate.
35
36
  */
36
37
  export const dynamicWorkerImplementation = SandboxImplementation.make({
37
38
  isolation: "isolated",
38
39
  identity: "cloudflare-dynamic-worker",
39
40
  });
40
41
 
41
- /** The narrow host stub the dynamic worker receives (Workers RPC). */
42
- export interface CodeModeHostStub {
43
- readonly call: (passId: string, hostCall: unknown) => Promise<unknown>;
42
+ interface CodeModePassHost extends Rpc.RpcTargetBranded {
43
+ readonly call: (hostCall: unknown) => Promise<unknown>;
44
44
  }
45
45
 
46
- interface RegisteredPass {
47
- readonly dispatch: (hostCall: unknown) => Promise<unknown>;
46
+ interface CodeModeHarnessEntrypoint extends Rpc.WorkerEntrypointBranded {
47
+ readonly run: (host: CodeModePassHost) => Promise<unknown>;
48
48
  }
49
49
 
50
50
  /**
51
- * Live passes by identity. Entries are Scope-managed: registered when a pass
52
- * opens and removed by its finalizer, so a stale harness (or a forged
53
- * `passId`) cannot reach any host authority.
51
+ * One object-capability endpoint for one execution pass. Workers RPC invokes
52
+ * the target in the request context where it was created, so the native
53
+ * Promise returned by `dispatch` and the Effect fiber that settles it share
54
+ * one I/O owner. Passing the target as `run()`'s argument also scopes the
55
+ * remote stub to that RPC call; no request state lives at module scope.
54
56
  */
55
- const passRegistry = new Map<string, RegisteredPass>();
57
+ class CodeModePassHostTarget extends RpcTarget implements CodeModePassHost {
58
+ readonly #dispatch: (hostCall: unknown) => Promise<unknown>;
56
59
 
57
- /**
58
- * The host-side RPC target for dynamic workers. The application exposes it
59
- * from its Worker entry (`export { CodeModeHostEntrypoint }`) and hands the
60
- * adapter a same-instance stub — `ctx.exports.CodeModeHostEntrypoint()` in
61
- * production (a self service binding may reach a different instance and must
62
- * not be used there); tests bind it through Miniflare's `kCurrentWorker`.
63
- */
64
- export class CodeModeHostEntrypoint extends WorkerEntrypoint {
65
- async call(passId: unknown, hostCall: unknown): Promise<unknown> {
66
- const pass = passRegistry.get(String(passId));
67
- if (pass === undefined) {
68
- throw new Error("Unknown Code Mode pass");
69
- }
70
- return pass.dispatch(hostCall);
60
+ constructor(dispatch: (hostCall: unknown) => Promise<unknown>) {
61
+ super();
62
+ this.#dispatch = dispatch;
63
+ }
64
+
65
+ call(hostCall: unknown): Promise<unknown> {
66
+ return this.#dispatch(hostCall);
71
67
  }
72
68
  }
73
69
 
@@ -103,9 +99,8 @@ const safeJson = (value) => {
103
99
  };
104
100
 
105
101
  export default class CodeModeHarness extends WorkerEntrypoint {
106
- async run() {
107
- const config = JSON.parse(this.env.CODE_MODE_PASS);
108
- const host = this.env.CODE_MODE_HOST;
102
+ async run(host) {
103
+ const config = this.env.CODE_MODE_PASS;
109
104
  const limits = config.limits;
110
105
  const logs = [];
111
106
  let logBytes = 0;
@@ -143,7 +138,7 @@ export default class CodeModeHarness extends WorkerEntrypoint {
143
138
  };
144
139
  throw new Error("code-mode host-call argument limit exceeded");
145
140
  }
146
- const outcome = await host.call(config.passId, {
141
+ const outcome = await host.call({
147
142
  namespace,
148
143
  method,
149
144
  argument: JSON.parse(argText),
@@ -227,50 +222,41 @@ const BoundedLogs = Schema.Array(Schema.String.check(Schema.isMaxLength(16 * 102
227
222
  Schema.isMaxLength(4_096),
228
223
  );
229
224
 
230
- const HarnessCompleted = Schema.Struct({
231
- _tag: Schema.Literal("completed"),
225
+ const HarnessCompleted = Schema.TaggedStruct("completed", {
232
226
  value: Schema.Json,
233
227
  logs: BoundedLogs,
234
228
  hostCalls: Schema.Natural,
235
229
  logBytes: Schema.Natural,
236
230
  resultBytes: Schema.Natural,
237
231
  });
238
- const HarnessSourceInvalid = Schema.Struct({
239
- _tag: Schema.Literal("source-invalid"),
232
+ const HarnessSourceInvalid = Schema.TaggedStruct("source-invalid", {
240
233
  message: Schema.String,
241
234
  });
242
- const HarnessNotAFunction = Schema.Struct({
243
- _tag: Schema.Literal("source-not-a-function"),
235
+ const HarnessNotAFunction = Schema.TaggedStruct("source-not-a-function", {
244
236
  actual: Schema.String,
245
237
  });
246
- const HarnessProgramFailed = Schema.Struct({
247
- _tag: Schema.Literal("program-failed"),
238
+ const HarnessProgramFailed = Schema.TaggedStruct("program-failed", {
248
239
  reason: Schema.Literals(["threw", "rejected", "non-json-result"]),
249
240
  thrown: Schema.Json,
250
241
  message: Schema.String,
251
242
  logs: BoundedLogs,
252
243
  });
253
- const HarnessLogLimit = Schema.Struct({
254
- _tag: Schema.Literal("log-limit"),
244
+ const HarnessLogLimit = Schema.TaggedStruct("log-limit", {
255
245
  observed: Schema.Natural,
256
246
  logs: BoundedLogs,
257
247
  });
258
- const HarnessArgumentLimit = Schema.Struct({
259
- _tag: Schema.Literal("argument-limit"),
248
+ const HarnessArgumentLimit = Schema.TaggedStruct("argument-limit", {
260
249
  observed: Schema.Natural,
261
250
  logs: BoundedLogs,
262
251
  });
263
- const HarnessResultLimit = Schema.Struct({
264
- _tag: Schema.Literal("result-limit"),
252
+ const HarnessResultLimit = Schema.TaggedStruct("result-limit", {
265
253
  observed: Schema.Natural,
266
254
  logs: BoundedLogs,
267
255
  });
268
- const HarnessHostCallLimit = Schema.Struct({
269
- _tag: Schema.Literal("host-call-limit"),
256
+ const HarnessHostCallLimit = Schema.TaggedStruct("host-call-limit", {
270
257
  logs: BoundedLogs,
271
258
  });
272
- const HarnessProtocol = Schema.Struct({
273
- _tag: Schema.Literal("protocol"),
259
+ const HarnessProtocol = Schema.TaggedStruct("protocol", {
274
260
  message: Schema.String,
275
261
  });
276
262
  const HarnessOutcome = Schema.Union([
@@ -285,6 +271,25 @@ const HarnessOutcome = Schema.Union([
285
271
  HarnessProtocol,
286
272
  ]);
287
273
 
274
+ const HarnessPassConfig = Schema.Struct({
275
+ namespaces: Schema.Array(
276
+ Schema.Struct({
277
+ name: Schema.NonEmptyString,
278
+ methods: Schema.Array(Schema.NonEmptyString).check(Schema.isMaxLength(64)),
279
+ }),
280
+ ).check(Schema.isMaxLength(32)),
281
+ limits: Schema.Struct({
282
+ maxLogBytes: Schema.Natural,
283
+ maxResultBytes: Schema.Natural,
284
+ maxHostCalls: Schema.Natural,
285
+ maxHostCallArgumentBytes: Schema.Natural,
286
+ }),
287
+ });
288
+
289
+ const encodeHarnessPassConfig = Schema.encodeSync(HarnessPassConfig);
290
+ const encodeJsonPayload = Schema.encodeSync(Schema.fromJsonString(Schema.Json));
291
+ const decodeJsonPayload = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
292
+
288
293
  const decodeHarnessOutcome = (value: unknown) => {
289
294
  try {
290
295
  return Schema.decodeUnknownOption(HarnessOutcome)(value);
@@ -309,6 +314,27 @@ const decodeHostCallResult = (value: unknown) => {
309
314
  }
310
315
  };
311
316
 
317
+ /** Dispose a Cloudflare RPC handle when the runtime supplies its untyped disposal hook. @internal */
318
+ export const disposeRpcHandle = (handle: unknown): Effect.Effect<void> =>
319
+ Effect.try({
320
+ try: () => {
321
+ if ((typeof handle !== "object" && typeof handle !== "function") || handle === null) return;
322
+ if (!(Symbol.dispose in handle)) return;
323
+ const dispose = Reflect.get(handle, Symbol.dispose);
324
+ if (typeof dispose === "function") {
325
+ Reflect.apply(dispose, handle, []);
326
+ }
327
+ },
328
+ catch: (cause) =>
329
+ safeCauseDiagnostic(cause, "The Cloudflare RPC disposal hook failed without a diagnostic"),
330
+ }).pipe(
331
+ Effect.catch((diagnostic) =>
332
+ Effect.logWarning(`Cloudflare RPC handle disposal failed: ${diagnostic}`).pipe(
333
+ Effect.ignoreCause,
334
+ ),
335
+ ),
336
+ );
337
+
312
338
  /**
313
339
  * Project a host outcome to the plain JSON envelope the harness reads. A
314
340
  * `CodeExecutionHost` may return either real `CodeHostCallResult` instances
@@ -326,8 +352,7 @@ const encodeHostResultPayload = (
326
352
  ): EncodedHostResultPayload | undefined => {
327
353
  try {
328
354
  const payload = outcome._tag === "CodeHostCallSuccess" ? outcome.value : outcome.error;
329
- const encodedPayload = JSON.stringify(payload);
330
- if (encodedPayload === undefined) return undefined;
355
+ const encodedPayload = encodeJsonPayload(payload);
331
356
  return {
332
357
  encodedPayload,
333
358
  resultBytes: utf8ByteLength(encodedPayload),
@@ -368,19 +393,14 @@ const reservedHarnessGlobals = new Set(["console"]);
368
393
  export interface DynamicWorkerCodeExecutorOptions {
369
394
  /** The `worker_loader` binding. */
370
395
  readonly loader: WorkerLoader;
371
- /**
372
- * A SAME-INSTANCE stub of `CodeModeHostEntrypoint`. In production create it
373
- * with `ctx.exports.CodeModeHostEntrypoint()`; a cross-instance stub would
374
- * dispatch host calls into an isolate without this pass's registry entry.
375
- */
376
- readonly hostStub: CodeModeHostStub;
377
396
  /** Compatibility date for dynamic workers; defaults to `2025-05-01`. */
378
397
  readonly compatibilityDate?: string | undefined;
379
398
  }
380
399
 
381
- const passCounterState = { next: 0 };
382
-
383
- const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExecute =>
400
+ const makeExecute = (
401
+ options: DynamicWorkerCodeExecutorOptions,
402
+ clock: Clock.Clock,
403
+ ): CodeExecutorExecute =>
384
404
  Effect.fn("DynamicWorkerCodeExecutor.execute")(function* (request: CodeExecutionRequest) {
385
405
  if (request.network._tag !== "NetworkDisabled") {
386
406
  return yield* CodeExecutorUnsupportedError.make({
@@ -407,20 +427,16 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
407
427
  });
408
428
  }
409
429
  }
410
-
411
430
  const host = yield* CodeExecutionHost;
412
- passCounterState.next += 1;
413
- // The pass id is the only credential a loaded program presents to reach
414
- // its host authority, so it must be unguessable: a program cannot forge
415
- // another pass's id even if two passes were ever concurrent (the broker
416
- // keeps them sequential, but the id must not rely on that). The counter
417
- // prefix keeps ids debuggable; the random suffix makes them unforgeable.
418
- const passId = `code-mode-pass-${passCounterState.next}-${crypto.randomUUID()}`;
419
-
420
- const startedAt = performance.now();
421
- const passDeadline = startedAt + Duration.toMillis(request.limits.maxWallTime);
422
- const remainingPassWallTime = (): Duration.Duration =>
423
- Duration.millis(Math.max(0, passDeadline - performance.now()));
431
+
432
+ // This synchronous clock access is confined to callbacks that must compute a timeout
433
+ // immediately. The Clock service remains the authority, so tests and hosts can replace it.
434
+ const startedAt = clock.monotonicTimeNanosUnsafe();
435
+ const passDeadline = startedAt + Duration.toNanosUnsafe(request.limits.maxWallTime);
436
+ const remainingPassWallTime = (): Duration.Duration => {
437
+ const now = clock.monotonicTimeNanosUnsafe();
438
+ return Duration.nanos(passDeadline > now ? passDeadline - now : 0n);
439
+ };
424
440
  let issuedHostCalls = 0;
425
441
  let passOpen = true;
426
442
  const queuedHostCalls: Array<QueuedHostCall> = [];
@@ -461,11 +477,19 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
461
477
  failPass(error);
462
478
  return yield* error;
463
479
  }
464
- const normalizedPayload: unknown = JSON.parse(encoded.encodedPayload);
480
+ const normalizedPayload = decodeJsonPayload(encoded.encodedPayload);
481
+ if (Option.isNone(normalizedPayload)) {
482
+ const error = CodeExecutionProtocolError.make({
483
+ implementation: dynamicWorkerImplementation,
484
+ message: "The execution host returned a result that could not cross the JSON boundary",
485
+ });
486
+ failPass(error);
487
+ return yield* error;
488
+ }
465
489
  queued.resolve(
466
490
  decoded.value._tag === "CodeHostCallSuccess"
467
- ? { _tag: "CodeHostCallSuccess", value: normalizedPayload }
468
- : { _tag: "CodeHostCallFailure", error: normalizedPayload },
491
+ ? { _tag: "CodeHostCallSuccess", value: normalizedPayload.value }
492
+ : { _tag: "CodeHostCallFailure", error: normalizedPayload.value },
469
493
  );
470
494
  });
471
495
 
@@ -539,22 +563,15 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
539
563
 
540
564
  const closeAdmission = Effect.sync(() => {
541
565
  passOpen = false;
542
- passRegistry.delete(passId);
543
566
  rejectQueuedHostCalls(new Error("Code Mode pass is closing"));
544
567
  });
545
-
546
- yield* Effect.acquireRelease(
547
- Effect.sync(() => {
548
- passRegistry.set(passId, { dispatch });
549
- }),
550
- () => closeAdmission.pipe(Effect.andThen(Fiber.interrupt(server))),
551
- );
568
+ yield* Effect.addFinalizer(() => closeAdmission.pipe(Effect.andThen(Fiber.interrupt(server))));
552
569
 
553
570
  // No `allowExperimental`: the runtime only accepts it when the CALLING
554
571
  // worker carries the `experimental` compatibility flag, which deployed
555
572
  // consumers cannot set — the option would reject every pass in
556
573
  // production. The harness needs no experimental runtime features.
557
- const workerCode = {
574
+ const workerCode: WorkerLoaderWorkerCode = {
558
575
  compatibilityDate: options.compatibilityDate ?? "2025-05-01",
559
576
  mainModule: "harness.js",
560
577
  modules: {
@@ -562,9 +579,7 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
562
579
  "program.js": `export default (\n${request.source}\n);`,
563
580
  },
564
581
  env: {
565
- CODE_MODE_HOST: options.hostStub,
566
- CODE_MODE_PASS: JSON.stringify({
567
- passId,
582
+ CODE_MODE_PASS: encodeHarnessPassConfig({
568
583
  namespaces: request.namespaces.map((namespace) => ({
569
584
  name: namespace.name,
570
585
  methods: namespace.methods,
@@ -590,9 +605,9 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
590
605
 
591
606
  const worker = yield* Effect.acquireRelease(
592
607
  Effect.try({
593
- try: () => options.loader.load(workerCode as never),
608
+ try: () => options.loader.load(workerCode),
594
609
  catch: (cause) => {
595
- const text = cause instanceof Error ? cause.message : String(cause);
610
+ const text = safeCauseMessage(cause, "The Worker Loader failed without a diagnostic");
596
611
  // Blame the program's source ONLY on a genuine compile diagnostic;
597
612
  // any other load rejection is an infrastructure start failure, not
598
613
  // the model's fault (see classifyWorkerFailure for the same split).
@@ -610,19 +625,19 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
610
625
  });
611
626
  },
612
627
  }),
613
- (stub) =>
614
- Effect.sync(() => {
615
- (stub as Partial<Record<typeof Symbol.dispose, () => void>>)[Symbol.dispose]?.();
616
- }),
628
+ disposeRpcHandle,
629
+ );
630
+
631
+ const entrypoint = yield* Effect.acquireRelease(
632
+ Effect.try({
633
+ try: () => worker.getEntrypoint<CodeModeHarnessEntrypoint>(),
634
+ catch: (cause) => classifyWorkerFailure(cause, request.limits.maxWallTime),
635
+ }),
636
+ disposeRpcHandle,
617
637
  );
618
638
 
619
639
  const rpc = Effect.tryPromise({
620
- try: async () => {
621
- const entrypoint = worker.getEntrypoint() as unknown as {
622
- run(): Promise<unknown>;
623
- };
624
- return await entrypoint.run();
625
- },
640
+ try: () => entrypoint.run(new CodeModePassHostTarget(dispatch)),
626
641
  catch: (cause) => classifyWorkerFailure(cause, request.limits.maxWallTime),
627
642
  });
628
643
 
@@ -650,7 +665,7 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
650
665
  return yield* Effect.failCause(exit.cause);
651
666
  }
652
667
  const raw = exit.value;
653
- const finishedAt = performance.now();
668
+ const finishedAt = clock.monotonicTimeNanosUnsafe();
654
669
 
655
670
  const outcome = decodeHarnessOutcome(raw);
656
671
  if (Option.isNone(outcome)) {
@@ -666,7 +681,7 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
666
681
  value: outcome.value.value,
667
682
  logs: outcome.value.logs,
668
683
  resourceUse: CodeExecutionResourceUse.make({
669
- wallTime: Duration.millis(Math.max(0, finishedAt - startedAt)),
684
+ wallTime: Duration.nanos(finishedAt > startedAt ? finishedAt - startedAt : 0n),
670
685
  hostCalls: outcome.value.hostCalls,
671
686
  logBytes: outcome.value.logBytes,
672
687
  resultBytes: outcome.value.resultBytes,
@@ -752,13 +767,7 @@ const classifyWorkerFailure = (
752
767
  | CodeExecutorTerminatedError
753
768
  | CodeExecutorStartError
754
769
  | CodeSourceError => {
755
- const text = (() => {
756
- try {
757
- return cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
758
- } catch {
759
- return "[unserializable worker failure]";
760
- }
761
- })();
770
+ const text = safeCauseDiagnostic(cause, "[unserializable worker failure]");
762
771
  // `WorkerLoader.load()` is lazy, so a module-compile error in the generated
763
772
  // program surfaces here at first use. Blame the program's source ONLY on a
764
773
  // genuine compile diagnostic (a `SyntaxError` or an explicit compile
@@ -798,4 +807,10 @@ const classifyWorkerFailure = (
798
807
  export const dynamicWorkerCodeExecutorLayer = (
799
808
  options: DynamicWorkerCodeExecutorOptions,
800
809
  ): Layer.Layer<CodeExecutor> =>
801
- Layer.succeed(CodeExecutor)(CodeExecutor.of({ execute: makeExecute(options) }));
810
+ Layer.effect(
811
+ CodeExecutor,
812
+ Effect.gen(function* () {
813
+ const clock = yield* Clock.Clock;
814
+ return CodeExecutor.of({ execute: makeExecute(options, clock) });
815
+ }),
816
+ );
@@ -30,6 +30,11 @@ import {
30
30
  WakeScheduler,
31
31
  type DurableSubmitAgent,
32
32
  } from "@effect-agent/session";
33
+ import {
34
+ decodePortRequest,
35
+ encodePortResponse,
36
+ type PortRequest,
37
+ } from "@effect-agent/storage-cloudflare";
33
38
  import type { DurableObject as CloudflareDurableObject } from "cloudflare:workers";
34
39
  import { Effect, Layer, Option, Schema, Stream } from "effect";
35
40
  import {
@@ -120,22 +125,26 @@ type ConversationObjectInitializationError =
120
125
  | CloudflareBindingError
121
126
  | MaintenancePassFailure;
122
127
 
123
- /** Port envelope tags whose owner-side execution durably mutates this Object's lane. */
124
- const MUTATING_PORT_TAGS: ReadonlySet<string> = new Set([
125
- "LedgerAdmit",
126
- "LedgerMarkReady",
127
- "LedgerRequestAbort",
128
- "LedgerRecordChildSettled",
129
- "StoreMaterialize",
130
- "StoreAppend",
131
- ]);
132
-
133
- const isMutatingPortRequest = (encoded: unknown): boolean =>
134
- typeof encoded === "object" &&
135
- encoded !== null &&
136
- "_tag" in encoded &&
137
- typeof encoded._tag === "string" &&
138
- MUTATING_PORT_TAGS.has(encoded._tag);
128
+ /** Classify only a decoded port request so new protocol members cannot bypass pre-arming. */
129
+ const isMutatingPortRequest = (request: PortRequest): boolean => {
130
+ switch (request._tag) {
131
+ case "LedgerAdmit":
132
+ case "LedgerMarkReady":
133
+ case "LedgerRequestAbort":
134
+ case "LedgerRecordChildSettled":
135
+ case "StoreMaterialize":
136
+ case "StoreAppend":
137
+ return true;
138
+ case "LedgerLookup":
139
+ case "LedgerResolveAdmission":
140
+ case "StoreReadPage":
141
+ case "StoreInspectTail":
142
+ case "StoreExport":
143
+ return false;
144
+ }
145
+ request satisfies never;
146
+ return false;
147
+ };
139
148
 
140
149
  /** The literal encoded `PortFailed(PortProtocolError)` fallback (same shape as WP2's). */
141
150
  const encodedPortProtocolFailure = (message: string): unknown => ({
@@ -171,7 +180,7 @@ const encodeResponse = (response: HostResponse): Effect.Effect<unknown> =>
171
180
  ),
172
181
  );
173
182
 
174
- const utf8Bytes = (value: unknown): number =>
183
+ const utf8Bytes = (value: PersistedJson): number =>
175
184
  new TextEncoder().encode(JSON.stringify(value)).length;
176
185
 
177
186
  /**
@@ -593,9 +602,20 @@ const portCallEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpo
593
602
  const ports = yield* ConversationObjectPorts;
594
603
  const maintenance = yield* ConversationMaintenance;
595
604
  const alarm = yield* DurableAlarmService;
596
- const mutating = isMutatingPortRequest(encoded);
605
+ const decoded = yield* decodePortRequest(encoded).pipe(
606
+ Effect.map((request) => ({ _tag: "success" as const, request })),
607
+ Effect.catch((error) => Effect.succeed({ _tag: "failure" as const, message: error.message })),
608
+ );
609
+ if (decoded._tag === "failure") {
610
+ return encodedPortProtocolFailure(
611
+ `The port request could not be decoded: ${decoded.message}`,
612
+ );
613
+ }
614
+ const mutating = isMutatingPortRequest(decoded.request);
597
615
  const handled = yield* (
598
- mutating ? maintenance.withMutation(ports.handle(encoded)) : ports.handle(encoded)
616
+ mutating
617
+ ? maintenance.withMutation(ports.handle(decoded.request))
618
+ : ports.handle(decoded.request)
599
619
  ).pipe(Effect.exit);
600
620
  if (handled._tag === "Failure") {
601
621
  // Without the committed generation/alarm the invariant cannot be promised; refuse before
@@ -604,7 +624,13 @@ const portCallEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpo
604
624
  "The owner Object could not arm its maintenance alarm before the mutation.",
605
625
  );
606
626
  }
607
- const response = handled.value;
627
+ const response = yield* encodePortResponse(handled.value).pipe(
628
+ Effect.catch((error) =>
629
+ Effect.succeed(
630
+ encodedPortProtocolFailure(`The port response could not be encoded: ${error.message}`),
631
+ ),
632
+ ),
633
+ );
608
634
  if (mutating) {
609
635
  // Prompt processing hint; the pre-armed alarm already guarantees convergence.
610
636
  yield* alarm.scheduleNow.pipe(
package/src/layers.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  } from "@effect-agent/session";
19
19
  import {
20
20
  conversationStoreLayer,
21
- handleEncodedPortRequest,
21
+ executePortRequest,
22
22
  routedConversationStoreLayer,
23
23
  routedSubmissionLedgerLayer,
24
24
  storageConfigLayer,
@@ -27,6 +27,8 @@ import {
27
27
  type DoStorageFailpointHandler,
28
28
  type DoStorageInitializationError,
29
29
  type DoStorageOptions,
30
+ type PortRequest,
31
+ type PortResponse,
30
32
  } from "@effect-agent/storage-cloudflare";
31
33
  import { BrowserCrypto } from "@effect/platform-browser";
32
34
  import { SqliteClient } from "@effect/sql-sqlite-do";
@@ -114,10 +116,9 @@ export interface CloudflareDurableRuntimeOptions {
114
116
  readonly toolReconciler?: Layer.Layer<ToolReconciler> | undefined;
115
117
  /**
116
118
  * Registered worker Bindings resolved at durable claim time (S2, spec/subagents.md §11):
117
- * build each with `DurableWorkerBinding.make(binding, digests)`. An Effect or callback form is
118
- * accepted because capture can be effectful. The callback receives the live Object context and
119
- * derived identities and is evaluated once per incarnation during Layer construction. Defaults
120
- * to the empty registration (every resolved claim fails closed).
119
+ * build each with `DurableWorkerBinding.make(binding, digests)`. The callback receives the live
120
+ * Object context and derived identities and is evaluated once per incarnation during Layer
121
+ * construction. Defaults to the empty registration (every resolved claim fails closed).
121
122
  */
122
123
  readonly bindings?: CloudflareBindingSource | undefined;
123
124
  /**
@@ -141,18 +142,10 @@ export interface CloudflareRuntimeSourceContext {
141
142
  /** Per-incarnation host values available while registered worker Bindings are captured. */
142
143
  export interface CloudflareBindingSourceContext extends CloudflareRuntimeSourceContext {}
143
144
 
144
- /**
145
- * Registered worker Bindings, or a closed Effect/callback that captures them once for each
146
- * Durable Object incarnation. Callback Effects cannot require services or fail typed.
147
- */
148
- export type CloudflareBindingSource =
149
- | ReadonlyArray<ResolvedBinding>
150
- | Effect.Effect<ReadonlyArray<ResolvedBinding>, never, never>
151
- | ((
152
- context: CloudflareBindingSourceContext,
153
- ) =>
154
- | ReadonlyArray<ResolvedBinding>
155
- | Effect.Effect<ReadonlyArray<ResolvedBinding>, never, never>);
145
+ /** Captures registered worker Bindings once for each Durable Object incarnation. */
146
+ export type CloudflareBindingSource = (
147
+ context: CloudflareBindingSourceContext,
148
+ ) => Effect.Effect<ReadonlyArray<ResolvedBinding>, never, never>;
156
149
 
157
150
  /** A closed Run-context service whose only remaining requirement is platform Crypto. */
158
151
  export type CloudflareRunContextLayer = Layer.Layer<RunContextPreparation, never, Crypto.Crypto>;
@@ -183,15 +176,15 @@ export type CloudflareDurableRuntimeServices =
183
176
  | ProgressWaitRegistry;
184
177
 
185
178
  /**
186
- * Owner-side endpoint body for the Conversation Object's `portCall` (plan §1.3): decode,
187
- * execute against THIS Object's LOCAL port facets — never the routed decorators, so a
188
- * request cannot bounce between Objects — and answer the encoded response envelope. Total by
189
- * construction (protocol anomalies answer `PortFailed(PortProtocolError)`).
179
+ * Owner-side execution port for a `portCall` request the wire endpoint has already decoded.
180
+ * It executes against THIS Object's LOCAL port facets — never the routed decorators, so a
181
+ * request cannot bounce between Objects — and returns the typed response for the endpoint to
182
+ * encode.
190
183
  */
191
184
  export class ConversationObjectPorts extends Context.Service<
192
185
  ConversationObjectPorts,
193
186
  {
194
- readonly handle: (encoded: unknown) => Effect.Effect<unknown>;
187
+ readonly handle: (request: PortRequest) => Effect.Effect<PortResponse>;
195
188
  }
196
189
  >()("@effect-agent/platform-cloudflare/ConversationObjectPorts") {}
197
190
 
@@ -267,16 +260,7 @@ const resolveBindings = (
267
260
  source: CloudflareDurableRuntimeOptions["bindings"],
268
261
  context: CloudflareBindingSourceContext,
269
262
  ): Effect.Effect<ReadonlyArray<ResolvedBinding>> =>
270
- source === undefined
271
- ? Effect.succeed([])
272
- : Effect.isEffect(source)
273
- ? source
274
- : typeof source === "function"
275
- ? Effect.suspend(() => {
276
- const bindings = source(context);
277
- return Effect.isEffect(bindings) ? bindings : Effect.succeed(bindings);
278
- })
279
- : Effect.succeed(source);
263
+ source === undefined ? Effect.succeed([]) : Effect.suspend(() => source(context));
280
264
 
281
265
  const resolveRunContext = (
282
266
  source: CloudflareRunContextSource,
@@ -358,7 +342,7 @@ export class CloudflareDurableRuntime {
358
342
  Effect.gen(function* () {
359
343
  const local = yield* Effect.context<SubmissionLedger | ConversationStore>();
360
344
  return ConversationObjectPorts.of({
361
- handle: (encoded) => handleEncodedPortRequest(encoded).pipe(Effect.provide(local)),
345
+ handle: (request) => executePortRequest(request).pipe(Effect.provide(local)),
362
346
  });
363
347
  }),
364
348
  ).pipe(Layer.provide(localPorts));
@@ -4,6 +4,7 @@ import { Effect, Layer, PubSub, Schema, Stream } from "effect";
4
4
 
5
5
  import { DurableAlarmService } from "./alarm.ts";
6
6
  import { ConversationObjectIdentity, ConversationObjectNamespace } from "./bindings.ts";
7
+ import { safeCauseMessage } from "./boundary.ts";
7
8
 
8
9
  /**
9
10
  * Bounded in-memory wake buffer for same-incarnation `awaitSettlement` subscribers. Wake
@@ -63,7 +64,7 @@ export const cloudflareWakeSchedulerLayer: Layer.Layer<
63
64
  catch: (cause) =>
64
65
  RemoteWakeDropped.make({
65
66
  conversationId,
66
- message: cause instanceof Error ? cause.message : String(cause),
67
+ message: safeCauseMessage(cause, "The remote wake failed without a diagnostic"),
67
68
  cause,
68
69
  }),
69
70
  }).pipe(