@effect-agent/platform-cloudflare 0.1.0-beta.15 → 0.1.0-beta.17

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.15",
3
+ "version": "0.1.0-beta.17",
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.15",
12
- "@effect-agent/sandbox": "0.1.0-beta.15",
13
- "@effect-agent/session": "0.1.0-beta.15",
14
- "@effect-agent/storage-cloudflare": "0.1.0-beta.15",
11
+ "@effect-agent/core": "0.1.0-beta.17",
12
+ "@effect-agent/sandbox": "0.1.0-beta.17",
13
+ "@effect-agent/session": "0.1.0-beta.17",
14
+ "@effect-agent/storage-cloudflare": "0.1.0-beta.17",
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"
package/src/bindings.ts CHANGED
@@ -31,6 +31,10 @@ export interface ConversationObjectRpc extends Rpc.DurableObjectBranded {
31
31
  submitEncoded(encoded: unknown): Promise<unknown>;
32
32
  /** Wake-hinted, poll-guaranteed settlement wait; answers an `AwaitSettlementResponse`. */
33
33
  awaitSettlementEncoded(encoded: unknown): Promise<unknown>;
34
+ /** Event-driven durable progress wait; answers a `ProgressObserved` host response. */
35
+ awaitProgressEncoded(encoded: unknown): Promise<unknown>;
36
+ /** Best-effort cancellation for one in-flight progress wait. */
37
+ cancelProgressEncoded(encoded: unknown): Promise<unknown>;
34
38
  /** One bounded page of canonical records; answers an `ObservePageResponse`. */
35
39
  observePage(encoded: unknown): Promise<unknown>;
36
40
  /** Durable abort intent; answers an `AbortResponse`. */
package/src/client.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  IdempotencyKey,
19
19
  JoinedToHost,
20
20
  LedgerError,
21
+ OperationDenied,
21
22
  PersistedJson,
22
23
  Principal,
23
24
  Receipt,
@@ -29,7 +30,7 @@ import {
29
30
  type DurableSubmitAgent,
30
31
  type DurableSubmitOptions,
31
32
  } from "@effect-agent/session";
32
- import { Context, Effect, Layer, Schema } from "effect";
33
+ import { Context, Crypto, Duration, Effect, Layer, Schema } from "effect";
33
34
 
34
35
  import { DurableAlarmError } from "./alarm.ts";
35
36
  import { ConversationObjectNamespace, type ConversationObjectRpc } from "./bindings.ts";
@@ -73,6 +74,10 @@ export class ConversationClientError extends Schema.TaggedError<ConversationClie
73
74
  conversationId: Schema.String,
74
75
  message: Schema.String,
75
76
  cause: Schema.optionalKey(Schema.Defect()),
77
+ /** Cloudflare's own classification for a failure safe to retry with a fresh stub. */
78
+ retryable: Schema.optionalKey(Schema.Boolean),
79
+ /** Cloudflare overloads are surfaced immediately instead of adding retry pressure. */
80
+ overloaded: Schema.optionalKey(Schema.Boolean),
76
81
  },
77
82
  ) {}
78
83
 
@@ -104,6 +109,21 @@ export class ObservePageRequest extends Schema.Class<ObservePageRequest>(
104
109
  limit: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(1_024)),
105
110
  }) {}
106
111
 
112
+ /** One event-driven wait for canonical progress strictly after this sequence. */
113
+ export class AwaitProgressRequest extends Schema.Class<AwaitProgressRequest>(
114
+ "@effect-agent/platform-cloudflare/AwaitProgressRequest",
115
+ )({
116
+ afterSequence: CanonicalSequence,
117
+ waiterId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)),
118
+ }) {}
119
+
120
+ /** Best-effort cancellation of one in-flight progress RPC. */
121
+ export class CancelProgressRequest extends Schema.Class<CancelProgressRequest>(
122
+ "@effect-agent/platform-cloudflare/CancelProgressRequest",
123
+ )({
124
+ waiterId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)),
125
+ }) {}
126
+
107
127
  // ---------------------------------------------------------------------------
108
128
  // Responses
109
129
  // ---------------------------------------------------------------------------
@@ -130,6 +150,7 @@ export const HostFailure = Schema.Union([
130
150
  DurableRuntimeFailpointError,
131
151
  AdmissionLimitExceeded,
132
152
  DurableAlarmError,
153
+ OperationDenied,
133
154
  HostProtocolError,
134
155
  ]);
135
156
  export type HostFailure = typeof HostFailure.Type;
@@ -152,6 +173,15 @@ export class ObservedPage extends Schema.TaggedClass<ObservedPage>(
152
173
  records: Schema.Array(CanonicalRecordEnvelope).check(Schema.isMaxLength(1_024)),
153
174
  }) {}
154
175
 
176
+ /** A record was already committed or an incarnation-local hint says the caller should re-read. */
177
+ export class ProgressObserved extends Schema.TaggedClass<ProgressObserved>(
178
+ "@effect-agent/platform-cloudflare/ProgressObserved",
179
+ )("ProgressObserved", {}) {}
180
+
181
+ export class ProgressCancelled extends Schema.TaggedClass<ProgressCancelled>(
182
+ "@effect-agent/platform-cloudflare/ProgressCancelled",
183
+ )("ProgressCancelled", {}) {}
184
+
155
185
  export class AbortRecorded extends Schema.TaggedClass<AbortRecorded>(
156
186
  "@effect-agent/platform-cloudflare/AbortRecorded",
157
187
  )("AbortRecorded", {
@@ -182,6 +212,8 @@ export const HostResponse = Schema.Union([
182
212
  SubmitSucceeded,
183
213
  SettlementReached,
184
214
  ObservedPage,
215
+ ProgressObserved,
216
+ ProgressCancelled,
185
217
  AbortRecorded,
186
218
  ApprovalRecorded,
187
219
  UnknownResolutionRecorded,
@@ -199,6 +231,10 @@ export const decodeReceipt = Schema.decodeUnknownEffect(Receipt);
199
231
  export const encodeReceipt = Schema.encodeEffect(Receipt);
200
232
  export const decodeObservePageRequest = Schema.decodeUnknownEffect(ObservePageRequest);
201
233
  export const encodeObservePageRequest = Schema.encodeEffect(ObservePageRequest);
234
+ export const decodeAwaitProgressRequest = Schema.decodeUnknownEffect(AwaitProgressRequest);
235
+ export const encodeAwaitProgressRequest = Schema.encodeEffect(AwaitProgressRequest);
236
+ export const decodeCancelProgressRequest = Schema.decodeUnknownEffect(CancelProgressRequest);
237
+ export const encodeCancelProgressRequest = Schema.encodeEffect(CancelProgressRequest);
202
238
  export const decodeAbortCommand = Schema.decodeUnknownEffect(AbortCommand);
203
239
  export const encodeAbortCommand = Schema.encodeEffect(AbortCommand);
204
240
  export const decodeApprovalDecisionCommand = Schema.decodeUnknownEffect(ApprovalDecisionCommand);
@@ -237,6 +273,14 @@ export type ClientAwaitFailure =
237
273
  export type ClientObserveFailure =
238
274
  | ConversationStoreError
239
275
  | ConversationNotMaterialized
276
+ | OperationDenied
277
+ | HostProtocolError
278
+ | ConversationClientError;
279
+
280
+ export type ClientProgressFailure =
281
+ | ConversationStoreError
282
+ | ConversationNotMaterialized
283
+ | OperationDenied
240
284
  | HostProtocolError
241
285
  | ConversationClientError;
242
286
 
@@ -253,6 +297,7 @@ export type ClientApprovalFailure =
253
297
  | LedgerError
254
298
  | SettlementConflict
255
299
  | ApprovalConflict
300
+ | OperationDenied
256
301
  | DurableAlarmError
257
302
  | HostProtocolError
258
303
  | ConversationClientError;
@@ -263,6 +308,7 @@ export type ClientUnknownFailure =
263
308
  | UnknownResolutionConflict
264
309
  | JoinedToHost
265
310
  | DurableRuntimeFailpointError
311
+ | OperationDenied
266
312
  | DurableAlarmError
267
313
  | HostProtocolError
268
314
  | ConversationClientError;
@@ -289,8 +335,10 @@ const AWAIT_FAILURE_TAGS: ReadonlySet<string> = new Set([
289
335
  const OBSERVE_FAILURE_TAGS: ReadonlySet<string> = new Set([
290
336
  "ConversationStoreError",
291
337
  "ConversationNotMaterialized",
338
+ "OperationDenied",
292
339
  "HostProtocolError",
293
340
  ]);
341
+ const PROGRESS_FAILURE_TAGS = OBSERVE_FAILURE_TAGS;
294
342
  const ABORT_FAILURE_TAGS: ReadonlySet<string> = new Set([
295
343
  "LedgerError",
296
344
  "SettlementConflict",
@@ -303,6 +351,7 @@ const APPROVAL_FAILURE_TAGS: ReadonlySet<string> = new Set([
303
351
  "LedgerError",
304
352
  "SettlementConflict",
305
353
  "ApprovalConflict",
354
+ "OperationDenied",
306
355
  "DurableAlarmError",
307
356
  "HostProtocolError",
308
357
  ]);
@@ -312,6 +361,7 @@ const UNKNOWN_FAILURE_TAGS: ReadonlySet<string> = new Set([
312
361
  "UnknownResolutionConflict",
313
362
  "JoinedToHost",
314
363
  "DurableRuntimeFailpointError",
364
+ "OperationDenied",
315
365
  "DurableAlarmError",
316
366
  "HostProtocolError",
317
367
  ]);
@@ -351,6 +401,14 @@ export class CloudflareConversationClient extends Context.Service<
351
401
  ) => Effect.Effect<Receipt, ClientSubmitFailure, InputSchema["EncodingServices"]>;
352
402
  /** Wake-hinted, poll-guaranteed settlement wait executed inside the owning Object. */
353
403
  readonly awaitSettlement: (receipt: Receipt) => Effect.Effect<Settlement, ClientAwaitFailure>;
404
+ /**
405
+ * Wait without polling until progress after `afterSequence` is already durable or hinted.
406
+ * The result is deliberately void: canonical records remain authoritative and must be read.
407
+ */
408
+ readonly awaitProgress: (
409
+ conversationId: ConversationId,
410
+ afterSequence: CanonicalSequence,
411
+ ) => Effect.Effect<void, ClientProgressFailure>;
354
412
  /** One bounded page of canonical records. */
355
413
  readonly readPage: (
356
414
  conversationId: ConversationId,
@@ -389,10 +447,37 @@ export class CloudflareConversationClient extends Context.Service<
389
447
  static readonly layer: Layer.Layer<
390
448
  CloudflareConversationClient,
391
449
  never,
392
- ConversationObjectNamespace
450
+ ConversationObjectNamespace | Crypto.Crypto
393
451
  > = Layer.effect(CloudflareConversationClient)(
394
452
  Effect.gen(function* () {
395
453
  const { namespace } = yield* ConversationObjectNamespace;
454
+ const crypto = yield* Crypto.Crypto;
455
+
456
+ const platformSignals = (cause: unknown) => {
457
+ let retryable: boolean | undefined;
458
+ let overloaded: boolean | undefined;
459
+ if (typeof cause === "object" && cause !== null) {
460
+ if ("retryable" in cause && typeof cause.retryable === "boolean") {
461
+ retryable = cause.retryable;
462
+ }
463
+ if ("overloaded" in cause && typeof cause.overloaded === "boolean") {
464
+ overloaded = cause.overloaded;
465
+ }
466
+ // Miniflare's faithful `ctx.abort()` signal predates the public `retryable` field.
467
+ // Treat only its explicit reset marker as the same idempotent-retry classification.
468
+ if (
469
+ retryable === undefined &&
470
+ "durableObjectReset" in cause &&
471
+ cause.durableObjectReset === true
472
+ ) {
473
+ retryable = true;
474
+ }
475
+ }
476
+ return {
477
+ ...(retryable === undefined ? {} : { retryable }),
478
+ ...(overloaded === undefined ? {} : { overloaded }),
479
+ };
480
+ };
396
481
 
397
482
  const call = (
398
483
  conversationId: string,
@@ -410,6 +495,7 @@ export class CloudflareConversationClient extends Context.Service<
410
495
  }`,
411
496
  ),
412
497
  cause,
498
+ ...platformSignals(cause),
413
499
  }),
414
500
  }).pipe(
415
501
  Effect.flatMap((raw) =>
@@ -488,6 +574,19 @@ export class CloudflareConversationClient extends Context.Service<
488
574
  return page.records;
489
575
  });
490
576
 
577
+ const cancelProgress = (
578
+ conversationId: ConversationId,
579
+ waiterId: string,
580
+ ): Effect.Effect<void> =>
581
+ encodeCancelProgressRequest(CancelProgressRequest.make({ waiterId })).pipe(
582
+ Effect.mapError(() => undefined),
583
+ Effect.flatMap((encoded) =>
584
+ call(conversationId, "cancelProgress", (stub) => stub.cancelProgressEncoded(encoded)),
585
+ ),
586
+ Effect.asVoid,
587
+ Effect.ignore,
588
+ );
589
+
491
590
  return CloudflareConversationClient.of({
492
591
  submit: <InputSchema extends Schema.Top>(
493
592
  agent: DurableSubmitAgent<InputSchema>,
@@ -558,6 +657,55 @@ export class CloudflareConversationClient extends Context.Service<
558
657
  return settled.settlement;
559
658
  }),
560
659
 
660
+ awaitProgress: (conversationId, afterSequence) =>
661
+ Effect.gen(function* () {
662
+ const waiterId = yield* crypto.randomUUIDv4.pipe(
663
+ Effect.mapError((error) =>
664
+ HostProtocolError.make({
665
+ message: boundHostDiagnostic(
666
+ `awaitProgress cancellation identity generation failed: ${error.message}`,
667
+ ),
668
+ }),
669
+ ),
670
+ );
671
+ const request = AwaitProgressRequest.make({ afterSequence, waiterId });
672
+ const encoded = yield* encodeAwaitProgressRequest(request).pipe(
673
+ Effect.mapError((error) =>
674
+ HostProtocolError.make({
675
+ message: boundHostDiagnostic(
676
+ `awaitProgress request encode failed: ${error.message}`,
677
+ ),
678
+ }),
679
+ ),
680
+ );
681
+
682
+ const attempt = (retry: number): Effect.Effect<void, ClientProgressFailure> =>
683
+ call(conversationId, "awaitProgress", (stub) =>
684
+ stub.awaitProgressEncoded(encoded),
685
+ ).pipe(
686
+ Effect.flatMap(
687
+ expect<ProgressObserved, ClientProgressFailure & HostFailure>(
688
+ conversationId,
689
+ "awaitProgress",
690
+ "ProgressObserved",
691
+ PROGRESS_FAILURE_TAGS,
692
+ ),
693
+ ),
694
+ Effect.asVoid,
695
+ Effect.catchTag("ConversationClientError", (error) =>
696
+ error.retryable === true && error.overloaded !== true && retry < 5
697
+ ? Effect.sleep(Duration.millis(10 * 2 ** retry)).pipe(
698
+ Effect.andThen(attempt(retry + 1)),
699
+ )
700
+ : Effect.fail(error),
701
+ ),
702
+ );
703
+
704
+ yield* attempt(0).pipe(
705
+ Effect.onInterrupt(() => cancelProgress(conversationId, waiterId)),
706
+ );
707
+ }),
708
+
561
709
  readPage,
562
710
 
563
711
  readAll: (conversationId) =>
@@ -27,6 +27,7 @@ import {
27
27
  SettlementConflict,
28
28
  SubmissionLedger,
29
29
  SubmissionLookupByKey,
30
+ WakeScheduler,
30
31
  type DurableSubmitAgent,
31
32
  } from "@effect-agent/session";
32
33
  import type { DurableObject as CloudflareDurableObject } from "cloudflare:workers";
@@ -56,11 +57,15 @@ import {
56
57
  HostFailed,
57
58
  HostProtocolError,
58
59
  ObservedPage,
60
+ ProgressObserved,
61
+ ProgressCancelled,
59
62
  SettlementReached,
60
63
  SubmitSucceeded,
61
64
  UnknownResolutionRecorded,
62
65
  boundHostDiagnostic,
63
66
  decodeAbortCommand,
67
+ decodeAwaitProgressRequest,
68
+ decodeCancelProgressRequest,
64
69
  decodeApprovalDecisionCommand,
65
70
  decodeObservePageRequest,
66
71
  decodeReceipt,
@@ -78,6 +83,7 @@ import {
78
83
  type CloudflareDurableRuntimeOptions,
79
84
  type CloudflareDurableRuntimeServices,
80
85
  } from "./layers.ts";
86
+ import { ProgressWaitRegistry } from "./progress-wait.ts";
81
87
 
82
88
  /**
83
89
  * `makeConversationObjectClass(options, observability?)` — the Conversation Durable Object
@@ -279,6 +285,46 @@ const awaitSettlementEndpoint = (
279
285
  Effect.flatMap(encodeResponse),
280
286
  );
281
287
 
288
+ const awaitProgressEndpoint = (encoded: unknown): Effect.Effect<unknown, never, EndpointServices> =>
289
+ decodeAwaitProgressRequest(encoded).pipe(
290
+ Effect.mapError(protocolFailure("The progress request could not be decoded")),
291
+ Effect.flatMap((request) =>
292
+ Effect.gen(function* () {
293
+ const identity = yield* ConversationObjectIdentity;
294
+ const runtime = yield* DurableAgentRuntime;
295
+ const registry = yield* ProgressWaitRegistry;
296
+ yield* Effect.scoped(
297
+ Effect.gen(function* () {
298
+ const cancelled = yield* registry.subscribe(request.waiterId);
299
+ yield* Effect.raceFirst(
300
+ runtime.awaitProgress(identity.conversationId, request.afterSequence),
301
+ cancelled,
302
+ );
303
+ }),
304
+ );
305
+ return ProgressObserved.make();
306
+ }),
307
+ ),
308
+ respond,
309
+ Effect.flatMap(encodeResponse),
310
+ );
311
+
312
+ const cancelProgressEndpoint = (
313
+ encoded: unknown,
314
+ ): Effect.Effect<unknown, never, EndpointServices> =>
315
+ decodeCancelProgressRequest(encoded).pipe(
316
+ Effect.mapError(protocolFailure("The progress cancellation could not be decoded")),
317
+ Effect.flatMap((request) =>
318
+ Effect.gen(function* () {
319
+ const registry = yield* ProgressWaitRegistry;
320
+ yield* registry.cancel(request.waiterId);
321
+ return ProgressCancelled.make();
322
+ }),
323
+ ),
324
+ respond,
325
+ Effect.flatMap(encodeResponse),
326
+ );
327
+
282
328
  const observePageEndpoint = (encoded: unknown): Effect.Effect<unknown, never, EndpointServices> =>
283
329
  decodeObservePageRequest(encoded).pipe(
284
330
  Effect.mapError(protocolFailure("The observe request could not be decoded")),
@@ -289,14 +335,12 @@ const observePageEndpoint = (encoded: unknown): Effect.Effect<unknown, never, En
289
335
  // The same fail-closed authorization seam the runtime's `observe` consults (P7 WP1);
290
336
  // the default reference preserves the possession behavior.
291
337
  const authorizer = yield* OperationAuthorizer;
292
- yield* authorizer
293
- .authorize(
294
- OperationAuthorizationRequest.make({
295
- operation: "observe",
296
- conversationId: identity.conversationId,
297
- }),
298
- )
299
- .pipe(Effect.catchTag("OperationDenied", deniedToProtocolFailure));
338
+ yield* authorizer.authorize(
339
+ OperationAuthorizationRequest.make({
340
+ operation: "observe",
341
+ conversationId: identity.conversationId,
342
+ }),
343
+ );
300
344
  const records = yield* Stream.runCollect(
301
345
  store.read(
302
346
  ConversationRead.make({
@@ -330,24 +374,6 @@ const abortEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpoint
330
374
  Effect.flatMap(encodeResponse),
331
375
  );
332
376
 
333
- /**
334
- * The pre-P7 host protocol's failure union does not carry `OperationDenied` (the Worker client
335
- * predates the authorizer). This assembly always runs the default possession authorizer — no
336
- * `CloudflareDurableRuntimeOptions` authorizer lever exists yet — so a denial here is
337
- * unreachable today; if one ever surfaces it degrades to the protocol failure instead of an
338
- * out-of-contract throw. The four P7 admin entry points below carry `OperationDenied` typed.
339
- */
340
- const deniedToProtocolFailure = (
341
- denied: OperationDenied,
342
- ): Effect.Effect<never, HostProtocolError> =>
343
- Effect.fail(
344
- HostProtocolError.make({
345
- message: boundHostDiagnostic(
346
- `The ${denied.operation} operation was denied: ${denied.reason}`,
347
- ),
348
- }),
349
- );
350
-
351
377
  const resolveApprovalEndpoint = (
352
378
  encoded: unknown,
353
379
  ): Effect.Effect<unknown, never, EndpointServices> =>
@@ -357,11 +383,7 @@ const resolveApprovalEndpoint = (
357
383
  Effect.gen(function* () {
358
384
  const maintenance = yield* ConversationMaintenance;
359
385
  const runtime = yield* DurableAgentRuntime;
360
- const intent = yield* maintenance.withMutation(
361
- runtime
362
- .resolveApproval(command)
363
- .pipe(Effect.catchTag("OperationDenied", deniedToProtocolFailure)),
364
- );
386
+ const intent = yield* maintenance.withMutation(runtime.resolveApproval(command));
365
387
  return ApprovalRecorded.make({ intent });
366
388
  }),
367
389
  ),
@@ -378,11 +400,7 @@ const resolveUnknownEndpoint = (
378
400
  Effect.gen(function* () {
379
401
  const maintenance = yield* ConversationMaintenance;
380
402
  const runtime = yield* DurableAgentRuntime;
381
- const intent = yield* maintenance.withMutation(
382
- runtime
383
- .resolveUnknown(command)
384
- .pipe(Effect.catchTag("OperationDenied", deniedToProtocolFailure)),
385
- );
403
+ const intent = yield* maintenance.withMutation(runtime.resolveUnknown(command));
386
404
  return UnknownResolutionRecorded.make({ intent });
387
405
  }),
388
406
  ),
@@ -599,12 +617,11 @@ const portCallEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpo
599
617
  });
600
618
 
601
619
  const wakeEndpoint: Effect.Effect<void, never, EndpointServices> = Effect.gen(function* () {
602
- const alarm = yield* DurableAlarmService;
603
- // Wake hints are droppable by contract: a failed alarm write is logged and swallowed; the
604
- // sender's own alarm/scan pairing (or this Object's next entry point) restores liveness.
605
- yield* alarm.scheduleNow.pipe(
606
- Effect.catch((error) => Effect.logWarning("ConversationObject.wake dropped", error)),
607
- );
620
+ const identity = yield* ConversationObjectIdentity;
621
+ const wake = yield* WakeScheduler;
622
+ // Route the remote hint through this incarnation's scheduler so scoped progress waiters and
623
+ // the alarm receive the same hint. Delivery remains droppable; canonical storage is authority.
624
+ yield* wake.notify(identity.conversationId);
608
625
  });
609
626
 
610
627
  const alarmEndpoint: Effect.Effect<void, MaintenancePassFailure, EndpointServices> = Effect.gen(
@@ -659,6 +676,8 @@ const effectCfPlatformLayer = (
659
676
  export interface ConversationObjectInstance extends CloudflareDurableObject {
660
677
  submitEncoded(encoded: unknown): Promise<unknown>;
661
678
  awaitSettlementEncoded(encoded: unknown): Promise<unknown>;
679
+ awaitProgressEncoded(encoded: unknown): Promise<unknown>;
680
+ cancelProgressEncoded(encoded: unknown): Promise<unknown>;
662
681
  observePage(encoded: unknown): Promise<unknown>;
663
682
  abortEncoded(encoded: unknown): Promise<unknown>;
664
683
  resolveApprovalEncoded(encoded: unknown): Promise<unknown>;
@@ -727,6 +746,8 @@ export const makeConversationObjectClass = <EventLayerError = never, EventServic
727
746
  const rpc = {
728
747
  submitEncoded: (encoded: unknown) => submitEndpoint(encoded),
729
748
  awaitSettlementEncoded: (encoded: unknown) => awaitSettlementEndpoint(encoded),
749
+ awaitProgressEncoded: (encoded: unknown) => awaitProgressEndpoint(encoded),
750
+ cancelProgressEncoded: (encoded: unknown) => cancelProgressEndpoint(encoded),
730
751
  observePage: (encoded: unknown) => observePageEndpoint(encoded),
731
752
  abortEncoded: (encoded: unknown) => abortEndpoint(encoded),
732
753
  resolveApprovalEncoded: (encoded: unknown) => resolveApprovalEndpoint(encoded),
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export * from "./bindings.ts";
17
17
  export * from "./config.ts";
18
18
  export * from "./alarm.ts";
19
19
  export * from "./wake-scheduler.ts";
20
+ export * from "./progress-wait.ts";
20
21
  export * from "./transport.ts";
21
22
  export * from "./layers.ts";
22
23
  export * from "./conversation-object.ts";
package/src/layers.ts CHANGED
@@ -5,9 +5,11 @@ import {
5
5
  DurableRuntimeConfig,
6
6
  DurableRuntimeFailpoint,
7
7
  ProducerId,
8
+ operationAuthorizerLayer,
8
9
  ToolReconciler,
9
10
  type ConversationStore,
10
11
  type DurableRuntimeFailpointHandler,
12
+ type OperationAuthorizerService,
11
13
  type ResolvedBinding,
12
14
  type SubmissionLedger,
13
15
  type WakeScheduler,
@@ -45,6 +47,7 @@ import {
45
47
  CloudflareDurableRuntimeConfigValue,
46
48
  CloudflarePlatformConfigError,
47
49
  } from "./config.ts";
50
+ import { ProgressWaitRegistry } from "./progress-wait.ts";
48
51
  import { conversationPortTransportLayer } from "./transport.ts";
49
52
  import { cloudflareWakeSchedulerLayer } from "./wake-scheduler.ts";
50
53
 
@@ -98,6 +101,8 @@ export interface CloudflareDurableRuntimeOptions {
98
101
  readonly maintenanceFailpoint?:
99
102
  | ((ctx: DurableObjectState) => ConversationMaintenanceFailpointHandler)
100
103
  | undefined;
104
+ /** Host-supplied fail-closed authorization policy; defaults to service possession. */
105
+ readonly operationAuthorizer?: OperationAuthorizerService | undefined;
101
106
  /**
102
107
  * Reconciliation policy consulted for open ordinary Tool Calls before an Unknown Outcome
103
108
  * is recorded (durability §10, DUR-009). Defaults to the fail-closed
@@ -152,7 +157,8 @@ export type CloudflareDurableRuntimeServices =
152
157
  | ConversationObjectIdentity
153
158
  | DurableAlarmService
154
159
  | ConversationMaintenance
155
- | ConversationObjectPorts;
160
+ | ConversationObjectPorts
161
+ | ProgressWaitRegistry;
156
162
 
157
163
  /**
158
164
  * Owner-side endpoint body for the Conversation Object's `portCall` (plan §1.3): decode,
@@ -356,6 +362,10 @@ export class CloudflareDurableRuntime {
356
362
  hit: options.maintenanceFailpoint(ctx),
357
363
  });
358
364
  const reconcilerLayer = options.toolReconciler ?? ToolReconciler.uncertain;
365
+ const authorizerLayer =
366
+ options.operationAuthorizer === undefined
367
+ ? Layer.empty
368
+ : operationAuthorizerLayer(options.operationAuthorizer);
359
369
  const bindingResolverLayer = Layer.effect(AgentBindingResolver)(
360
370
  Effect.map(
361
371
  resolveBindings(options.bindings, { ctx, env, conversationId, producerId }),
@@ -368,6 +378,7 @@ export class CloudflareDurableRuntime {
368
378
  cloudflareConfigLayer,
369
379
  DurableAlarmService.layer,
370
380
  maintenanceFailpointLayer,
381
+ ProgressWaitRegistry.layer,
371
382
  );
372
383
 
373
384
  const runtimeStack = DurableAgentRuntime.layer.pipe(
@@ -376,7 +387,12 @@ export class CloudflareDurableRuntime {
376
387
  Layer.provideMerge(runtimeConfigLayer),
377
388
  Layer.provideMerge(bindingResolverLayer),
378
389
  Layer.provide(
379
- Layer.mergeAll(runtimeFailpointLayer, reconcilerLayer, BrowserCrypto.layer),
390
+ Layer.mergeAll(
391
+ runtimeFailpointLayer,
392
+ reconcilerLayer,
393
+ authorizerLayer,
394
+ BrowserCrypto.layer,
395
+ ),
380
396
  ),
381
397
  Layer.provideMerge(base),
382
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
+ }