@effect-agent/platform-cloudflare 0.1.0-beta.15 → 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/dist/index.d.mts +142 -73
- package/dist/index.mjs +148 -20
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/bindings.ts +4 -0
- package/src/client.ts +150 -2
- package/src/conversation-object.ts +63 -42
- package/src/index.ts +1 -0
- package/src/layers.ts +18 -2
- package/src/progress-wait.ts +103 -0
- package/src/wake-scheduler.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-agent/platform-cloudflare",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.16",
|
|
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
|
-
"@effect-agent/sandbox": "0.1.0-beta.
|
|
13
|
-
"@effect-agent/session": "0.1.0-beta.
|
|
14
|
-
"@effect-agent/storage-cloudflare": "0.1.0-beta.
|
|
11
|
+
"@effect-agent/core": "0.1.0-beta.16",
|
|
12
|
+
"@effect-agent/sandbox": "0.1.0-beta.16",
|
|
13
|
+
"@effect-agent/session": "0.1.0-beta.16",
|
|
14
|
+
"@effect-agent/storage-cloudflare": "0.1.0-beta.16",
|
|
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
|
-
.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
603
|
-
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
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(
|
|
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
|
+
}
|