@effect-agent/platform-cloudflare 0.1.0-beta.79 → 0.1.0-beta.80

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.
Files changed (33) hide show
  1. package/dist/Alarm.d.mts +17 -5
  2. package/dist/Alarm.mjs +48 -12
  3. package/dist/Alarm.mjs.map +1 -1
  4. package/dist/CloudflareBindings.d.mts +14 -7
  5. package/dist/CloudflareBindings.mjs +10 -7
  6. package/dist/CloudflareBindings.mjs.map +1 -1
  7. package/dist/CloudflareScheduling.mjs +2 -2
  8. package/dist/CloudflareSubscriptions.mjs +1 -1
  9. package/dist/CloudflareThreadClient.d.mts +41 -41
  10. package/dist/CloudflareThreadClient.mjs +3 -3
  11. package/dist/CloudflareThreadClient.mjs.map +1 -1
  12. package/dist/{ThreadObject-MuU9xAyn.d.mts → ThreadObject-3uDpKfB3.d.mts} +82 -44
  13. package/dist/{ThreadObject-KJf_5Isr.mjs → ThreadObject-DGkXv41C.mjs} +213 -68
  14. package/dist/ThreadObject-DGkXv41C.mjs.map +1 -0
  15. package/dist/ThreadObject.d.mts +2 -2
  16. package/dist/ThreadObject.mjs +2 -2
  17. package/dist/WakeScheduler.d.mts +2 -2
  18. package/dist/WakeScheduler.mjs +6 -6
  19. package/dist/WakeScheduler.mjs.map +1 -1
  20. package/dist/index.d.mts +1 -1
  21. package/dist/index.mjs +1 -1
  22. package/dist/{prepared-admission-G7N4DDqS.mjs → prepared-admission-DSi55W-7.mjs} +2 -2
  23. package/dist/{prepared-admission-G7N4DDqS.mjs.map → prepared-admission-DSi55W-7.mjs.map} +1 -1
  24. package/package.json +1 -1
  25. package/src/Alarm.ts +91 -14
  26. package/src/CloudflareBindings.ts +15 -6
  27. package/src/CloudflareThreadClient.ts +3 -3
  28. package/src/ThreadObject.ts +284 -59
  29. package/src/WakeScheduler.ts +6 -6
  30. package/src/internal/layers.ts +150 -22
  31. package/src/internal/message-delivery.ts +37 -21
  32. package/src/internal/transport.ts +2 -2
  33. package/dist/ThreadObject-KJf_5Isr.mjs.map +0 -1
@@ -1,4 +1,4 @@
1
- import { ThreadId } from "@effect-agent/core/Identifiers";
1
+ import { ThreadId, type SubmissionId } from "@effect-agent/core/Identifiers";
2
2
  import {
3
3
  type RunContextPreparation,
4
4
  type RunCostEstimator,
@@ -52,16 +52,31 @@ import {
52
52
  operationAuthorizerLayer,
53
53
  type OperationAuthorizerService,
54
54
  } from "@effect-agent/thread/OperationAuthorizer";
55
+ import { type PreparedInputAdmission } from "@effect-agent/thread/PreparedInputAdmission";
55
56
  import { ProducerId } from "@effect-agent/thread/Records";
56
- import { LedgerError, SubmissionLedger } from "@effect-agent/thread/SubmissionLedger";
57
+ import {
58
+ LedgerError,
59
+ SubmissionLedger,
60
+ SubmissionLookupById,
61
+ type SubmissionSnapshot,
62
+ } from "@effect-agent/thread/SubmissionLedger";
57
63
  import { ThreadProjectionMaintenance } from "@effect-agent/thread/ThreadProjectionMaintenance";
58
64
  import { ThreadStoreError, ThreadStore } from "@effect-agent/thread/ThreadStore";
59
65
  import { ToolReconciler } from "@effect-agent/thread/ToolReconciler";
60
66
  import { type WakeScheduler } from "@effect-agent/thread/WakeScheduler";
61
67
  import { BrowserCrypto } from "@effect/platform-browser";
62
68
  import { SqliteClient } from "@effect/sql-sqlite-do";
63
- import type { Crypto } from "effect";
64
- import { Cause, Context, Duration, Effect, Layer, Schema, Semaphore } from "effect";
69
+ import {
70
+ type Crypto,
71
+ Cause,
72
+ Context,
73
+ Duration,
74
+ Effect,
75
+ Layer,
76
+ Schema,
77
+ Semaphore,
78
+ type Option,
79
+ } from "effect";
65
80
  import { SqlClient } from "effect/unstable/sql/SqlClient";
66
81
 
67
82
  import {
@@ -75,6 +90,7 @@ import {
75
90
  } from "../Alarm.ts";
76
91
  import {
77
92
  ThreadObjectIdentity,
93
+ ThreadObjectPlacement,
78
94
  DurableObjectContext,
79
95
  type ThreadObjectNamespace,
80
96
  } from "../CloudflareBindings.ts";
@@ -162,6 +178,7 @@ export interface CloudflareDurableRuntimeOptions {
162
178
  export type CloudflareBootstrapServices =
163
179
  | CloudflareDurableRuntimeConfig
164
180
  | ThreadObjectIdentity
181
+ | ThreadObjectPlacement
165
182
  | DurableRuntimeConfig
166
183
  | Crypto.Crypto
167
184
  | DoStorageFailpoint
@@ -208,6 +225,10 @@ export class ThreadObjectPorts extends Context.Service<
208
225
  ThreadObjectPorts,
209
226
  {
210
227
  readonly handle: (request: PortRequest) => Effect.Effect<PortResponse>;
228
+ /** Local-only identity check before a bound RPC can read or mutate a Submission. */
229
+ readonly lookupSubmission: (
230
+ submissionId: SubmissionId,
231
+ ) => Effect.Effect<Option.Option<SubmissionSnapshot>, LedgerError>;
211
232
  }
212
233
  >()("@effect-agent/platform-cloudflare/ThreadObjectPorts") {}
213
234
 
@@ -284,27 +305,21 @@ const threadIdFromState = (
284
305
  * The native class factory builds this Layer inside its constructor gate. Custom Effect hosts
285
306
  * can provide it around the complete application Layer with the native context already supplied.
286
307
  */
287
- export const layerConfig = (
308
+ const runtimeConfigLayer = (
288
309
  options: CloudflareDurableRuntimeOptions,
289
- ): Layer.Layer<CloudflareBootstrapServices, CloudflarePlatformConfigError, DurableObjectContext> =>
310
+ producerId: ProducerId,
311
+ ): Layer.Layer<
312
+ Exclude<CloudflareBootstrapServices, ThreadObjectIdentity | ThreadObjectPlacement>,
313
+ CloudflarePlatformConfigError,
314
+ DurableObjectContext
315
+ > =>
290
316
  Layer.unwrap(
291
317
  Effect.gen(function* () {
292
318
  const { ctx } = yield* DurableObjectContext;
293
319
  const config = yield* configFromOptions(options);
294
- const threadId = yield* threadIdFromState(ctx);
295
-
296
- const producerId = yield* decodeProducerId(`${config.producerPrefix}:${threadId}`).pipe(
297
- Effect.mapError((error) =>
298
- CloudflarePlatformConfigError.make({
299
- message: `The minted producer identity is invalid: ${error.message}`,
300
- cause: error,
301
- }),
302
- ),
303
- );
304
320
 
305
321
  return Layer.mergeAll(
306
322
  Layer.succeed(CloudflareDurableRuntimeConfig, config),
307
- Layer.succeed(ThreadObjectIdentity, { threadId, producerId }),
308
323
  DurableRuntimeConfig.layer({
309
324
  deploymentId: config.deploymentId,
310
325
  producerId,
@@ -338,6 +353,59 @@ export const layerConfig = (
338
353
  }),
339
354
  );
340
355
 
356
+ const producerIdentity = (prefix: string, owner: string) =>
357
+ decodeProducerId(`${prefix}:${owner}`).pipe(
358
+ Effect.mapError((error) =>
359
+ CloudflarePlatformConfigError.make({
360
+ message: `The minted producer identity is invalid: ${error.message}`,
361
+ cause: error,
362
+ }),
363
+ ),
364
+ );
365
+
366
+ /** Native one-Thread configuration; retains its existing producer and logical identities. */
367
+ export const layerConfig = (
368
+ options: CloudflareDurableRuntimeOptions,
369
+ ): Layer.Layer<CloudflareBootstrapServices, CloudflarePlatformConfigError, DurableObjectContext> =>
370
+ Layer.unwrap(
371
+ Effect.gen(function* () {
372
+ const { ctx } = yield* DurableObjectContext;
373
+ const threadId = yield* threadIdFromState(ctx);
374
+ const producerId = yield* producerIdentity(options.producerPrefix, threadId);
375
+
376
+ return Layer.mergeAll(
377
+ runtimeConfigLayer(options, producerId),
378
+ Layer.succeed(ThreadObjectIdentity, { threadId, producerId }),
379
+ Layer.succeed(ThreadObjectPlacement, { ownsThread: (target) => target === threadId }),
380
+ );
381
+ }),
382
+ );
383
+
384
+ /**
385
+ * Configuration for an application Object owning several logical Threads. The producer is
386
+ * the stable physical Object. No logical identity is installed globally: handleRpc binds and
387
+ * validates it per request using the placement guard and this actual runtime producer.
388
+ */
389
+ export const layerHostConfig = (
390
+ options: CloudflareDurableRuntimeOptions,
391
+ ownsThread: (threadId: ThreadId) => boolean,
392
+ ): Layer.Layer<
393
+ Exclude<CloudflareBootstrapServices, ThreadObjectIdentity>,
394
+ CloudflarePlatformConfigError,
395
+ DurableObjectContext
396
+ > =>
397
+ Layer.unwrap(
398
+ Effect.gen(function* () {
399
+ const { ctx } = yield* DurableObjectContext;
400
+ const producerId = yield* producerIdentity(options.producerPrefix, ctx.id.toString());
401
+
402
+ return Layer.merge(
403
+ runtimeConfigLayer(options, producerId),
404
+ Layer.succeed(ThreadObjectPlacement, { ownsThread }),
405
+ );
406
+ }),
407
+ );
408
+
341
409
  export interface ThreadPublicationOptions<E = never, R = never, P = never> {
342
410
  /**
343
411
  * Optional host outbox consumer, built once per incarnation with RAW LOCAL ThreadStore and
@@ -450,12 +518,68 @@ const boundLayer = <E = never, R = never>(
450
518
  | ThreadObjectNamespace
451
519
  | CloudflareBootstrapServices
452
520
  | Exclude<R, ThreadStore | SubmissionLedger | SqlClient>
521
+ > =>
522
+ Layer.unwrap(
523
+ Effect.map(DurableObjectContext, ({ ctx }) =>
524
+ sharedLayer(DurableAgentRuntime.layerWithBindings(bindings), options).pipe(
525
+ Layer.provideMerge(SqliteClient.layer({ storage: ctx.storage })),
526
+ ),
527
+ ),
528
+ );
529
+
530
+ /**
531
+ * Build the application runtime over the existing owner SqlClient and native ports, then
532
+ * assemble its one maintenance coordinator. The application may acquire its Bindings from
533
+ * those ports and expose extra services, including ThreadHostMaintenance. It must not acquire
534
+ * another runtime stack or require ThreadMaintenance while constructing this Layer.
535
+ * Supply layerHostConfig and deterministic placement; dispatch addressed ingress with handleRpc.
536
+ */
537
+ export function layerInHost<A, E, R, P = never, PE = never, PR = never>(
538
+ application: Layer.Layer<DurableAgentRuntime | A, E, R>,
539
+ options: Omit<ThreadPublicationOptions<PE, PR>, "projection"> & {
540
+ readonly projection: Layer.Layer<ThreadProjectionMaintenance | P, PE, PR>;
541
+ },
542
+ ): Layer.Layer<
543
+ CloudflareDurableRuntimeServices | A | P,
544
+ Layer.Error<ReturnType<typeof sharedLayer<A, E, R, PE, PR>>>,
545
+ Layer.Services<ReturnType<typeof sharedLayer<A, E, Exclude<R, P>, PE, PR>>>
546
+ >;
547
+
548
+ export function layerInHost<A, E, R, PE = never, PR = never>(
549
+ application: Layer.Layer<DurableAgentRuntime | A, E, R>,
550
+ options?: ThreadPublicationOptions<PE, PR>,
551
+ ): ReturnType<typeof sharedLayer<A, E, R, PE, PR>>;
552
+
553
+ export function layerInHost<A, E, R, PE = never, PR = never>(
554
+ application: Layer.Layer<DurableAgentRuntime | A, E, R>,
555
+ options: ThreadPublicationOptions<PE, PR> = {},
556
+ ) {
557
+ return sharedLayer(application, options);
558
+ }
559
+
560
+ type HostRuntimeServices = Exclude<
561
+ CloudflareDurableRuntimeServices,
562
+ DurableAgentRuntime | ThreadMaintenance
563
+ >;
564
+
565
+ const sharedLayer = <A, E, R, PE = never, PR = never>(
566
+ application: Layer.Layer<DurableAgentRuntime | A, E, R>,
567
+ options: ThreadPublicationOptions<PE, PR> = {},
568
+ ): Layer.Layer<
569
+ CloudflareDurableRuntimeServices | A,
570
+ DoStorageInitializationError | MessageDeliveryError | E | PE,
571
+ | DurableObjectContext
572
+ | ThreadObjectNamespace
573
+ | Exclude<CloudflareBootstrapServices, ThreadObjectIdentity>
574
+ | SqlClient
575
+ | Exclude<R, HostRuntimeServices | PreparedInputAdmission>
576
+ | Exclude<PR, ThreadStore | SubmissionLedger | SqlClient>
453
577
  > =>
454
578
  Layer.unwrap(
455
579
  Effect.gen(function* () {
456
580
  const { ctx } = yield* DurableObjectContext;
457
581
  const config = yield* CloudflareDurableRuntimeConfig;
458
- const { threadId } = yield* ThreadObjectIdentity;
582
+ const { ownsThread } = yield* ThreadObjectPlacement;
459
583
 
460
584
  const storageOptions: DoStorageOptions = {
461
585
  storage: ctx.storage,
@@ -467,7 +591,7 @@ const boundLayer = <E = never, R = never>(
467
591
 
468
592
  const infrastructure = Layer.mergeAll(
469
593
  storageConfigLayer(storageOptions),
470
- SqliteClient.layer({ storage: ctx.storage }),
594
+ Layer.effect(SqlClient)(SqlClient),
471
595
  );
472
596
 
473
597
  // The same local ports serve routed decorators and owner-side RPC execution.
@@ -585,19 +709,22 @@ const boundLayer = <E = never, R = never>(
585
709
  const portsEndpointLayer = Layer.effect(ThreadObjectPorts)(
586
710
  Effect.gen(function* () {
587
711
  const local = yield* Effect.context<SubmissionLedger | ThreadStore>();
712
+ const ledger = Context.get(local, SubmissionLedger);
588
713
 
589
714
  return ThreadObjectPorts.of({
590
715
  handle: (request) => executePortRequest(request).pipe(Effect.provide(local)),
716
+ lookupSubmission: (submissionId) =>
717
+ ledger.lookup(SubmissionLookupById.make({ submissionId })),
591
718
  });
592
719
  }),
593
720
  ).pipe(Layer.provide(localPorts));
594
721
 
595
722
  const routedPorts = Layer.mergeAll(
596
- routedSubmissionLedgerLayer({ localThreadId: threadId }),
597
- routedThreadStoreLayer({ localThreadId: threadId }),
723
+ routedSubmissionLedgerLayer({ ownsThread }),
724
+ routedThreadStoreLayer({ ownsThread }),
598
725
  ).pipe(Layer.provide(localPorts), Layer.provide(threadPortTransportLayer));
599
726
 
600
- const runtimeStack = DurableAgentRuntime.layerWithBindings(bindings).pipe(
727
+ const runtimeStack = application.pipe(
601
728
  Layer.provide(
602
729
  cloudflarePreparedInputAdmissionLayer.pipe(Layer.provide(CloudflareThreadClient.layer)),
603
730
  ),
@@ -605,6 +732,7 @@ const boundLayer = <E = never, R = never>(
605
732
  Layer.provideMerge(routedPorts),
606
733
  Layer.provideMerge(wakes),
607
734
  Layer.provideMerge(base),
735
+ Layer.provideMerge(portsEndpointLayer),
608
736
  );
609
737
 
610
738
  return Layer.mergeAll(
@@ -1,13 +1,14 @@
1
+ import { type ThreadId } from "@effect-agent/core/Identifiers";
1
2
  import {
2
3
  MessageDeliveryDriver,
3
4
  MessageDeliveryError,
4
5
  MessageDeliveryStore,
5
6
  } from "@effect-agent/thread/MessageDelivery";
6
7
  import { WakeScheduler } from "@effect-agent/thread/WakeScheduler";
7
- import { Clock, Context, Deferred, Effect, Layer, Option, Ref, Semaphore } from "effect";
8
+ import { Clock, Context, Deferred, Effect, Layer, Option, Ref, Semaphore, Stream } from "effect";
8
9
 
9
10
  import { DurableAlarmError, ThreadMessageDelivery, ThreadMutationGate } from "../Alarm.ts";
10
- import { ThreadObjectIdentity } from "../CloudflareBindings.ts";
11
+ import { ThreadObjectPlacement } from "../CloudflareBindings.ts";
11
12
  import { CloudflareDurableRuntimeConfig } from "../CloudflareConfig.ts";
12
13
 
13
14
  /** Every externally requested write prearms the owning Object's maintenance generation. */
@@ -17,14 +18,17 @@ export const guardedMessageDeliveryStoreLayer = Layer.effect(
17
18
  const store = yield* MessageDeliveryStore;
18
19
  const mutations = yield* ThreadMutationGate;
19
20
  const wakes = yield* WakeScheduler;
20
- const { threadId } = yield* ThreadObjectIdentity;
21
+ const { ownsThread } = yield* ThreadObjectPlacement;
21
22
  // Reconstructed as unknown on every incarnation. The gate prevents a racing read from
22
23
  // caching an empty deadline across a write; SQL remains the recovery authority.
23
24
  const deadline = yield* Ref.make<number | null | undefined>(undefined);
24
25
  const cacheGate = yield* Semaphore.make(1);
25
26
 
26
- const local = <A, E>(owner: string, body: Effect.Effect<A, E>) =>
27
- owner === threadId
27
+ const local = <A, E>(
28
+ owner: ThreadId | undefined,
29
+ body: Effect.Effect<A, E>,
30
+ ): Effect.Effect<A, E | MessageDeliveryError> =>
31
+ owner === undefined || ownsThread(owner)
28
32
  ? body
29
33
  : Effect.fail(
30
34
  MessageDeliveryError.make({ reason: "validation", operation: "message owner" }),
@@ -44,7 +48,7 @@ export const guardedMessageDeliveryStoreLayer = Layer.effect(
44
48
  const cached = yield* Ref.get(deadline);
45
49
 
46
50
  if (cached !== undefined) return cached;
47
- const current = yield* store.nextDeadline(threadId);
51
+ const current = yield* store.nextDeadline();
48
52
 
49
53
  yield* Ref.set(deadline, current);
50
54
 
@@ -56,14 +60,27 @@ export const guardedMessageDeliveryStoreLayer = Layer.effect(
56
60
  insert: (record) =>
57
61
  local(
58
62
  record.key.ownerThreadId,
59
- mutate(store.insert(record)).pipe(Effect.tap(() => wakes.notify(threadId))),
63
+ mutate(store.insert(record)).pipe(
64
+ Effect.tap(() => wakes.notify(record.key.ownerThreadId)),
65
+ ),
60
66
  ),
61
67
  get: (key) => local(key.ownerThreadId, store.get(key)),
62
68
  list: (request) => local(request.ownerThreadId, store.list(request)),
63
69
  change: (key, change) => local(key.ownerThreadId, mutate(store.change(key, change))),
64
- due: (nowMillis, limit, owner = threadId) =>
65
- local(owner, store.due(nowMillis, limit, threadId)),
66
- nextDeadline: (owner = threadId) => local(owner, nextDeadline),
70
+ // Only the trusted physical-owner pump omits an owner. All returned keys still
71
+ // pass placement validation before the driver may dispatch any of the wave.
72
+ due: (nowMillis, limit, owner) =>
73
+ local(owner, store.due(nowMillis, limit, owner)).pipe(
74
+ Effect.flatMap((keys) =>
75
+ keys.every((key) => ownsThread(key.ownerThreadId))
76
+ ? Effect.succeed(keys)
77
+ : Effect.fail(
78
+ MessageDeliveryError.make({ reason: "validation", operation: "message owner" }),
79
+ ),
80
+ ),
81
+ ),
82
+ nextDeadline: (owner) =>
83
+ local(owner, owner === undefined ? nextDeadline : store.nextDeadline(owner)),
67
84
  });
68
85
  }),
69
86
  );
@@ -75,7 +92,6 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
75
92
  const store = yield* MessageDeliveryStore;
76
93
  const wakes = yield* WakeScheduler;
77
94
  const config = yield* CloudflareDurableRuntimeConfig;
78
- const { threadId } = yield* ThreadObjectIdentity;
79
95
 
80
96
  const failure = (operation: string) => () =>
81
97
  DurableAlarmError.make({
@@ -84,10 +100,10 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
84
100
  });
85
101
 
86
102
  const drain = Effect.gen(function* () {
87
- const deadline = yield* store.nextDeadline(threadId);
103
+ const deadline = yield* store.nextDeadline();
88
104
 
89
105
  if (deadline !== null && deadline <= (yield* Clock.currentTimeMillis)) {
90
- yield* driver.runDue(threadId);
106
+ yield* driver.runDue();
91
107
  }
92
108
  }).pipe(Effect.mapError(failure("drain message delivery")));
93
109
 
@@ -96,16 +112,16 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
96
112
  drainUntil: Effect.fn("ThreadMessageDelivery.drainUntil")(function* (
97
113
  finished: Deferred.Deferred<void>,
98
114
  ) {
115
+ // One scoped subscription covers every logical lane in this physical owner.
116
+ // Acquire it before reading durable deadlines; hints remain droppable.
117
+ const notified = (yield* Stream.toPull(wakes.wakes)).pipe(Effect.catch(() => Effect.never));
118
+
99
119
  // Always finish one wave; source completion prevents starting subsequent waves.
100
120
  yield* Effect.gen(function* () {
101
- // Subscribe before the durable read so an insertion during a wave is retained
102
- // as a hint for the next one. The scan interval covers dropped notifications.
103
- const notified = yield* wakes.subscribe(threadId);
104
-
105
121
  yield* drain;
106
122
 
107
123
  const deadline = yield* store
108
- .nextDeadline(threadId)
124
+ .nextDeadline()
109
125
  .pipe(Effect.mapError(failure("read message deadline")));
110
126
 
111
127
  // The index includes unfinished waves, lease expiry, retry and settlement polls.
@@ -122,10 +138,10 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
122
138
  Deferred.await(finished),
123
139
  Effect.raceFirst(notified, Effect.sleep(delay)),
124
140
  );
125
- }).pipe(Effect.scoped, Effect.repeat({ until: () => Deferred.isDone(finished) }));
126
- }),
141
+ }).pipe(Effect.repeat({ until: () => Deferred.isDone(finished) }));
142
+ }, Effect.scoped),
127
143
  pendingDeadline: store
128
- .nextDeadline(threadId)
144
+ .nextDeadline()
129
145
  .pipe(Effect.map(Option.fromNullishOr), Effect.mapError(failure("read message deadline"))),
130
146
  });
131
147
  }),
@@ -25,12 +25,12 @@ export const threadPortTransportLayer: Layer.Layer<
25
25
  ThreadObjectNamespace
26
26
  > = Layer.effect(ThreadPortTransport)(
27
27
  Effect.gen(function* () {
28
- const { namespace } = yield* ThreadObjectNamespace;
28
+ const { get } = yield* ThreadObjectNamespace;
29
29
 
30
30
  return ThreadPortTransport.of({
31
31
  call: (threadId, request) =>
32
32
  Effect.tryPromise({
33
- try: () => namespace.get(namespace.idFromName(threadId)).portCall(request),
33
+ try: () => get(threadId).portCall(request),
34
34
  catch: (cause) => portTransportFailure(threadId, cause),
35
35
  }).pipe(
36
36
  Effect.withSpan("CloudflarePortTransport.call", {