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

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/src/layers.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  import { ConversationId } from "@effect-agent/core";
2
2
  import {
3
3
  AgentBindingResolver,
4
- ConversationStore,
5
4
  DurableAgentRuntime,
6
5
  DurableRuntimeConfig,
7
6
  DurableRuntimeFailpoint,
8
7
  ProducerId,
9
- SubmissionLedger,
8
+ operationAuthorizerLayer,
10
9
  ToolReconciler,
11
- WakeScheduler,
10
+ type ConversationStore,
12
11
  type DurableRuntimeFailpointHandler,
12
+ type OperationAuthorizerService,
13
13
  type ResolvedBinding,
14
+ type SubmissionLedger,
15
+ type WakeScheduler,
14
16
  } from "@effect-agent/session";
15
17
  import {
16
18
  conversationStoreLayer,
@@ -28,11 +30,16 @@ import { BrowserCrypto } from "@effect/platform-browser";
28
30
  import { SqliteClient } from "@effect/sql-sqlite-do";
29
31
  import { Context, Duration, Effect, Layer, Schema } from "effect";
30
32
 
31
- import { ConversationMaintenance, DurableAlarmService } from "./alarm.ts";
33
+ import {
34
+ ConversationMaintenance,
35
+ ConversationMaintenanceFailpoint,
36
+ DurableAlarmService,
37
+ type ConversationMaintenanceFailpointHandler,
38
+ } from "./alarm.ts";
32
39
  import {
33
40
  ConversationObjectIdentity,
34
- ConversationObjectNamespace,
35
41
  DurableObjectContext,
42
+ type ConversationObjectNamespace,
36
43
  } from "./bindings.ts";
37
44
  import {
38
45
  CLOUDFLARE_RUNTIME_DEFAULTS,
@@ -40,6 +47,7 @@ import {
40
47
  CloudflareDurableRuntimeConfigValue,
41
48
  CloudflarePlatformConfigError,
42
49
  } from "./config.ts";
50
+ import { ProgressWaitRegistry } from "./progress-wait.ts";
43
51
  import { conversationPortTransportLayer } from "./transport.ts";
44
52
  import { cloudflareWakeSchedulerLayer } from "./wake-scheduler.ts";
45
53
 
@@ -89,6 +97,12 @@ export interface CloudflareDurableRuntimeOptions {
89
97
  readonly runtimeFailpoint?:
90
98
  | ((ctx: DurableObjectState) => DurableRuntimeFailpointHandler)
91
99
  | undefined;
100
+ /** Conversation-maintenance generation/alarm fault injection; default none. */
101
+ readonly maintenanceFailpoint?:
102
+ | ((ctx: DurableObjectState) => ConversationMaintenanceFailpointHandler)
103
+ | undefined;
104
+ /** Host-supplied fail-closed authorization policy; defaults to service possession. */
105
+ readonly operationAuthorizer?: OperationAuthorizerService | undefined;
92
106
  /**
93
107
  * Reconciliation policy consulted for open ordinary Tool Calls before an Unknown Outcome
94
108
  * is recorded (durability §10, DUR-009). Defaults to the fail-closed
@@ -143,7 +157,8 @@ export type CloudflareDurableRuntimeServices =
143
157
  | ConversationObjectIdentity
144
158
  | DurableAlarmService
145
159
  | ConversationMaintenance
146
- | ConversationObjectPorts;
160
+ | ConversationObjectPorts
161
+ | ProgressWaitRegistry;
147
162
 
148
163
  /**
149
164
  * Owner-side endpoint body for the Conversation Object's `portCall` (plan §1.3): decode,
@@ -340,7 +355,17 @@ export class CloudflareDurableRuntime {
340
355
  options.runtimeFailpoint === undefined
341
356
  ? DurableRuntimeFailpoint.layer
342
357
  : Layer.succeed(DurableRuntimeFailpoint)({ hit: options.runtimeFailpoint(ctx) });
358
+ const maintenanceFailpointLayer =
359
+ options.maintenanceFailpoint === undefined
360
+ ? ConversationMaintenanceFailpoint.layer
361
+ : Layer.succeed(ConversationMaintenanceFailpoint)({
362
+ hit: options.maintenanceFailpoint(ctx),
363
+ });
343
364
  const reconcilerLayer = options.toolReconciler ?? ToolReconciler.uncertain;
365
+ const authorizerLayer =
366
+ options.operationAuthorizer === undefined
367
+ ? Layer.empty
368
+ : operationAuthorizerLayer(options.operationAuthorizer);
344
369
  const bindingResolverLayer = Layer.effect(AgentBindingResolver)(
345
370
  Effect.map(
346
371
  resolveBindings(options.bindings, { ctx, env, conversationId, producerId }),
@@ -352,6 +377,8 @@ export class CloudflareDurableRuntime {
352
377
  identityLayer,
353
378
  cloudflareConfigLayer,
354
379
  DurableAlarmService.layer,
380
+ maintenanceFailpointLayer,
381
+ ProgressWaitRegistry.layer,
355
382
  );
356
383
 
357
384
  const runtimeStack = DurableAgentRuntime.layer.pipe(
@@ -360,7 +387,12 @@ export class CloudflareDurableRuntime {
360
387
  Layer.provideMerge(runtimeConfigLayer),
361
388
  Layer.provideMerge(bindingResolverLayer),
362
389
  Layer.provide(
363
- Layer.mergeAll(runtimeFailpointLayer, reconcilerLayer, BrowserCrypto.layer),
390
+ Layer.mergeAll(
391
+ runtimeFailpointLayer,
392
+ reconcilerLayer,
393
+ authorizerLayer,
394
+ BrowserCrypto.layer,
395
+ ),
364
396
  ),
365
397
  Layer.provideMerge(base),
366
398
  );
@@ -0,0 +1,103 @@
1
+ import { Context, Deferred, Effect, Layer, Ref, type Scope } from "effect";
2
+
3
+ /** Cancellation tombstones are bounded hints, never durable authority. */
4
+ const MAX_CANCELLATION_TOMBSTONES = 1_024;
5
+
6
+ type ActiveRegistration = ReadonlySet<Deferred.Deferred<void>>;
7
+ type Registration = ActiveRegistration | "cancelled";
8
+ type Registrations = ReadonlyMap<string, Registration>;
9
+
10
+ /**
11
+ * Per-incarnation cancellation registry for long-lived progress RPCs. The public runtime owns
12
+ * the actual wake registration; this host-only registry lets an interrupted Worker Effect ask
13
+ * the Object to interrupt its scoped wait before the Worker execution context itself ends.
14
+ */
15
+ export class ProgressWaitRegistry extends Context.Service<
16
+ ProgressWaitRegistry,
17
+ {
18
+ /** Register a Scope-owned cancellation signal, observing any early cancel tombstone. */
19
+ readonly subscribe: (
20
+ waiterId: string,
21
+ ) => Effect.Effect<Effect.Effect<void>, never, Scope.Scope>;
22
+ /** Cancel a registered waiter, or remember a bounded early cancellation. */
23
+ readonly cancel: (waiterId: string) => Effect.Effect<void>;
24
+ }
25
+ >()("@effect-agent/platform-cloudflare/ProgressWaitRegistry") {
26
+ static readonly layer: Layer.Layer<ProgressWaitRegistry> = Layer.effect(
27
+ ProgressWaitRegistry,
28
+ Effect.gen(function* () {
29
+ const registrations = yield* Ref.make<Registrations>(new Map());
30
+
31
+ const remove = (waiterId: string, deferred: Deferred.Deferred<void>) =>
32
+ Ref.update(registrations, (current) => {
33
+ const existing = current.get(waiterId);
34
+ if (existing === undefined || existing === "cancelled" || !existing.has(deferred)) {
35
+ return current;
36
+ }
37
+ const next = new Map(current);
38
+ const active = new Set(existing);
39
+ active.delete(deferred);
40
+ if (active.size === 0) {
41
+ next.delete(waiterId);
42
+ } else {
43
+ next.set(waiterId, active);
44
+ }
45
+ return next;
46
+ });
47
+
48
+ const subscribe = Effect.fn("ProgressWaitRegistry.subscribe")(
49
+ (waiterId: string): Effect.Effect<Effect.Effect<void>, never, Scope.Scope> =>
50
+ Effect.gen(function* () {
51
+ const deferred = yield* Deferred.make<void>();
52
+ yield* Effect.addFinalizer(() => remove(waiterId, deferred));
53
+ const cancelled = yield* Ref.modify(registrations, (current) => {
54
+ const existing = current.get(waiterId);
55
+ const next = new Map(current);
56
+ if (existing === "cancelled") {
57
+ return [true, current] as const;
58
+ }
59
+ const active = new Set(existing ?? []);
60
+ active.add(deferred);
61
+ next.set(waiterId, active);
62
+ return [false, next] as const;
63
+ });
64
+ return { cancelled, deferred };
65
+ }).pipe(
66
+ Effect.map(({ cancelled, deferred }) =>
67
+ cancelled ? Effect.void : Deferred.await(deferred),
68
+ ),
69
+ ),
70
+ );
71
+
72
+ const cancel = Effect.fn("ProgressWaitRegistry.cancel")(function* (waiterId: string) {
73
+ const waiters = yield* Ref.modify(registrations, (current) => {
74
+ const existing = current.get(waiterId);
75
+ const next = new Map(current);
76
+ if (existing === undefined) {
77
+ next.set(waiterId, "cancelled");
78
+ let tombstones = 0;
79
+ for (const registration of next.values()) {
80
+ if (registration === "cancelled") tombstones += 1;
81
+ }
82
+ if (tombstones > MAX_CANCELLATION_TOMBSTONES) {
83
+ for (const [id, registration] of next) {
84
+ if (registration !== "cancelled") continue;
85
+ next.delete(id);
86
+ break;
87
+ }
88
+ }
89
+ return [[], next] as const;
90
+ }
91
+ if (existing === "cancelled") return [[], current] as const;
92
+ next.delete(waiterId);
93
+ return [[...existing], next] as const;
94
+ });
95
+ yield* Effect.forEach(waiters, (waiter) => Deferred.succeed(waiter, undefined), {
96
+ discard: true,
97
+ });
98
+ });
99
+
100
+ return ProgressWaitRegistry.of({ subscribe, cancel });
101
+ }),
102
+ );
103
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ConversationId } from "@effect-agent/core";
2
- import { WakeScheduler } from "@effect-agent/session";
2
+ import { makeWakeSubscriptionHub, WakeScheduler } from "@effect-agent/session";
3
3
  import { Effect, Layer, PubSub, Schema, Stream } from "effect";
4
4
 
5
5
  import { DurableAlarmService } from "./alarm.ts";
@@ -42,10 +42,12 @@ export const cloudflareWakeSchedulerLayer: Layer.Layer<
42
42
  const identity = yield* ConversationObjectIdentity;
43
43
  const { namespace } = yield* ConversationObjectNamespace;
44
44
  const hints = yield* PubSub.sliding<ConversationId>(WAKE_BUFFER_CAPACITY);
45
+ const progress = yield* makeWakeSubscriptionHub;
45
46
  yield* Effect.addFinalizer(() => PubSub.shutdown(hints));
46
47
 
47
48
  const notifyLocal = (conversationId: ConversationId) =>
48
- PubSub.publish(hints, conversationId).pipe(
49
+ progress.notify(conversationId).pipe(
50
+ Effect.andThen(PubSub.publish(hints, conversationId)),
49
51
  Effect.andThen(alarm.scheduleNow),
50
52
  Effect.catch((error) =>
51
53
  // `notify` never fails by contract; a failed alarm write degrades to "hint lost"
@@ -79,6 +81,7 @@ export const cloudflareWakeSchedulerLayer: Layer.Layer<
79
81
  conversationId === identity.conversationId
80
82
  ? notifyLocal(conversationId)
81
83
  : notifyRemote(conversationId),
84
+ subscribe: progress.subscribe,
82
85
  wakes: Stream.fromPubSub(hints),
83
86
  });
84
87
  }),