@effect-agent/platform-cloudflare 0.1.0-beta.12 → 0.1.0-beta.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-agent/platform-cloudflare",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.14",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/index.d.mts",
@@ -8,10 +8,10 @@
8
8
  }
9
9
  },
10
10
  "dependencies": {
11
- "@effect-agent/core": "0.1.0-beta.12",
12
- "@effect-agent/sandbox": "0.1.0-beta.12",
13
- "@effect-agent/session": "0.1.0-beta.12",
14
- "@effect-agent/storage-cloudflare": "0.1.0-beta.12",
11
+ "@effect-agent/core": "0.1.0-beta.14",
12
+ "@effect-agent/sandbox": "0.1.0-beta.14",
13
+ "@effect-agent/session": "0.1.0-beta.14",
14
+ "@effect-agent/storage-cloudflare": "0.1.0-beta.14",
15
15
  "@effect/platform-browser": "4.0.0-beta.107",
16
16
  "@effect/sql-sqlite-do": "4.0.0-beta.107",
17
17
  "effect": "4.0.0-beta.107"
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  CodeExecutionHost,
3
- CodeExecutionRequest,
4
3
  CodeExecutionResourceUse,
5
4
  CodeExecutionResult,
6
5
  CodeExecutionTimeoutError,
@@ -17,9 +16,10 @@ import {
17
16
  CodeSourceError,
18
17
  SandboxImplementation,
19
18
  type CodeExecutorExecute,
19
+ type CodeExecutionRequest,
20
20
  } from "@effect-agent/sandbox";
21
21
  import { WorkerEntrypoint } from "cloudflare:workers";
22
- import { Clock, Duration, Effect, Fiber, Layer, Option, Schema } from "effect";
22
+ import { Cause, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect";
23
23
 
24
24
  /**
25
25
  * The Cloudflare Dynamic Worker `CodeExecutor` adapter (C4 of ADR-0017;
@@ -301,6 +301,14 @@ const decodeHostCall = (value: unknown) => {
301
301
  }
302
302
  };
303
303
 
304
+ const decodeHostCallResult = (value: unknown) => {
305
+ try {
306
+ return Schema.decodeUnknownOption(CodeHostCallResult)(value);
307
+ } catch {
308
+ return Option.none<CodeHostCallResult>();
309
+ }
310
+ };
311
+
304
312
  /**
305
313
  * Project a host outcome to the plain JSON envelope the harness reads. A
306
314
  * `CodeExecutionHost` may return either real `CodeHostCallResult` instances
@@ -308,10 +316,26 @@ const decodeHostCall = (value: unknown) => {
308
316
  * Mode capability's broker route), so this reads the shared fields rather than
309
317
  * `Schema.encodeSync`, which would reject a plain object.
310
318
  */
311
- const hostResultEnvelope = (outcome: CodeHostCallResult): Record<string, unknown> =>
312
- outcome._tag === "CodeHostCallSuccess"
313
- ? { _tag: "CodeHostCallSuccess", value: outcome.value }
314
- : { _tag: "CodeHostCallFailure", error: outcome.error };
319
+ interface EncodedHostResultPayload {
320
+ readonly encodedPayload: string;
321
+ readonly resultBytes: number;
322
+ }
323
+
324
+ const encodeHostResultPayload = (
325
+ outcome: CodeHostCallResult,
326
+ ): EncodedHostResultPayload | undefined => {
327
+ try {
328
+ const payload = outcome._tag === "CodeHostCallSuccess" ? outcome.value : outcome.error;
329
+ const encodedPayload = JSON.stringify(payload);
330
+ if (encodedPayload === undefined) return undefined;
331
+ return {
332
+ encodedPayload,
333
+ resultBytes: utf8ByteLength(encodedPayload),
334
+ };
335
+ } catch {
336
+ return undefined;
337
+ }
338
+ };
315
339
 
316
340
  const utf8ByteLength = (value: string): number => {
317
341
  let total = 0;
@@ -322,21 +346,24 @@ const utf8ByteLength = (value: string): number => {
322
346
  return total;
323
347
  };
324
348
 
325
- const encodedJsonByteLength = (value: unknown): number | undefined => {
326
- try {
327
- const encoded = JSON.stringify(value);
328
- return encoded === undefined ? undefined : utf8ByteLength(encoded);
329
- } catch {
330
- return undefined;
331
- }
332
- };
349
+ type HostDispatchFailure =
350
+ | { readonly _tag: "host-call-limit" }
351
+ | { readonly _tag: "host-call-result-limit"; readonly observed: number }
352
+ | { readonly _tag: "host-call-protocol" }
353
+ | { readonly _tag: "wall-clock-timeout" }
354
+ | { readonly _tag: "host-call-defect"; readonly cause: Cause.Cause<never> };
333
355
 
334
- interface PendingHostCall {
335
- readonly hostCall: unknown;
356
+ interface QueuedHostCall {
357
+ readonly call: CodeHostCall;
336
358
  readonly resolve: (value: unknown) => void;
337
359
  readonly reject: (reason: unknown) => void;
338
360
  }
339
361
 
362
+ interface ActiveHostCall {
363
+ readonly fiber: Fiber.Fiber<Record<string, unknown>, never>;
364
+ readonly settlement: Promise<void>;
365
+ }
366
+
340
367
  /** Reserved global names the harness owns inside the dynamic worker. */
341
368
  const reservedHarnessGlobals = new Set(["console"]);
342
369
 
@@ -392,83 +419,193 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
392
419
  // prefix keeps ids debuggable; the random suffix makes them unforgeable.
393
420
  const passId = `code-mode-pass-${passCounterState.next}-${crypto.randomUUID()}`;
394
421
 
395
- // Promise-side host calls bridge into the Effect world through a pending
396
- // list served by a scoped fiber, exactly like the deterministic
397
- // substitute: interruption reaches in-flight host calls, and pass-fatal
398
- // conditions fail the pass by failing the server.
399
- const pending: Array<PendingHostCall> = [];
400
- let wake: (() => void) | undefined;
422
+ const startedAt = performance.now();
423
+ const passDeadline = startedAt + Duration.toMillis(request.limits.maxWallTime);
424
+ const remainingPassWallTime = (): Duration.Duration =>
425
+ Duration.millis(Math.max(0, passDeadline - performance.now()));
401
426
  let issuedHostCalls = 0;
402
- const dispatch = (hostCall: unknown): Promise<unknown> =>
403
- new Promise((resolve, reject) => {
404
- issuedHostCalls += 1;
405
- if (issuedHostCalls > request.limits.maxHostCalls + 1) {
406
- reject(new Error("host-call limit exceeded"));
407
- return;
408
- }
409
- pending.push({ hostCall, resolve, reject });
410
- wake?.();
411
- });
412
-
413
- yield* Effect.acquireRelease(
414
- Effect.sync(() => {
415
- passRegistry.set(passId, { dispatch });
416
- }),
417
- () =>
418
- Effect.sync(() => {
419
- passRegistry.delete(passId);
420
- }),
421
- );
422
-
423
- const nextPending = Effect.suspend(() => {
424
- const item = pending.shift();
425
- if (item !== undefined) {
426
- return Effect.succeed(item);
427
- }
428
- return Effect.callback<PendingHostCall>((resume) => {
429
- wake = () => {
430
- wake = undefined;
431
- const next = pending.shift();
432
- if (next !== undefined) {
433
- resume(Effect.succeed(next));
434
- }
435
- };
436
- });
427
+ let passOpen = true;
428
+ const queuedHostCalls: Array<QueuedHostCall> = [];
429
+ let activeHostCall: ActiveHostCall | undefined;
430
+ let hostDispatchFailure: HostDispatchFailure | undefined;
431
+ let propagatedHostDispatchFailure: HostDispatchFailure | undefined;
432
+ let signalHostDispatchFailure = (_failure: HostDispatchFailure): void => undefined;
433
+ const hostDispatchFailureSignal = new Promise<HostDispatchFailure>((resolve) => {
434
+ signalHostDispatchFailure = resolve;
437
435
  });
438
-
439
- const serveHostCalls = Effect.gen(function* () {
440
- let served = 0;
441
- while (true) {
442
- const item = yield* nextPending;
443
- served += 1;
444
- if (served > request.limits.maxHostCalls) {
445
- return yield* CodeHostCallLimitError.make({
446
- implementation: dynamicWorkerImplementation,
447
- limit: request.limits.maxHostCalls,
448
- logs: [],
449
- });
450
- }
451
- const decoded = decodeHostCall(item.hostCall);
452
- if (Option.isNone(decoded)) {
453
- item.reject(new TypeError("host calls must match the CodeHostCall schema"));
454
- continue;
455
- }
456
- const outcome = yield* host.call(decoded.value);
457
- if (outcome._tag === "CodeHostCallSuccess") {
458
- const bytes = encodedJsonByteLength(outcome.value);
459
- if (bytes === undefined || bytes > request.limits.maxHostCallResultBytes) {
460
- return yield* CodeOutputLimitError.make({
436
+ const rejectQueuedHostCalls = (reason: Error): void => {
437
+ for (const queued of queuedHostCalls.splice(0)) {
438
+ queued.reject(reason);
439
+ }
440
+ };
441
+ const recordHostDispatchFailure = (failure: HostDispatchFailure): void => {
442
+ if (hostDispatchFailure !== undefined) return;
443
+ hostDispatchFailure = failure;
444
+ signalHostDispatchFailure(failure);
445
+ rejectQueuedHostCalls(new Error("Code Mode pass failed"));
446
+ };
447
+ const failHostDispatch = (failure: HostDispatchFailure) => {
448
+ switch (failure._tag) {
449
+ case "host-call-limit":
450
+ return Effect.fail(
451
+ CodeHostCallLimitError.make({
452
+ implementation: dynamicWorkerImplementation,
453
+ limit: request.limits.maxHostCalls,
454
+ logs: [],
455
+ }),
456
+ );
457
+ case "host-call-result-limit":
458
+ return Effect.fail(
459
+ CodeOutputLimitError.make({
461
460
  implementation: dynamicWorkerImplementation,
462
461
  surface: "host-call-result",
463
462
  limit: request.limits.maxHostCallResultBytes,
464
- observed: bytes ?? 0,
463
+ observed: failure.observed,
465
464
  logs: [],
466
- });
465
+ }),
466
+ );
467
+ case "host-call-protocol":
468
+ return Effect.fail(
469
+ CodeExecutionProtocolError.make({
470
+ implementation: dynamicWorkerImplementation,
471
+ message: "The execution host returned a value outside the CodeHostCallResult schema",
472
+ }),
473
+ );
474
+ case "wall-clock-timeout":
475
+ return Effect.fail(
476
+ CodeExecutionTimeoutError.make({
477
+ implementation: dynamicWorkerImplementation,
478
+ kind: "wall-clock",
479
+ maxWallTime: request.limits.maxWallTime,
480
+ logs: [],
481
+ }),
482
+ );
483
+ case "host-call-defect":
484
+ return Effect.failCause(failure.cause);
485
+ }
486
+ };
487
+ const propagateHostDispatchFailure = (failure: HostDispatchFailure) =>
488
+ Effect.gen(function* () {
489
+ propagatedHostDispatchFailure = failure;
490
+ return yield* failHostDispatch(failure);
491
+ });
492
+
493
+ const startNextHostCall = (): void => {
494
+ if (!passOpen || hostDispatchFailure !== undefined || activeHostCall !== undefined) return;
495
+ const queued = queuedHostCalls.shift();
496
+ if (queued === undefined) return;
497
+
498
+ // A Dynamic Worker callback is a new Workers RPC into the loader isolate. Running the
499
+ // complete host call on an independent root fiber breaks its dependency on the still-open
500
+ // guest RPC. Retaining the handle and starting one call at a time preserves bounded,
501
+ // serialized execution and lets pass teardown interrupt and await the active call.
502
+ const fiber = Effect.runFork(
503
+ Effect.yieldNow.pipe(
504
+ Effect.andThen(host.call(queued.call)),
505
+ Effect.timeoutOrElse({
506
+ duration: remainingPassWallTime(),
507
+ orElse: () =>
508
+ Effect.sync(() => {
509
+ recordHostDispatchFailure({ _tag: "wall-clock-timeout" });
510
+ throw new Error("code-mode host call exceeded the pass wall-clock limit");
511
+ }),
512
+ }),
513
+ Effect.map((outcome) => {
514
+ const decoded = decodeHostCallResult(outcome);
515
+ if (Option.isNone(decoded)) {
516
+ recordHostDispatchFailure({ _tag: "host-call-protocol" });
517
+ throw new Error("host-call protocol violation");
518
+ }
519
+ const encoded = encodeHostResultPayload(decoded.value);
520
+ if (
521
+ encoded === undefined ||
522
+ encoded.resultBytes > request.limits.maxHostCallResultBytes
523
+ ) {
524
+ recordHostDispatchFailure({
525
+ _tag: "host-call-result-limit",
526
+ observed: encoded?.resultBytes ?? 0,
527
+ });
528
+ throw new Error("host-call result limit exceeded");
529
+ }
530
+ const normalizedPayload: unknown = JSON.parse(encoded.encodedPayload);
531
+ return decoded.value._tag === "CodeHostCallSuccess"
532
+ ? { _tag: "CodeHostCallSuccess", value: normalizedPayload }
533
+ : { _tag: "CodeHostCallFailure", error: normalizedPayload };
534
+ }),
535
+ ),
536
+ );
537
+ const settlement = Effect.runPromise(Fiber.await(fiber))
538
+ .then((exit) => {
539
+ if (Exit.isSuccess(exit)) {
540
+ queued.resolve(exit.value);
541
+ return;
467
542
  }
468
- }
469
- item.resolve(hostResultEnvelope(outcome));
543
+ if (!Cause.hasInterruptsOnly(exit.cause)) {
544
+ recordHostDispatchFailure({ _tag: "host-call-defect", cause: exit.cause });
545
+ }
546
+ queued.reject(new Error("Code Mode host call failed"));
547
+ })
548
+ .finally(() => {
549
+ if (activeHostCall?.fiber === fiber) activeHostCall = undefined;
550
+ startNextHostCall();
551
+ });
552
+ activeHostCall = { fiber, settlement };
553
+ };
554
+
555
+ const dispatch = (hostCall: unknown): Promise<unknown> => {
556
+ if (!passOpen) {
557
+ return Promise.reject(new Error("Code Mode pass is closing"));
558
+ }
559
+ issuedHostCalls += 1;
560
+ if (issuedHostCalls > request.limits.maxHostCalls) {
561
+ recordHostDispatchFailure({ _tag: "host-call-limit" });
562
+ return Promise.reject(new Error("host-call limit exceeded"));
563
+ }
564
+ const decoded = decodeHostCall(hostCall);
565
+ if (Option.isNone(decoded)) {
566
+ return Promise.reject(new TypeError("host calls must match the CodeHostCall schema"));
470
567
  }
568
+ return new Promise((resolve, reject) => {
569
+ queuedHostCalls.push({ call: decoded.value, resolve, reject });
570
+ startNextHostCall();
571
+ });
572
+ };
573
+
574
+ const closeHostDispatch = Effect.gen(function* () {
575
+ passOpen = false;
576
+ passRegistry.delete(passId);
577
+ rejectQueuedHostCalls(new Error("Code Mode pass is closing"));
578
+ const active = activeHostCall;
579
+ if (active !== undefined) {
580
+ yield* Fiber.interrupt(active.fiber);
581
+ yield* Effect.promise(() => active.settlement);
582
+ if (activeHostCall === active) activeHostCall = undefined;
583
+ }
584
+ return hostDispatchFailure;
471
585
  });
586
+ const stopHostDispatch = closeHostDispatch.pipe(
587
+ Effect.flatMap((failure) =>
588
+ failure !== undefined && propagatedHostDispatchFailure !== failure
589
+ ? propagateHostDispatchFailure(failure)
590
+ : Effect.void,
591
+ ),
592
+ );
593
+ const releaseHostDispatch = closeHostDispatch.pipe(
594
+ Effect.flatMap((failure) => {
595
+ if (failure?._tag !== "host-call-defect" || propagatedHostDispatchFailure === failure) {
596
+ return Effect.void;
597
+ }
598
+ propagatedHostDispatchFailure = failure;
599
+ return Effect.failCause(failure.cause);
600
+ }),
601
+ );
602
+
603
+ yield* Effect.acquireRelease(
604
+ Effect.sync(() => {
605
+ passRegistry.set(passId, { dispatch });
606
+ }),
607
+ () => releaseHostDispatch,
608
+ );
472
609
 
473
610
  const workerCode = {
474
611
  compatibilityDate: options.compatibilityDate ?? "2025-05-01",
@@ -505,7 +642,6 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
505
642
  }),
506
643
  };
507
644
 
508
- const startedAt = yield* Clock.currentTimeMillis;
509
645
  const worker = yield* Effect.acquireRelease(
510
646
  Effect.try({
511
647
  try: () => options.loader.load(workerCode as never),
@@ -534,8 +670,6 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
534
670
  }),
535
671
  );
536
672
 
537
- const server = yield* serveHostCalls.pipe(Effect.forkScoped);
538
-
539
673
  const rpc = Effect.tryPromise({
540
674
  try: async () => {
541
675
  const entrypoint = worker.getEntrypoint() as unknown as {
@@ -546,20 +680,29 @@ const makeExecute = (options: DynamicWorkerCodeExecutorOptions): CodeExecutorExe
546
680
  catch: (cause) => classifyWorkerFailure(cause, request.limits.maxWallTime),
547
681
  });
548
682
 
549
- const raw = yield* Effect.raceFirst(rpc, Fiber.join(server)).pipe(
550
- Effect.timeoutOrElse({
551
- duration: request.limits.maxWallTime,
552
- orElse: () =>
553
- CodeExecutionTimeoutError.make({
554
- implementation: dynamicWorkerImplementation,
555
- kind: "wall-clock",
556
- maxWallTime: request.limits.maxWallTime,
557
- logs: [],
558
- }),
559
- }),
560
- Effect.ensuring(Fiber.interrupt(server)),
683
+ const raw = yield* Effect.raceFirst(
684
+ rpc.pipe(
685
+ Effect.timeoutOrElse({
686
+ duration: remainingPassWallTime(),
687
+ orElse: () =>
688
+ CodeExecutionTimeoutError.make({
689
+ implementation: dynamicWorkerImplementation,
690
+ kind: "wall-clock",
691
+ maxWallTime: request.limits.maxWallTime,
692
+ logs: [],
693
+ }),
694
+ }),
695
+ ),
696
+ Effect.promise(() => hostDispatchFailureSignal).pipe(
697
+ Effect.flatMap(propagateHostDispatchFailure),
698
+ ),
561
699
  );
562
- const finishedAt = yield* Clock.currentTimeMillis;
700
+ yield* stopHostDispatch;
701
+ const finishedAt = performance.now();
702
+
703
+ if (hostDispatchFailure !== undefined) {
704
+ return yield* propagateHostDispatchFailure(hostDispatchFailure);
705
+ }
563
706
 
564
707
  const outcome = decodeHarnessOutcome(raw);
565
708
  if (Option.isNone(outcome)) {