@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.
- package/dist/index.d.mts +43 -60
- package/dist/index.mjs +259 -216
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/alarm.ts +2 -1
- package/src/bindings.ts +31 -30
- package/src/boundary.ts +51 -0
- package/src/client.ts +86 -187
- package/src/code-mode-executor.ts +130 -115
- package/src/conversation-object.ts +46 -20
- package/src/layers.ts +17 -33
- package/src/wake-scheduler.ts +2 -1
|
@@ -18,56 +18,52 @@ import {
|
|
|
18
18
|
type CodeExecutorExecute,
|
|
19
19
|
type CodeExecutionRequest,
|
|
20
20
|
} from "@effect-agent/sandbox";
|
|
21
|
-
import {
|
|
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
|
|
29
|
-
* routes back
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
47
|
-
readonly
|
|
46
|
+
interface CodeModeHarnessEntrypoint extends Rpc.WorkerEntrypointBranded {
|
|
47
|
+
readonly run: (host: CodeModePassHost) => Promise<unknown>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
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
|
-
|
|
57
|
+
class CodeModePassHostTarget extends RpcTarget implements CodeModePassHost {
|
|
58
|
+
readonly #dispatch: (hostCall: unknown) => Promise<unknown>;
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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(
|
|
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.
|
|
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.
|
|
239
|
-
_tag: Schema.Literal("source-invalid"),
|
|
232
|
+
const HarnessSourceInvalid = Schema.TaggedStruct("source-invalid", {
|
|
240
233
|
message: Schema.String,
|
|
241
234
|
});
|
|
242
|
-
const HarnessNotAFunction = Schema.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
608
|
+
try: () => options.loader.load(workerCode),
|
|
594
609
|
catch: (cause) => {
|
|
595
|
-
const text = 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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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:
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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)`.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
187
|
-
*
|
|
188
|
-
* request cannot bounce between Objects — and
|
|
189
|
-
*
|
|
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: (
|
|
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: (
|
|
345
|
+
handle: (request) => executePortRequest(request).pipe(Effect.provide(local)),
|
|
362
346
|
});
|
|
363
347
|
}),
|
|
364
348
|
).pipe(Layer.provide(localPorts));
|
package/src/wake-scheduler.ts
CHANGED
|
@@ -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
|
|
67
|
+
message: safeCauseMessage(cause, "The remote wake failed without a diagnostic"),
|
|
67
68
|
cause,
|
|
68
69
|
}),
|
|
69
70
|
}).pipe(
|