@effect-agent/platform-cloudflare 0.1.0-beta.52 → 0.1.0-beta.54
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/Alarm.d.mts +45 -6
- package/dist/Alarm.mjs +111 -47
- package/dist/Alarm.mjs.map +1 -1
- package/dist/BrowserRestCapture.mjs +1 -1
- package/dist/{CloudflareBrowser-1jeZx3_g.mjs → CloudflareBrowser-Bj22nNUT.mjs} +12 -13
- package/dist/CloudflareBrowser-Bj22nNUT.mjs.map +1 -0
- package/dist/CloudflareBrowser.mjs +1 -1
- package/dist/CloudflareThreadClient.d.mts +18 -17
- package/dist/CloudflareThreadClient.mjs +2 -1
- package/dist/CloudflareThreadClient.mjs.map +1 -1
- package/dist/ProtectedBrowser.d.mts +5 -3
- package/dist/ProtectedBrowser.mjs +180 -36
- package/dist/ProtectedBrowser.mjs.map +1 -1
- package/dist/{ThreadObject-BSRVxtPI.d.mts → ThreadObject-6_j7YgMV.d.mts} +48 -37
- package/dist/{ThreadObject-BY8axaWT.mjs → ThreadObject-D2eP-tEO.mjs} +44 -20
- package/dist/ThreadObject-D2eP-tEO.mjs.map +1 -0
- package/dist/ThreadObject.d.mts +2 -2
- package/dist/ThreadObject.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +2 -2
- package/package.json +1 -1
- package/src/Alarm.ts +242 -72
- package/src/CloudflareThreadClient.ts +6 -0
- package/src/ThreadObject.ts +1 -0
- package/src/internal/browser-quick-action.ts +14 -11
- package/src/internal/layers.ts +93 -9
- package/src/internal/progress-wait.ts +17 -13
- package/src/protected-browser/binding.ts +2 -1
- package/src/protected-browser/inspect-frame.ts +62 -19
- package/src/protected-browser/native.ts +50 -5
- package/src/protected-browser/policy.ts +138 -13
- package/dist/CloudflareBrowser-1jeZx3_g.mjs.map +0 -1
- package/dist/ThreadObject-BY8axaWT.mjs.map +0 -1
package/src/Alarm.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@effect-agent/thread/DurableAgentRuntime";
|
|
7
7
|
import { SubmissionLedger, type SubmissionSnapshot } from "@effect-agent/thread/SubmissionLedger";
|
|
8
8
|
import {
|
|
9
|
+
Cause,
|
|
9
10
|
Clock,
|
|
10
11
|
Context,
|
|
11
12
|
DateTime,
|
|
@@ -157,7 +158,7 @@ export class DurableAlarmService extends Context.Service<
|
|
|
157
158
|
export class MaintenancePassReport extends Schema.Class<MaintenancePassReport>(
|
|
158
159
|
"@effect-agent/platform-cloudflare/MaintenancePassReport",
|
|
159
160
|
)({
|
|
160
|
-
/** `caught-up`
|
|
161
|
+
/** `caught-up` ran no runtime work (publication may be pending); `actionable` ran recovery. */
|
|
161
162
|
phase: Schema.Literals(["caught-up", "actionable"]),
|
|
162
163
|
/** Recovery decisions executed (or deferred) BEFORE any new claim in this pass. */
|
|
163
164
|
recovered: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
@@ -196,6 +197,53 @@ export class ThreadMaintenanceFailpoint extends Context.Service<
|
|
|
196
197
|
static readonly layer = Layer.succeed(this)({ hit: () => Effect.void });
|
|
197
198
|
}
|
|
198
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Durable host publication of canonical records and ledger approval/abort/resolution intents.
|
|
202
|
+
* The host owns schema-versioned cursors, destination idempotency and acknowledgement. Delivery
|
|
203
|
+
* is at least once. Hooks must not write the alarm slot or mutate the supplied raw source ports.
|
|
204
|
+
*
|
|
205
|
+
* `invalidate`, `prepareGeneration` and `pendingDeadline` must be bounded local operations.
|
|
206
|
+
* `prepareGeneration` durably invalidates a scan only when its generation changes; repeated
|
|
207
|
+
* calls must preserve partial scan progress. It runs with no source mutation in flight.
|
|
208
|
+
* `drain` performs bounded delivery and persists retries before returning. A pending deadline
|
|
209
|
+
* defers runtime recovery/Attempts, allowing committed host publications to drain first.
|
|
210
|
+
* Unexpected hook failures leave the prearmed generation for retry. Hooks acquire per-call
|
|
211
|
+
* resources with Effect.scoped; Layer construction owns incarnation resources (eviction need
|
|
212
|
+
* not run finalizers). Do not hold a local hook behind network I/O or call back into producers.
|
|
213
|
+
*/
|
|
214
|
+
export interface ThreadPublicationService {
|
|
215
|
+
readonly invalidate: Effect.Effect<void, DurableAlarmError>;
|
|
216
|
+
readonly prepareGeneration: (generation: bigint) => Effect.Effect<void, DurableAlarmError>;
|
|
217
|
+
readonly drain: Effect.Effect<void, DurableAlarmError>;
|
|
218
|
+
readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Opt in with `ThreadObject.layer(registrations, { publication: Layer.effect(ThreadPublication)(...) })`. */
|
|
222
|
+
export class ThreadPublication extends Context.Service<
|
|
223
|
+
ThreadPublication,
|
|
224
|
+
ThreadPublicationService
|
|
225
|
+
>()("@effect-agent/platform-cloudflare/ThreadPublication") {
|
|
226
|
+
static readonly layer = Layer.succeed(this)({
|
|
227
|
+
invalidate: Effect.void,
|
|
228
|
+
prepareGeneration: () => Effect.void,
|
|
229
|
+
drain: Effect.void,
|
|
230
|
+
pendingDeadline: Effect.succeed(Option.none()),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @internal A committed source operation must not become a failed operation because delivery failed. */
|
|
235
|
+
export const publishCommitted = Effect.gen(function* () {
|
|
236
|
+
const publication = yield* ThreadPublication;
|
|
237
|
+
|
|
238
|
+
yield* publication.invalidate.pipe(Effect.andThen(publication.drain));
|
|
239
|
+
}).pipe(
|
|
240
|
+
Effect.catchCause((cause) =>
|
|
241
|
+
Cause.hasInterrupts(cause)
|
|
242
|
+
? Effect.interrupt
|
|
243
|
+
: Effect.logError("Thread publication deferred after source commit", cause),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
|
|
199
247
|
const MaintenanceGeneration = Schema.BigIntFromString.check(
|
|
200
248
|
Schema.isGreaterThanOrEqualToBigInt(0n),
|
|
201
249
|
);
|
|
@@ -271,6 +319,84 @@ const stableExternalWait = (
|
|
|
271
319
|
}
|
|
272
320
|
};
|
|
273
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Shared prearm/acknowledgement boundary for ingress and runtime-owned producers.
|
|
324
|
+
* `ThreadObject.layer` provides this same instance in its Services. Rebuilt runtime/maintenance
|
|
325
|
+
* Layers must reuse that instance; a second gate cannot observe the native producers' activity.
|
|
326
|
+
*/
|
|
327
|
+
export class ThreadMutationGate extends Context.Service<
|
|
328
|
+
ThreadMutationGate,
|
|
329
|
+
{
|
|
330
|
+
readonly withMutation: <A, E, R>(
|
|
331
|
+
body: Effect.Effect<A, E, R>,
|
|
332
|
+
) => Effect.Effect<A, E | DurableAlarmError, R>;
|
|
333
|
+
readonly withSnapshot: <A, E, R>(
|
|
334
|
+
body: (active: number) => Effect.Effect<A, E, R>,
|
|
335
|
+
) => Effect.Effect<A, E, R>;
|
|
336
|
+
}
|
|
337
|
+
>()("@effect-agent/platform-cloudflare/internal/ThreadMutationGate") {
|
|
338
|
+
static readonly layer = Layer.effect(this)(
|
|
339
|
+
Effect.gen(function* () {
|
|
340
|
+
const { ctx } = yield* DurableObjectContext;
|
|
341
|
+
const config = yield* CloudflareDurableRuntimeConfig;
|
|
342
|
+
const failpoint = yield* ThreadMaintenanceFailpoint;
|
|
343
|
+
// A fresh incarnation has no live mutations; durable generations survive eviction.
|
|
344
|
+
const activeMutations = yield* Ref.make(0);
|
|
345
|
+
const generationGate = yield* Semaphore.make(1);
|
|
346
|
+
const minimumAlarmDelay = Math.max(1, Math.ceil(config.alarmBackoffBase / 2));
|
|
347
|
+
|
|
348
|
+
const runTransaction = <A>(operation: string, transaction: () => Promise<A>) =>
|
|
349
|
+
Effect.tryPromise({ try: transaction, catch: alarmFailure(operation) });
|
|
350
|
+
|
|
351
|
+
const beginMutation = Effect.fn("ThreadMaintenance.beginMutation")(function* () {
|
|
352
|
+
yield* failpoint.hit("maintenance:dirty:before");
|
|
353
|
+
const now = yield* Clock.currentTimeMillis;
|
|
354
|
+
|
|
355
|
+
yield* runTransaction("advance maintenance generation", () =>
|
|
356
|
+
ctx.storage.transaction(async (transaction) => {
|
|
357
|
+
const { state } = await readMaintenanceState(transaction);
|
|
358
|
+
|
|
359
|
+
const next = ThreadMaintenanceState.make({
|
|
360
|
+
...state,
|
|
361
|
+
dirty: state.dirty + 1n,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
|
|
365
|
+
// The earliest configured retry bounds a newly actionable mutation without relying
|
|
366
|
+
// on its best-effort immediate wake hint.
|
|
367
|
+
await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
yield* failpoint.hit("maintenance:dirty:after");
|
|
371
|
+
yield* Ref.update(activeMutations, (active) => active + 1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const endMutation = generationGate.withPermit(
|
|
375
|
+
Ref.update(activeMutations, (active) => Math.max(0, active - 1)),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const withMutation = <A, E, R>(
|
|
379
|
+
body: Effect.Effect<A, E, R>,
|
|
380
|
+
): Effect.Effect<A, E | DurableAlarmError, R> =>
|
|
381
|
+
Effect.acquireUseRelease(
|
|
382
|
+
generationGate.withPermit(beginMutation()),
|
|
383
|
+
() =>
|
|
384
|
+
failpoint.hit("maintenance:mutation:armed").pipe(
|
|
385
|
+
Effect.andThen(body),
|
|
386
|
+
Effect.tap(() => failpoint.hit("maintenance:mutation:finished")),
|
|
387
|
+
),
|
|
388
|
+
() => endMutation,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
return ThreadMutationGate.of({
|
|
392
|
+
withMutation,
|
|
393
|
+
withSnapshot: (body) =>
|
|
394
|
+
generationGate.withPermit(Effect.flatMap(Ref.get(activeMutations), body)),
|
|
395
|
+
});
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
274
400
|
export type MaintenancePassFailure =
|
|
275
401
|
| DurableWorkerFailure
|
|
276
402
|
| DurableBindingFailure
|
|
@@ -313,6 +439,8 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
313
439
|
static readonly layer: Layer.Layer<
|
|
314
440
|
ThreadMaintenance,
|
|
315
441
|
never,
|
|
442
|
+
| ThreadMutationGate
|
|
443
|
+
| ThreadPublication
|
|
316
444
|
| DurableAgentRuntime
|
|
317
445
|
| SubmissionLedger
|
|
318
446
|
| DurableAlarmService
|
|
@@ -335,68 +463,13 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
335
463
|
* restarts at zero and merely re-arms sooner than a long-lived one would have.
|
|
336
464
|
*/
|
|
337
465
|
const stalls = yield* Ref.make(0);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
* deliberately not durable: after eviction every begun mutation has stopped, while its
|
|
341
|
-
* pre-armed dirty generation remains durable for recovery. The short gate never spans the
|
|
342
|
-
* caller's mutation or cross-Object I/O.
|
|
343
|
-
*/
|
|
344
|
-
const activeMutations = yield* Ref.make(0);
|
|
345
|
-
const generationGate = yield* Semaphore.make(1);
|
|
346
|
-
// At-least-once deliveries are idempotent, but overlapping pass bodies could otherwise
|
|
347
|
-
// acknowledge state while a sibling pass is still mutating it. Port/RPC mutations do not
|
|
348
|
-
// take this permit, so cross-Object I/O cannot deadlock the maintenance serialization.
|
|
466
|
+
const mutations = yield* ThreadMutationGate;
|
|
467
|
+
const publication = yield* ThreadPublication;
|
|
349
468
|
const maintenancePassGate = yield* Semaphore.make(1);
|
|
350
469
|
const minimumAlarmDelay = Math.max(1, Math.ceil(config.alarmBackoffBase / 2));
|
|
351
470
|
|
|
352
|
-
const runTransaction = <A>(
|
|
353
|
-
|
|
354
|
-
transaction: () => Promise<A>,
|
|
355
|
-
): Effect.Effect<A, DurableAlarmError> =>
|
|
356
|
-
Effect.tryPromise({
|
|
357
|
-
try: transaction,
|
|
358
|
-
catch: alarmFailure(operation),
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const beginMutation = Effect.fn("ThreadMaintenance.beginMutation")(function* () {
|
|
362
|
-
yield* failpoint.hit("maintenance:dirty:before");
|
|
363
|
-
const now = yield* Clock.currentTimeMillis;
|
|
364
|
-
|
|
365
|
-
yield* runTransaction("advance maintenance generation", () =>
|
|
366
|
-
ctx.storage.transaction(async (transaction) => {
|
|
367
|
-
const { state } = await readMaintenanceState(transaction);
|
|
368
|
-
|
|
369
|
-
const next = ThreadMaintenanceState.make({
|
|
370
|
-
...state,
|
|
371
|
-
dirty: state.dirty + 1n,
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
|
|
375
|
-
// The earliest configured retry bounds a newly actionable mutation without relying
|
|
376
|
-
// on its best-effort immediate wake hint.
|
|
377
|
-
await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
|
|
378
|
-
}),
|
|
379
|
-
);
|
|
380
|
-
yield* failpoint.hit("maintenance:dirty:after");
|
|
381
|
-
yield* Ref.update(activeMutations, (active) => active + 1);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
const endMutation = generationGate.withPermit(
|
|
385
|
-
Ref.update(activeMutations, (active) => Math.max(0, active - 1)),
|
|
386
|
-
);
|
|
387
|
-
|
|
388
|
-
const withMutation = <A, E, R>(
|
|
389
|
-
body: Effect.Effect<A, E, R>,
|
|
390
|
-
): Effect.Effect<A, E | DurableAlarmError, R> =>
|
|
391
|
-
Effect.acquireUseRelease(
|
|
392
|
-
generationGate.withPermit(beginMutation()),
|
|
393
|
-
() =>
|
|
394
|
-
failpoint.hit("maintenance:mutation:armed").pipe(
|
|
395
|
-
Effect.andThen(body),
|
|
396
|
-
Effect.tap(() => failpoint.hit("maintenance:mutation:finished")),
|
|
397
|
-
),
|
|
398
|
-
() => endMutation,
|
|
399
|
-
);
|
|
471
|
+
const runTransaction = <A>(operation: string, transaction: () => Promise<A>) =>
|
|
472
|
+
Effect.tryPromise({ try: transaction, catch: alarmFailure(operation) });
|
|
400
473
|
|
|
401
474
|
const ensureAlarm = Effect.fn("ThreadMaintenance.ensureAlarm")(function* () {
|
|
402
475
|
yield* failpoint.hit("maintenance:ensure:before");
|
|
@@ -414,6 +487,18 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
414
487
|
}
|
|
415
488
|
}),
|
|
416
489
|
);
|
|
490
|
+
const deadline = yield* publication.pendingDeadline;
|
|
491
|
+
|
|
492
|
+
if (Option.isSome(deadline)) {
|
|
493
|
+
yield* runTransaction("ensure publication alarm", () =>
|
|
494
|
+
ctx.storage.transaction((transaction) =>
|
|
495
|
+
ensureTransactionAlarmBy(
|
|
496
|
+
transaction,
|
|
497
|
+
Math.max(now + minimumAlarmDelay, deadline.value),
|
|
498
|
+
),
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
}
|
|
417
502
|
yield* failpoint.hit("maintenance:ensure:after");
|
|
418
503
|
});
|
|
419
504
|
|
|
@@ -429,7 +514,8 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
429
514
|
await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
|
|
430
515
|
}
|
|
431
516
|
if (state.processed >= state.dirty) {
|
|
432
|
-
|
|
517
|
+
// Prearm even a publication-only pass before invoking any host hook.
|
|
518
|
+
await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
|
|
433
519
|
|
|
434
520
|
return { _tag: "CaughtUp" as const, nonterminal: state.nonterminal };
|
|
435
521
|
}
|
|
@@ -437,7 +523,11 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
437
523
|
// LATER to its bounded backoff, which does not cancel the running handler.
|
|
438
524
|
await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
|
|
439
525
|
|
|
440
|
-
return {
|
|
526
|
+
return {
|
|
527
|
+
_tag: "Actionable" as const,
|
|
528
|
+
generation: state.dirty,
|
|
529
|
+
nonterminal: state.nonterminal,
|
|
530
|
+
};
|
|
441
531
|
}),
|
|
442
532
|
);
|
|
443
533
|
|
|
@@ -474,23 +564,74 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
474
564
|
alarm: report.alarm,
|
|
475
565
|
}).pipe(Effect.as(report));
|
|
476
566
|
|
|
477
|
-
const started = yield*
|
|
567
|
+
const started = yield* mutations.withSnapshot((activeAtStart) =>
|
|
478
568
|
Effect.gen(function* () {
|
|
479
|
-
const activeAtStart = yield* Ref.get(activeMutations);
|
|
480
569
|
const generation = yield* beginPass();
|
|
481
570
|
|
|
571
|
+
if (generation._tag === "Actionable" && activeAtStart === 0) {
|
|
572
|
+
// The gate excludes a producer starting between the snapshot and certification.
|
|
573
|
+
yield* publication.prepareGeneration(generation.generation);
|
|
574
|
+
}
|
|
575
|
+
|
|
482
576
|
return { ...generation, activeAtStart };
|
|
483
577
|
}),
|
|
484
578
|
);
|
|
485
579
|
|
|
486
|
-
|
|
580
|
+
const deadline = yield* publication.pendingDeadline;
|
|
581
|
+
|
|
582
|
+
if (
|
|
583
|
+
started._tag === "Actionable" ||
|
|
584
|
+
(Option.isSome(deadline) && deadline.value <= (yield* Clock.currentTimeMillis))
|
|
585
|
+
) {
|
|
586
|
+
yield* publication.drain;
|
|
587
|
+
}
|
|
588
|
+
const pending = yield* publication.pendingDeadline;
|
|
589
|
+
|
|
590
|
+
if (started._tag === "CaughtUp" || Option.isSome(pending)) {
|
|
591
|
+
yield* failpoint.hit("maintenance:finish:before");
|
|
592
|
+
|
|
593
|
+
const disposition = yield* mutations.withSnapshot((active) =>
|
|
594
|
+
Effect.gen(function* () {
|
|
595
|
+
// Re-read under the producer gate: a concurrent append/host mutation cannot be
|
|
596
|
+
// cleared using a stale empty deadline. Dirty generations bound all producer races.
|
|
597
|
+
const latest = yield* publication.pendingDeadline;
|
|
598
|
+
const now = yield* Clock.currentTimeMillis;
|
|
599
|
+
|
|
600
|
+
return yield* runTransaction("finish publication pass", () =>
|
|
601
|
+
ctx.storage.transaction(async (transaction) => {
|
|
602
|
+
const { state } = await readMaintenanceState(transaction);
|
|
603
|
+
|
|
604
|
+
const nativeDeadline =
|
|
605
|
+
active > 0 || state.dirty > state.processed
|
|
606
|
+
? now + config.wakeScanInterval
|
|
607
|
+
: Infinity;
|
|
608
|
+
|
|
609
|
+
const next = Option.isSome(latest)
|
|
610
|
+
? Math.min(nativeDeadline, latest.value)
|
|
611
|
+
: nativeDeadline;
|
|
612
|
+
|
|
613
|
+
if (Number.isFinite(next)) {
|
|
614
|
+
await transaction.setAlarm(Math.max(now + minimumAlarmDelay, next));
|
|
615
|
+
|
|
616
|
+
return "rearmed" as const;
|
|
617
|
+
}
|
|
618
|
+
await transaction.deleteAlarm();
|
|
619
|
+
|
|
620
|
+
return "cleared" as const;
|
|
621
|
+
}),
|
|
622
|
+
);
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
yield* failpoint.hit("maintenance:finish:after");
|
|
627
|
+
|
|
487
628
|
return yield* annotate(
|
|
488
629
|
MaintenancePassReport.make({
|
|
489
630
|
phase: "caught-up",
|
|
490
631
|
recovered: 0,
|
|
491
632
|
settled: 0,
|
|
492
633
|
nonterminal: started.nonterminal,
|
|
493
|
-
alarm:
|
|
634
|
+
alarm: disposition,
|
|
494
635
|
}),
|
|
495
636
|
);
|
|
496
637
|
}
|
|
@@ -528,9 +669,9 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
528
669
|
|
|
529
670
|
yield* failpoint.hit("maintenance:finish:before");
|
|
530
671
|
|
|
531
|
-
const alarmDisposition = yield*
|
|
672
|
+
const alarmDisposition = yield* mutations.withSnapshot((active) =>
|
|
532
673
|
Effect.gen(function* () {
|
|
533
|
-
const
|
|
674
|
+
const publicationDeadline = yield* publication.pendingDeadline;
|
|
534
675
|
|
|
535
676
|
return yield* runTransaction("finish maintenance pass", () =>
|
|
536
677
|
ctx.storage.transaction(async (transaction) => {
|
|
@@ -556,7 +697,14 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
556
697
|
// Replace the crash-fallback slot with this pass's bounded backoff. The target
|
|
557
698
|
// is never earlier than the begin-pass fallback, so workerd does not cancel
|
|
558
699
|
// this running alarm handler before its report/span can complete.
|
|
559
|
-
await transaction.setAlarm(
|
|
700
|
+
await transaction.setAlarm(
|
|
701
|
+
Option.isSome(publicationDeadline)
|
|
702
|
+
? Math.max(
|
|
703
|
+
now + minimumAlarmDelay,
|
|
704
|
+
Math.min(now + delay, publicationDeadline.value),
|
|
705
|
+
)
|
|
706
|
+
: now + delay,
|
|
707
|
+
);
|
|
560
708
|
|
|
561
709
|
return "rearmed" as const;
|
|
562
710
|
}
|
|
@@ -566,7 +714,22 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
566
714
|
// unseen effects are never acknowledged. Do not accelerate that future alarm
|
|
567
715
|
// from inside the current handler: workerd cancels a running handler when it
|
|
568
716
|
// writes an earlier slot.
|
|
569
|
-
await ensureTransactionAlarmBy(
|
|
717
|
+
await ensureTransactionAlarmBy(
|
|
718
|
+
transaction,
|
|
719
|
+
Option.isSome(publicationDeadline)
|
|
720
|
+
? Math.max(
|
|
721
|
+
now + minimumAlarmDelay,
|
|
722
|
+
Math.min(now + config.wakeScanInterval, publicationDeadline.value),
|
|
723
|
+
)
|
|
724
|
+
: now + config.wakeScanInterval,
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
return "rearmed" as const;
|
|
728
|
+
}
|
|
729
|
+
if (Option.isSome(publicationDeadline)) {
|
|
730
|
+
await transaction.setAlarm(
|
|
731
|
+
Math.max(now + minimumAlarmDelay, publicationDeadline.value),
|
|
732
|
+
);
|
|
570
733
|
|
|
571
734
|
return "rearmed" as const;
|
|
572
735
|
}
|
|
@@ -615,8 +778,15 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
615
778
|
}),
|
|
616
779
|
}),
|
|
617
780
|
),
|
|
618
|
-
ensureAlarm: ensureAlarm(),
|
|
619
|
-
withMutation
|
|
781
|
+
ensureAlarm: mutations.withSnapshot(() => ensureAlarm()),
|
|
782
|
+
withMutation: (body) =>
|
|
783
|
+
mutations.withMutation(
|
|
784
|
+
body.pipe(
|
|
785
|
+
Effect.tap(() =>
|
|
786
|
+
publishCommitted.pipe(Effect.provideService(ThreadPublication, publication)),
|
|
787
|
+
),
|
|
788
|
+
),
|
|
789
|
+
),
|
|
620
790
|
});
|
|
621
791
|
}),
|
|
622
792
|
);
|
|
@@ -61,6 +61,7 @@ import { cloudflareFailureSignals, safeCauseMessage } from "./internal/boundary.
|
|
|
61
61
|
|
|
62
62
|
/** Ceiling for host protocol diagnostic strings. */
|
|
63
63
|
const MAX_HOST_DIAGNOSTIC_LENGTH = 4_096;
|
|
64
|
+
const PROGRESS_CANCELLATION_TIMEOUT = "1 second";
|
|
64
65
|
|
|
65
66
|
const BoundedDiagnostic = Schema.String.check(Schema.isMaxLength(MAX_HOST_DIAGNOSTIC_LENGTH));
|
|
66
67
|
|
|
@@ -379,6 +380,7 @@ export class CloudflareThreadClient extends Context.Service<
|
|
|
379
380
|
/**
|
|
380
381
|
* Wait without polling until progress after `afterSequence` is already durable or hinted.
|
|
381
382
|
* The result is deliberately void: canonical records remain authoritative and must be read.
|
|
383
|
+
* Interruption waits at most one second for best-effort remote cancellation.
|
|
382
384
|
*/
|
|
383
385
|
readonly awaitProgress: (
|
|
384
386
|
threadId: ThreadId,
|
|
@@ -556,6 +558,10 @@ export class CloudflareThreadClient extends Context.Service<
|
|
|
556
558
|
encodeCancelProgressRequest(CancelProgressRequest.make({ waiterId })).pipe(
|
|
557
559
|
Effect.mapError(() => undefined),
|
|
558
560
|
Effect.flatMap((encoded) => call(threadId, "cancelProgress", encoded)),
|
|
561
|
+
// Finalizers are uninterruptible; only the foreign RPC branch must remain
|
|
562
|
+
// interruptible so a lost cancellation reply cannot prevent local shutdown.
|
|
563
|
+
Effect.interruptible,
|
|
564
|
+
Effect.timeout(PROGRESS_CANCELLATION_TIMEOUT),
|
|
559
565
|
Effect.asVoid,
|
|
560
566
|
Effect.ignore,
|
|
561
567
|
);
|
package/src/ThreadObject.ts
CHANGED
|
@@ -103,6 +103,7 @@ import { ProgressWaitRegistry } from "./internal/progress-wait.ts";
|
|
|
103
103
|
export {
|
|
104
104
|
layer,
|
|
105
105
|
layerConfig,
|
|
106
|
+
type ThreadPublicationOptions as PublicationOptions,
|
|
106
107
|
type CloudflareDurableRuntimeOptions as RuntimeOptions,
|
|
107
108
|
type CloudflareDurableRuntimeServices as Services,
|
|
108
109
|
type CloudflareDurableRuntimeInitializationError as InitializationError,
|
|
@@ -271,14 +271,21 @@ const navigationError = (message: string, cause?: unknown): PageCaptureNavigatio
|
|
|
271
271
|
const privateResponseCause = (bodyText: string): Error | undefined =>
|
|
272
272
|
bodyText.length === 0 ? undefined : new Error(boundedDiagnostic(bodyText));
|
|
273
273
|
|
|
274
|
+
/** Foreign cancellation must not keep a response Scope open indefinitely. */
|
|
275
|
+
const cancelResponse = (cancel: () => Promise<void>, warning: string): Effect.Effect<void> =>
|
|
276
|
+
Effect.tryPromise({ try: cancel, catch: () => undefined }).pipe(
|
|
277
|
+
Effect.interruptible,
|
|
278
|
+
Effect.timeoutOrElse({
|
|
279
|
+
duration: "1 second",
|
|
280
|
+
orElse: () => Effect.fail(undefined),
|
|
281
|
+
}),
|
|
282
|
+
Effect.catch(() => Effect.logWarning(warning)),
|
|
283
|
+
);
|
|
284
|
+
|
|
274
285
|
const releaseResponseReader = (
|
|
275
286
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
276
287
|
): Effect.Effect<void> =>
|
|
277
|
-
|
|
278
|
-
try: () => reader.cancel(),
|
|
279
|
-
catch: (cause) => protocolError("Canceling the Quick Action response failed", cause),
|
|
280
|
-
}).pipe(
|
|
281
|
-
Effect.catch((error) => Effect.logWarning(error.message)),
|
|
288
|
+
cancelResponse(() => reader.cancel(), "Canceling the Quick Action response failed").pipe(
|
|
282
289
|
Effect.ensuring(
|
|
283
290
|
Effect.try({
|
|
284
291
|
try: () => reader.releaseLock(),
|
|
@@ -655,16 +662,12 @@ const screenshotOptions = (request: PageScreenshotRequest): BrowserRunScreenshot
|
|
|
655
662
|
};
|
|
656
663
|
|
|
657
664
|
const cancelBody = (body: ReadableStream<Uint8Array>): Effect.Effect<void> =>
|
|
658
|
-
|
|
659
|
-
try: () => body.cancel(),
|
|
660
|
-
catch: () => undefined,
|
|
661
|
-
}).pipe(Effect.catch(() => Effect.void));
|
|
665
|
+
cancelResponse(() => body.cancel(), "Canceling the screenshot response failed");
|
|
662
666
|
|
|
663
667
|
const releaseScreenshotReader = (
|
|
664
668
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
665
669
|
): Effect.Effect<void> =>
|
|
666
|
-
|
|
667
|
-
Effect.catch(() => Effect.logWarning("Canceling the screenshot response failed")),
|
|
670
|
+
cancelResponse(() => reader.cancel(), "Canceling the screenshot response failed").pipe(
|
|
668
671
|
Effect.ensuring(
|
|
669
672
|
Effect.try({
|
|
670
673
|
try: () => reader.releaseLock(),
|
package/src/internal/layers.ts
CHANGED
|
@@ -47,8 +47,8 @@ import {
|
|
|
47
47
|
type OperationAuthorizerService,
|
|
48
48
|
} from "@effect-agent/thread/OperationAuthorizer";
|
|
49
49
|
import { ProducerId } from "@effect-agent/thread/Records";
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
50
|
+
import { LedgerError, SubmissionLedger } from "@effect-agent/thread/SubmissionLedger";
|
|
51
|
+
import { ThreadStoreError, ThreadStore } from "@effect-agent/thread/ThreadStore";
|
|
52
52
|
import { ToolReconciler } from "@effect-agent/thread/ToolReconciler";
|
|
53
53
|
import { type WakeScheduler } from "@effect-agent/thread/WakeScheduler";
|
|
54
54
|
import { BrowserCrypto } from "@effect/platform-browser";
|
|
@@ -58,6 +58,9 @@ import { Context, Duration, Effect, Layer, Schema } from "effect";
|
|
|
58
58
|
|
|
59
59
|
import {
|
|
60
60
|
ThreadMaintenance,
|
|
61
|
+
ThreadMutationGate,
|
|
62
|
+
ThreadPublication,
|
|
63
|
+
publishCommitted,
|
|
61
64
|
ThreadMaintenanceFailpoint,
|
|
62
65
|
DurableAlarmService,
|
|
63
66
|
type ThreadMaintenanceFailpointHandler,
|
|
@@ -168,6 +171,8 @@ export type CloudflareDurableRuntimeServices =
|
|
|
168
171
|
| WakeScheduler
|
|
169
172
|
| DurableAlarmService
|
|
170
173
|
| ThreadMaintenance
|
|
174
|
+
| ThreadMutationGate
|
|
175
|
+
| ThreadPublication
|
|
171
176
|
| ThreadObjectPorts
|
|
172
177
|
| ProgressWaitRegistry;
|
|
173
178
|
|
|
@@ -311,15 +316,33 @@ export const layerConfig = (
|
|
|
311
316
|
}),
|
|
312
317
|
);
|
|
313
318
|
|
|
319
|
+
export interface ThreadPublicationOptions<E = never, R = never> {
|
|
320
|
+
/**
|
|
321
|
+
* Optional host outbox consumer, built once per incarnation with RAW LOCAL ThreadStore and
|
|
322
|
+
* SubmissionLedger services. Yield DurableObjectContext and ThreadObjectIdentity for native
|
|
323
|
+
* bindings and identity. Initialization is local-only, inside the constructor gate; setup
|
|
324
|
+
* errors and additional requirements remain in the returned Layer. Layer.effect owns Scope.
|
|
325
|
+
* Canonical appends and durable approval, abort and unknown-resolution intents invalidate
|
|
326
|
+
* publication after commit. Custom host facts must use ThreadMaintenance.withMutation.
|
|
327
|
+
*/
|
|
328
|
+
readonly publication?: Layer.Layer<ThreadPublication, E, R>;
|
|
329
|
+
}
|
|
330
|
+
|
|
314
331
|
/**
|
|
315
332
|
* Register typed Agents and version declarations. Hashing and dependency capture happen in
|
|
316
333
|
* this Layer's Scope, after application Layers have been provided. Every Agent's instruction,
|
|
317
334
|
* Tool, Schema, and model requirements remain visible until satisfied by Layer composition.
|
|
318
335
|
* Use Layer.unwrap for registration values that need effectful application setup.
|
|
319
336
|
*/
|
|
320
|
-
export const layer = <const Entries extends ReadonlyArray<AgentRegistration
|
|
337
|
+
export const layer = <const Entries extends ReadonlyArray<AgentRegistration>, E = never, R = never>(
|
|
321
338
|
registrations: Entries,
|
|
322
|
-
|
|
339
|
+
options: ThreadPublicationOptions<E, R> = {},
|
|
340
|
+
) =>
|
|
341
|
+
Layer.unwrap(
|
|
342
|
+
Effect.map(compileRegistrations(registrations), (bindings) =>
|
|
343
|
+
layerFromBindings(bindings, options),
|
|
344
|
+
),
|
|
345
|
+
);
|
|
323
346
|
|
|
324
347
|
/**
|
|
325
348
|
* Assemble the durable runtime from already-resolved Agent Bindings.
|
|
@@ -327,12 +350,16 @@ export const layer = <const Entries extends ReadonlyArray<AgentRegistration>>(
|
|
|
327
350
|
* Supply host services through `ThreadObject.make` or `ThreadObject.layerConfig` and
|
|
328
351
|
* the Durable Object context and namespace Layers when composing a custom host.
|
|
329
352
|
*/
|
|
330
|
-
export const layerFromBindings = (
|
|
353
|
+
export const layerFromBindings = <E = never, R = never>(
|
|
331
354
|
bindings: ReadonlyArray<ResolvedBinding>,
|
|
355
|
+
options: ThreadPublicationOptions<E, R> = {},
|
|
332
356
|
): Layer.Layer<
|
|
333
357
|
CloudflareDurableRuntimeServices,
|
|
334
|
-
DoStorageInitializationError,
|
|
335
|
-
|
|
358
|
+
DoStorageInitializationError | E,
|
|
359
|
+
| DurableObjectContext
|
|
360
|
+
| ThreadObjectNamespace
|
|
361
|
+
| CloudflareBootstrapServices
|
|
362
|
+
| Exclude<R, ThreadStore | SubmissionLedger>
|
|
336
363
|
> =>
|
|
337
364
|
Layer.unwrap(
|
|
338
365
|
Effect.gen(function* () {
|
|
@@ -355,10 +382,67 @@ export const layerFromBindings = (
|
|
|
355
382
|
|
|
356
383
|
// The same local ports serve routed decorators and owner-side RPC execution.
|
|
357
384
|
// The RPC executor must never receive routed ports and bounce requests between Objects.
|
|
358
|
-
const
|
|
385
|
+
const rawLocalPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
|
|
359
386
|
Layer.provide(infrastructure),
|
|
360
387
|
);
|
|
361
388
|
|
|
389
|
+
const publication = (options.publication ?? ThreadPublication.layer).pipe(
|
|
390
|
+
Layer.provide(rawLocalPorts),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const localPorts =
|
|
394
|
+
options.publication === undefined
|
|
395
|
+
? rawLocalPorts
|
|
396
|
+
: Layer.effectContext(
|
|
397
|
+
Effect.gen(function* () {
|
|
398
|
+
const store = yield* ThreadStore;
|
|
399
|
+
const ledger = yield* SubmissionLedger;
|
|
400
|
+
const mutations = yield* ThreadMutationGate;
|
|
401
|
+
const publish = yield* Effect.context<ThreadPublication>();
|
|
402
|
+
const afterCommit = publishCommitted.pipe(Effect.provide(publish));
|
|
403
|
+
|
|
404
|
+
// Every runtime-owned producer prearms too: a crash between commit and invalidation
|
|
405
|
+
// leaves a NEW, uncertified generation. Source errors keep their native port types.
|
|
406
|
+
const observedStore = ThreadStore.of({
|
|
407
|
+
...store,
|
|
408
|
+
append: (request) =>
|
|
409
|
+
mutations
|
|
410
|
+
.withMutation(store.append(request).pipe(Effect.tap(() => afterCommit)))
|
|
411
|
+
.pipe(
|
|
412
|
+
Effect.catchTag("DurableAlarmError", (cause) =>
|
|
413
|
+
ThreadStoreError.make({
|
|
414
|
+
operation: "prearm publication append",
|
|
415
|
+
message: cause.message,
|
|
416
|
+
cause,
|
|
417
|
+
}),
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const observeIntent = <A, Failure>(body: Effect.Effect<A, Failure>) =>
|
|
423
|
+
mutations.withMutation(body.pipe(Effect.tap(() => afterCommit))).pipe(
|
|
424
|
+
Effect.catchTag("DurableAlarmError", (cause) =>
|
|
425
|
+
LedgerError.make({
|
|
426
|
+
operation: "prearm publication intent",
|
|
427
|
+
message: "The publication generation could not be armed",
|
|
428
|
+
cause,
|
|
429
|
+
}),
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
return Context.make(ThreadStore, observedStore).pipe(
|
|
434
|
+
Context.add(SubmissionLedger, {
|
|
435
|
+
...ledger,
|
|
436
|
+
recordApprovalDecision: (request) =>
|
|
437
|
+
observeIntent(ledger.recordApprovalDecision(request)),
|
|
438
|
+
requestAbort: (request) => observeIntent(ledger.requestAbort(request)),
|
|
439
|
+
recordUnknownResolution: (request) =>
|
|
440
|
+
observeIntent(ledger.recordUnknownResolution(request)),
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
}),
|
|
444
|
+
).pipe(Layer.provide(rawLocalPorts));
|
|
445
|
+
|
|
362
446
|
const portsEndpointLayer = Layer.effect(ThreadObjectPorts)(
|
|
363
447
|
Effect.gen(function* () {
|
|
364
448
|
const local = yield* Effect.context<SubmissionLedger | ThreadStore>();
|
|
@@ -386,6 +470,6 @@ export const layerFromBindings = (
|
|
|
386
470
|
runtimeStack,
|
|
387
471
|
ThreadMaintenance.layer.pipe(Layer.provide(runtimeStack)),
|
|
388
472
|
portsEndpointLayer,
|
|
389
|
-
);
|
|
473
|
+
).pipe(Layer.provideMerge(publication), Layer.provideMerge(ThreadMutationGate.layer));
|
|
390
474
|
}),
|
|
391
475
|
);
|