@effect-agent/platform-cloudflare 0.1.0-beta.23 → 0.1.0-beta.25

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,8 +18,11 @@ import {
18
18
  type CodeExecutorExecute,
19
19
  type CodeExecutionRequest,
20
20
  } from "@effect-agent/sandbox";
21
+ import { BrowserCrypto } from "@effect/platform-browser";
21
22
  import { WorkerEntrypoint } from "cloudflare:workers";
22
- import { Duration, Effect, Exit, Fiber, Layer, Option, Queue, Schema } from "effect";
23
+ import { Clock, Crypto, Duration, Effect, Exit, Fiber, Layer, Option, Queue, Schema } from "effect";
24
+
25
+ import { safeCauseDiagnostic, safeCauseMessage } from "./boundary.ts";
23
26
 
24
27
  /**
25
28
  * The Cloudflare Dynamic Worker `CodeExecutor` adapter (C4 of ADR-0017;
@@ -43,6 +46,10 @@ export interface CodeModeHostStub {
43
46
  readonly call: (passId: string, hostCall: unknown) => Promise<unknown>;
44
47
  }
45
48
 
49
+ interface CodeModeHarnessEntrypoint extends Rpc.WorkerEntrypointBranded {
50
+ readonly run: () => Promise<unknown>;
51
+ }
52
+
46
53
  interface RegisteredPass {
47
54
  readonly dispatch: (hostCall: unknown) => Promise<unknown>;
48
55
  }
@@ -63,7 +70,10 @@ const passRegistry = new Map<string, RegisteredPass>();
63
70
  */
64
71
  export class CodeModeHostEntrypoint extends WorkerEntrypoint {
65
72
  async call(passId: unknown, hostCall: unknown): Promise<unknown> {
66
- const pass = passRegistry.get(String(passId));
73
+ if (typeof passId !== "string") {
74
+ throw new TypeError("Code Mode pass identities must be strings");
75
+ }
76
+ const pass = passRegistry.get(passId);
67
77
  if (pass === undefined) {
68
78
  throw new Error("Unknown Code Mode pass");
69
79
  }
@@ -104,7 +114,7 @@ const safeJson = (value) => {
104
114
 
105
115
  export default class CodeModeHarness extends WorkerEntrypoint {
106
116
  async run() {
107
- const config = JSON.parse(this.env.CODE_MODE_PASS);
117
+ const config = this.env.CODE_MODE_PASS;
108
118
  const host = this.env.CODE_MODE_HOST;
109
119
  const limits = config.limits;
110
120
  const logs = [];
@@ -227,50 +237,41 @@ const BoundedLogs = Schema.Array(Schema.String.check(Schema.isMaxLength(16 * 102
227
237
  Schema.isMaxLength(4_096),
228
238
  );
229
239
 
230
- const HarnessCompleted = Schema.Struct({
231
- _tag: Schema.Literal("completed"),
240
+ const HarnessCompleted = Schema.TaggedStruct("completed", {
232
241
  value: Schema.Json,
233
242
  logs: BoundedLogs,
234
243
  hostCalls: Schema.Natural,
235
244
  logBytes: Schema.Natural,
236
245
  resultBytes: Schema.Natural,
237
246
  });
238
- const HarnessSourceInvalid = Schema.Struct({
239
- _tag: Schema.Literal("source-invalid"),
247
+ const HarnessSourceInvalid = Schema.TaggedStruct("source-invalid", {
240
248
  message: Schema.String,
241
249
  });
242
- const HarnessNotAFunction = Schema.Struct({
243
- _tag: Schema.Literal("source-not-a-function"),
250
+ const HarnessNotAFunction = Schema.TaggedStruct("source-not-a-function", {
244
251
  actual: Schema.String,
245
252
  });
246
- const HarnessProgramFailed = Schema.Struct({
247
- _tag: Schema.Literal("program-failed"),
253
+ const HarnessProgramFailed = Schema.TaggedStruct("program-failed", {
248
254
  reason: Schema.Literals(["threw", "rejected", "non-json-result"]),
249
255
  thrown: Schema.Json,
250
256
  message: Schema.String,
251
257
  logs: BoundedLogs,
252
258
  });
253
- const HarnessLogLimit = Schema.Struct({
254
- _tag: Schema.Literal("log-limit"),
259
+ const HarnessLogLimit = Schema.TaggedStruct("log-limit", {
255
260
  observed: Schema.Natural,
256
261
  logs: BoundedLogs,
257
262
  });
258
- const HarnessArgumentLimit = Schema.Struct({
259
- _tag: Schema.Literal("argument-limit"),
263
+ const HarnessArgumentLimit = Schema.TaggedStruct("argument-limit", {
260
264
  observed: Schema.Natural,
261
265
  logs: BoundedLogs,
262
266
  });
263
- const HarnessResultLimit = Schema.Struct({
264
- _tag: Schema.Literal("result-limit"),
267
+ const HarnessResultLimit = Schema.TaggedStruct("result-limit", {
265
268
  observed: Schema.Natural,
266
269
  logs: BoundedLogs,
267
270
  });
268
- const HarnessHostCallLimit = Schema.Struct({
269
- _tag: Schema.Literal("host-call-limit"),
271
+ const HarnessHostCallLimit = Schema.TaggedStruct("host-call-limit", {
270
272
  logs: BoundedLogs,
271
273
  });
272
- const HarnessProtocol = Schema.Struct({
273
- _tag: Schema.Literal("protocol"),
274
+ const HarnessProtocol = Schema.TaggedStruct("protocol", {
274
275
  message: Schema.String,
275
276
  });
276
277
  const HarnessOutcome = Schema.Union([
@@ -285,6 +286,26 @@ const HarnessOutcome = Schema.Union([
285
286
  HarnessProtocol,
286
287
  ]);
287
288
 
289
+ const HarnessPassConfig = Schema.Struct({
290
+ passId: Schema.NonEmptyString,
291
+ namespaces: Schema.Array(
292
+ Schema.Struct({
293
+ name: Schema.NonEmptyString,
294
+ methods: Schema.Array(Schema.NonEmptyString).check(Schema.isMaxLength(64)),
295
+ }),
296
+ ).check(Schema.isMaxLength(32)),
297
+ limits: Schema.Struct({
298
+ maxLogBytes: Schema.Natural,
299
+ maxResultBytes: Schema.Natural,
300
+ maxHostCalls: Schema.Natural,
301
+ maxHostCallArgumentBytes: Schema.Natural,
302
+ }),
303
+ });
304
+
305
+ const encodeHarnessPassConfig = Schema.encodeSync(HarnessPassConfig);
306
+ const encodeJsonPayload = Schema.encodeSync(Schema.fromJsonString(Schema.Json));
307
+ const decodeJsonPayload = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
308
+
288
309
  const decodeHarnessOutcome = (value: unknown) => {
289
310
  try {
290
311
  return Schema.decodeUnknownOption(HarnessOutcome)(value);
@@ -309,6 +330,27 @@ const decodeHostCallResult = (value: unknown) => {
309
330
  }
310
331
  };
311
332
 
333
+ /** Dispose a Cloudflare RPC handle when the runtime supplies its untyped disposal hook. @internal */
334
+ export const disposeRpcHandle = (handle: unknown): Effect.Effect<void> =>
335
+ Effect.try({
336
+ try: () => {
337
+ if ((typeof handle !== "object" && typeof handle !== "function") || handle === null) return;
338
+ if (!(Symbol.dispose in handle)) return;
339
+ const dispose = Reflect.get(handle, Symbol.dispose);
340
+ if (typeof dispose === "function") {
341
+ Reflect.apply(dispose, handle, []);
342
+ }
343
+ },
344
+ catch: (cause) =>
345
+ safeCauseDiagnostic(cause, "The Cloudflare RPC disposal hook failed without a diagnostic"),
346
+ }).pipe(
347
+ Effect.catch((diagnostic) =>
348
+ Effect.logWarning(`Cloudflare RPC handle disposal failed: ${diagnostic}`).pipe(
349
+ Effect.ignoreCause,
350
+ ),
351
+ ),
352
+ );
353
+
312
354
  /**
313
355
  * Project a host outcome to the plain JSON envelope the harness reads. A
314
356
  * `CodeExecutionHost` may return either real `CodeHostCallResult` instances
@@ -326,8 +368,7 @@ const encodeHostResultPayload = (
326
368
  ): EncodedHostResultPayload | undefined => {
327
369
  try {
328
370
  const payload = outcome._tag === "CodeHostCallSuccess" ? outcome.value : outcome.error;
329
- const encodedPayload = JSON.stringify(payload);
330
- if (encodedPayload === undefined) return undefined;
371
+ const encodedPayload = encodeJsonPayload(payload);
331
372
  return {
332
373
  encodedPayload,
333
374
  resultBytes: utf8ByteLength(encodedPayload),
@@ -378,9 +419,11 @@ export interface DynamicWorkerCodeExecutorOptions {
378
419
  readonly compatibilityDate?: string | undefined;
379
420
  }
380
421
 
381
- const passCounterState = { next: 0 };
382
-
383
- const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExecute =>
422
+ const makeExecute = (
423
+ options: DynamicWorkerCodeExecutorOptions,
424
+ crypto: Crypto.Crypto,
425
+ clock: Clock.Clock,
426
+ ): CodeExecutorExecute =>
384
427
  Effect.fn("DynamicWorkerCodeExecutor.execute")(function* (request: CodeExecutionRequest) {
385
428
  if (request.network._tag !== "NetworkDisabled") {
386
429
  return yield* CodeExecutorUnsupportedError.make({
@@ -409,18 +452,32 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
409
452
  }
410
453
 
411
454
  const host = yield* CodeExecutionHost;
412
- passCounterState.next += 1;
413
455
  // The pass id is the only credential a loaded program presents to reach
414
456
  // its host authority, so it must be unguessable: a program cannot forge
415
457
  // 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()));
458
+ // keeps them sequential, but the id must not rely on that).
459
+ const passId = yield* crypto.randomUUIDv4.pipe(
460
+ Effect.mapError((cause) =>
461
+ CodeExecutorStartError.make({
462
+ implementation: dynamicWorkerImplementation,
463
+ message: `Could not mint the Code Mode pass identity: ${safeCauseMessage(
464
+ cause,
465
+ "the crypto service failed without a diagnostic",
466
+ )}`.slice(0, 8_000),
467
+ cause,
468
+ }),
469
+ ),
470
+ Effect.map((uuid) => `code-mode-pass-${uuid}`),
471
+ );
472
+
473
+ // This synchronous clock access is confined to callbacks that must compute a timeout
474
+ // immediately. The Clock service remains the authority, so tests and hosts can replace it.
475
+ const startedAt = clock.monotonicTimeNanosUnsafe();
476
+ const passDeadline = startedAt + Duration.toNanosUnsafe(request.limits.maxWallTime);
477
+ const remainingPassWallTime = (): Duration.Duration => {
478
+ const now = clock.monotonicTimeNanosUnsafe();
479
+ return Duration.nanos(passDeadline > now ? passDeadline - now : 0n);
480
+ };
424
481
  let issuedHostCalls = 0;
425
482
  let passOpen = true;
426
483
  const queuedHostCalls: Array<QueuedHostCall> = [];
@@ -461,11 +518,19 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
461
518
  failPass(error);
462
519
  return yield* error;
463
520
  }
464
- const normalizedPayload: unknown = JSON.parse(encoded.encodedPayload);
521
+ const normalizedPayload = decodeJsonPayload(encoded.encodedPayload);
522
+ if (Option.isNone(normalizedPayload)) {
523
+ const error = CodeExecutionProtocolError.make({
524
+ implementation: dynamicWorkerImplementation,
525
+ message: "The execution host returned a result that could not cross the JSON boundary",
526
+ });
527
+ failPass(error);
528
+ return yield* error;
529
+ }
465
530
  queued.resolve(
466
531
  decoded.value._tag === "CodeHostCallSuccess"
467
- ? { _tag: "CodeHostCallSuccess", value: normalizedPayload }
468
- : { _tag: "CodeHostCallFailure", error: normalizedPayload },
532
+ ? { _tag: "CodeHostCallSuccess", value: normalizedPayload.value }
533
+ : { _tag: "CodeHostCallFailure", error: normalizedPayload.value },
469
534
  );
470
535
  });
471
536
 
@@ -554,7 +619,7 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
554
619
  // worker carries the `experimental` compatibility flag, which deployed
555
620
  // consumers cannot set — the option would reject every pass in
556
621
  // production. The harness needs no experimental runtime features.
557
- const workerCode = {
622
+ const workerCode: WorkerLoaderWorkerCode = {
558
623
  compatibilityDate: options.compatibilityDate ?? "2025-05-01",
559
624
  mainModule: "harness.js",
560
625
  modules: {
@@ -563,7 +628,7 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
563
628
  },
564
629
  env: {
565
630
  CODE_MODE_HOST: options.hostStub,
566
- CODE_MODE_PASS: JSON.stringify({
631
+ CODE_MODE_PASS: encodeHarnessPassConfig({
567
632
  passId,
568
633
  namespaces: request.namespaces.map((namespace) => ({
569
634
  name: namespace.name,
@@ -590,9 +655,9 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
590
655
 
591
656
  const worker = yield* Effect.acquireRelease(
592
657
  Effect.try({
593
- try: () => options.loader.load(workerCode as never),
658
+ try: () => options.loader.load(workerCode),
594
659
  catch: (cause) => {
595
- const text = cause instanceof Error ? cause.message : String(cause);
660
+ const text = safeCauseMessage(cause, "The Worker Loader failed without a diagnostic");
596
661
  // Blame the program's source ONLY on a genuine compile diagnostic;
597
662
  // any other load rejection is an infrastructure start failure, not
598
663
  // the model's fault (see classifyWorkerFailure for the same split).
@@ -610,19 +675,19 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
610
675
  });
611
676
  },
612
677
  }),
613
- (stub) =>
614
- Effect.sync(() => {
615
- (stub as Partial<Record<typeof Symbol.dispose, () => void>>)[Symbol.dispose]?.();
616
- }),
678
+ disposeRpcHandle,
679
+ );
680
+
681
+ const entrypoint = yield* Effect.acquireRelease(
682
+ Effect.try({
683
+ try: () => worker.getEntrypoint<CodeModeHarnessEntrypoint>(),
684
+ catch: (cause) => classifyWorkerFailure(cause, request.limits.maxWallTime),
685
+ }),
686
+ disposeRpcHandle,
617
687
  );
618
688
 
619
689
  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
- },
690
+ try: () => entrypoint.run(),
626
691
  catch: (cause) => classifyWorkerFailure(cause, request.limits.maxWallTime),
627
692
  });
628
693
 
@@ -650,7 +715,7 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
650
715
  return yield* Effect.failCause(exit.cause);
651
716
  }
652
717
  const raw = exit.value;
653
- const finishedAt = performance.now();
718
+ const finishedAt = clock.monotonicTimeNanosUnsafe();
654
719
 
655
720
  const outcome = decodeHarnessOutcome(raw);
656
721
  if (Option.isNone(outcome)) {
@@ -666,7 +731,7 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
666
731
  value: outcome.value.value,
667
732
  logs: outcome.value.logs,
668
733
  resourceUse: CodeExecutionResourceUse.make({
669
- wallTime: Duration.millis(Math.max(0, finishedAt - startedAt)),
734
+ wallTime: Duration.nanos(finishedAt > startedAt ? finishedAt - startedAt : 0n),
670
735
  hostCalls: outcome.value.hostCalls,
671
736
  logBytes: outcome.value.logBytes,
672
737
  resultBytes: outcome.value.resultBytes,
@@ -752,13 +817,7 @@ const classifyWorkerFailure = (
752
817
  | CodeExecutorTerminatedError
753
818
  | CodeExecutorStartError
754
819
  | 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
- })();
820
+ const text = safeCauseDiagnostic(cause, "[unserializable worker failure]");
762
821
  // `WorkerLoader.load()` is lazy, so a module-compile error in the generated
763
822
  // program surfaces here at first use. Blame the program's source ONLY on a
764
823
  // genuine compile diagnostic (a `SyntaxError` or an explicit compile
@@ -798,4 +857,11 @@ const classifyWorkerFailure = (
798
857
  export const dynamicWorkerCodeExecutorLayer = (
799
858
  options: DynamicWorkerCodeExecutorOptions,
800
859
  ): Layer.Layer<CodeExecutor> =>
801
- Layer.succeed(CodeExecutor)(CodeExecutor.of({ execute: makeExecute(options) }));
860
+ Layer.effect(
861
+ CodeExecutor,
862
+ Effect.gen(function* () {
863
+ const crypto = yield* Crypto.Crypto;
864
+ const clock = yield* Clock.Clock;
865
+ return CodeExecutor.of({ execute: makeExecute(options, crypto, clock) });
866
+ }),
867
+ ).pipe(Layer.provide(BrowserCrypto.layer));
@@ -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(