@runuai/host 0.9.149 → 0.9.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1847 -236
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1062 -140
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,1752 @@
1
+ /**
2
+ * ADR-154 task-keyed project-environment replica.
3
+ *
4
+ * Unlike the legacy sidecar replica, this authority is deliberately
5
+ * in-memory. Each task owns its reader, staging view, live values, delivery
6
+ * cursor and gate. Nothing in this module imports the host database or writes
7
+ * a secret to disk.
8
+ */
9
+
10
+ import {
11
+ MAX_PROJECT_ENV_ACK_PAGE_BYTES,
12
+ isHostOpId,
13
+ isProjectEnvPolicyEpochs,
14
+ isProjectEnvReplicaV1ApplyFrame,
15
+ isProjectEnvReplicaV1BeginFrame,
16
+ isProjectEnvReplicaV1CommitFrame,
17
+ isProjectEnvReplicaV1PageFrame,
18
+ sameProjectEnvHead,
19
+ type ProjectEnvDeliveryCursor,
20
+ type ProjectEnvReplicaBindingState,
21
+ type ProjectEnvReplicaMaterialization,
22
+ type ProjectEnvReplicaResult,
23
+ type ProjectEnvReplicaV1AckFrame,
24
+ type ProjectEnvReplicaV1ApplyFrame,
25
+ type ProjectEnvReplicaV1BeginFrame,
26
+ type ProjectEnvReplicaV1CommitFrame,
27
+ type ProjectEnvReplicaV1PageFrame,
28
+ } from "../src/protocol";
29
+ import { PROJECT_ENV_POLICY_EPOCHS } from "../src/project-env-policy";
30
+ import { RUNNER_BOOT_ID } from "./runner-boot";
31
+
32
+ export type ProjectEnvTaskReplicaFrame =
33
+ | ProjectEnvReplicaV1BeginFrame
34
+ | ProjectEnvReplicaV1PageFrame
35
+ | ProjectEnvReplicaV1ApplyFrame
36
+ | ProjectEnvReplicaV1CommitFrame;
37
+
38
+ /**
39
+ * The lease registry supplies one of these per task grant. Implementations
40
+ * must read through that task's project session and recheck their own lease
41
+ * fence after I/O. This replica independently rechecks its fence too.
42
+ */
43
+ export interface ProjectEnvTaskValueReader {
44
+ /** Exact raw v2 parameter envelope, or null when the named value is absent. */
45
+ readStaged(
46
+ projectId: string,
47
+ key: string,
48
+ stagingId: string,
49
+ ): Promise<string | null>;
50
+ /** Exact raw v1 envelope and observed SSM version, or null when absent. */
51
+ readLegacy(
52
+ projectId: string,
53
+ key: string,
54
+ version: number,
55
+ ): Promise<{ value: string; version: number } | null>;
56
+ }
57
+
58
+ export interface ProjectEnvTaskAssignment {
59
+ taskId: string;
60
+ runEpoch: number;
61
+ orgId: string;
62
+ reader: ProjectEnvTaskValueReader;
63
+ }
64
+
65
+ export interface ProjectEnvTaskReplicaStatus {
66
+ taskId: string;
67
+ runEpoch: number;
68
+ runnerBootId: string;
69
+ gateOpen: boolean;
70
+ /** This run stays fail-closed until a new run or runner process starts. */
71
+ restartRequired: boolean;
72
+ streamId: string | null;
73
+ attemptId: string | null;
74
+ appliedThrough: ProjectEnvDeliveryCursor | null;
75
+ projectIds: string[];
76
+ }
77
+
78
+ export interface ProjectEnvTaskReplicaSnapshotEntry {
79
+ state: ProjectEnvReplicaBindingState;
80
+ materialization: ProjectEnvReplicaMaterialization;
81
+ value: string | null;
82
+ }
83
+
84
+ export interface ProjectEnvTaskReplicaSnapshotProject {
85
+ projectId: string;
86
+ recordVersion: number;
87
+ entries: ProjectEnvTaskReplicaSnapshotEntry[];
88
+ }
89
+
90
+ export interface ProjectEnvTaskReplicaSnapshot {
91
+ taskId: string;
92
+ runEpoch: number;
93
+ runnerBootId: string;
94
+ appliedThrough: ProjectEnvDeliveryCursor;
95
+ projects: ProjectEnvTaskReplicaSnapshotProject[];
96
+ }
97
+
98
+ export interface ProjectEnvTaskReplicaManagerOptions {
99
+ runnerBootId?: string;
100
+ policyEpochs?: readonly number[];
101
+ /** Optional lower limits for tests or unusually constrained runners. */
102
+ replayLedgerLimits?: {
103
+ maxTaskEntries?: number;
104
+ maxTaskBytes?: number;
105
+ maxTotalEntries?: number;
106
+ maxTotalBytes?: number;
107
+ };
108
+ onChange?: (taskId: string) => void;
109
+ }
110
+
111
+ interface ReplicaEntry {
112
+ state: ProjectEnvReplicaBindingState;
113
+ materialization: ProjectEnvReplicaMaterialization;
114
+ value: string | null;
115
+ }
116
+
117
+ interface ReplicaProject {
118
+ recordVersion: number;
119
+ records: Map<string, ReplicaEntry>;
120
+ }
121
+
122
+ interface PageReceipt {
123
+ fingerprint: string;
124
+ after: string | null;
125
+ recordVersion: number;
126
+ results: ProjectEnvReplicaResult[];
127
+ complete: boolean;
128
+ appliedThrough: ProjectEnvDeliveryCursor;
129
+ }
130
+
131
+ interface StagedProject extends ReplicaProject {
132
+ started: boolean;
133
+ complete: boolean;
134
+ lastKey: string | null;
135
+ pages: Map<string, PageReceipt>;
136
+ }
137
+
138
+ interface SnapshotAttempt {
139
+ attemptId: string;
140
+ beginFingerprint: string;
141
+ projectIds: string[];
142
+ projects: Map<string, StagedProject>;
143
+ /** The grant reader it began under (its `readerGeneration`): every value
144
+ * it holds was read through it, or it is refused. */
145
+ readerGeneration: number;
146
+ }
147
+
148
+ type ApplyReceipt =
149
+ | {
150
+ fingerprint: string;
151
+ ok: true;
152
+ attemptId: string | null;
153
+ projectId: string;
154
+ recordVersion: number;
155
+ result: ProjectEnvReplicaResult;
156
+ appliedThrough: ProjectEnvDeliveryCursor;
157
+ }
158
+ | {
159
+ fingerprint: string;
160
+ ok: false;
161
+ attemptId: string | null;
162
+ code: string;
163
+ appliedThrough: ProjectEnvDeliveryCursor;
164
+ };
165
+
166
+ interface CompletedAttempt {
167
+ fingerprint: string;
168
+ highWater: ProjectEnvDeliveryCursor;
169
+ }
170
+
171
+ interface ReadyWaiter {
172
+ resolve: () => void;
173
+ reject: (error: Error) => void;
174
+ timer: NodeJS.Timeout;
175
+ }
176
+
177
+ interface TaskReplica {
178
+ taskId: string;
179
+ runEpoch: number;
180
+ orgId: string;
181
+ reader: ProjectEnvTaskValueReader;
182
+ /** Advanced each time a grant's reader replaces the reader: a number, so
183
+ * no earlier grant's reader (and its sessions) stays reachable here. */
184
+ readerGeneration: number;
185
+ /** The reader generation whose attempt produced the committed snapshot. */
186
+ snapshotReaderGeneration: number | null;
187
+ /** Advanced by synchronous lifecycle changes, fencing reads in flight. */
188
+ generation: number;
189
+ gateOpen: boolean;
190
+ streamId: string | null;
191
+ appliedSequence: number;
192
+ attempt: SnapshotAttempt | null;
193
+ projects: Map<string, ReplicaProject>;
194
+ retiredStreams: Set<string>;
195
+ retiredAttempts: Set<string>;
196
+ applyReceipts: Map<number, ApplyReceipt>;
197
+ applyReceiptBytes: Map<number, number>;
198
+ completedAttempts: Map<string, CompletedAttempt>;
199
+ replayLedgerEntries: number;
200
+ replayLedgerBytes: number;
201
+ restartRequired: boolean;
202
+ /** Materialized page/apply entries not yet installed into a task view. */
203
+ inFlightEntries: Set<ReplicaEntry>;
204
+ waiters: Set<ReadyWaiter>;
205
+ }
206
+
207
+ type FramePhase = ProjectEnvReplicaV1AckFrame["phase"];
208
+
209
+ const NULL_PAGE_CURSOR = "\0";
210
+ /**
211
+ * Replay receipts cannot be aged out while an assignment keeps serving. The
212
+ * wire contract has opaque stream/attempt ids and no acknowledgement watermark
213
+ * from the cloud, so an arbitrarily late retry can still be legitimate. These
214
+ * limits therefore fail the replica closed instead of evicting evidence. They
215
+ * count only the four cumulative replay ledgers below; live values and the one
216
+ * active snapshot are replica state, not replay metadata, and follow their own
217
+ * task/attempt lifetime.
218
+ */
219
+ /** Maximum replay-evidence records retained by one live task assignment. */
220
+ export const PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_ENTRIES = 16_384;
221
+ /** Maximum encoded replay-evidence bytes retained by one live assignment. */
222
+ export const PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_BYTES = 8 * 1_024 * 1_024;
223
+ /** Aggregate replay-evidence record bound for this runner process. */
224
+ export const PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_ENTRIES = 65_536;
225
+ /** Aggregate encoded replay-evidence byte bound for this runner process. */
226
+ export const PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_BYTES = 64 * 1_024 * 1_024;
227
+ const REPLAY_LEDGER_ENTRY_OVERHEAD_BYTES = 64;
228
+ const REPLAY_LEDGER_EXHAUSTED = "replica_restart_required";
229
+ /** A grant for the run was accepted and its reader is not active yet: retry
230
+ * the frame shortly. A snapshot read through the reader it replaces would
231
+ * answer for nothing. */
232
+ export const PROJECT_ENV_ACTIVATION_PENDING = "activation_pending";
233
+ /** The run's grant reader changed under this attempt, this read or the live
234
+ * snapshot this apply would change: begin a new attempt. */
235
+ export const PROJECT_ENV_READER_CHANGED = "reader_changed";
236
+ const CLOSED_READER: ProjectEnvTaskValueReader = {
237
+ readStaged: async () => {
238
+ throw new Error("task replica reader is closed");
239
+ },
240
+ readLegacy: async () => {
241
+ throw new Error("task replica reader is closed");
242
+ },
243
+ };
244
+
245
+ function canonicalJson(value: unknown): string {
246
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
247
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
248
+ const object = value as Record<string, unknown>;
249
+ return `{${Object.keys(object)
250
+ .sort()
251
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
252
+ .join(",")}}`;
253
+ }
254
+
255
+ function clone<T>(value: T): T {
256
+ return structuredClone(value);
257
+ }
258
+
259
+ function sameStrings(left: readonly string[], right: readonly string[]): boolean {
260
+ return (
261
+ left.length === right.length &&
262
+ left.every((value, index) => value === right[index])
263
+ );
264
+ }
265
+
266
+ function validRunEpoch(value: number): boolean {
267
+ return Number.isSafeInteger(value) && value >= 1;
268
+ }
269
+
270
+ function validCredentialId(value: string): boolean {
271
+ return (
272
+ value.length > 0 &&
273
+ value.length <= 128 &&
274
+ !/[\u0000-\u001f\u007f]/.test(value)
275
+ );
276
+ }
277
+
278
+ function validReplayLedgerLimit(value: number): boolean {
279
+ return Number.isSafeInteger(value) && value >= 1;
280
+ }
281
+
282
+ function replayLedgerEntryBytes(value: unknown): number {
283
+ return (
284
+ REPLAY_LEDGER_ENTRY_OVERHEAD_BYTES +
285
+ Buffer.byteLength(canonicalJson(value), "utf8")
286
+ );
287
+ }
288
+
289
+ function validAssignment(value: ProjectEnvTaskAssignment): boolean {
290
+ return (
291
+ isHostOpId(value.taskId) &&
292
+ validRunEpoch(value.runEpoch) &&
293
+ validCredentialId(value.orgId) &&
294
+ value.reader !== null &&
295
+ typeof value.reader === "object" &&
296
+ typeof value.reader.readStaged === "function" &&
297
+ typeof value.reader.readLegacy === "function"
298
+ );
299
+ }
300
+
301
+ function exactObject(
302
+ value: unknown,
303
+ keys: readonly string[],
304
+ ): value is Record<string, unknown> {
305
+ return (
306
+ value !== null &&
307
+ typeof value === "object" &&
308
+ !Array.isArray(value) &&
309
+ Object.keys(value).length === keys.length &&
310
+ keys.every((key) => Object.hasOwn(value, key))
311
+ );
312
+ }
313
+
314
+ function desiredMaterialization(
315
+ state: ProjectEnvReplicaBindingState,
316
+ policyEpochs: readonly number[],
317
+ ): ProjectEnvReplicaMaterialization {
318
+ if (state.head.tombstone) return { kind: "withheld", reason: "tombstone" };
319
+ if (state.policyEpoch === null || !policyEpochs.includes(state.policyEpoch)) {
320
+ return { kind: "withheld", reason: "policy_epoch_unknown" };
321
+ }
322
+ if (state.binding?.kind === "omit") {
323
+ return { kind: "withheld", reason: "binding_omits" };
324
+ }
325
+ if (state.binding?.kind === "withhold") {
326
+ return { kind: "withheld", reason: state.binding.reason };
327
+ }
328
+ return { kind: "unavailable", reason: "materialization_pending" };
329
+ }
330
+
331
+ function resultOf(entry: ReplicaEntry): ProjectEnvReplicaResult {
332
+ return {
333
+ state: clone(entry.state),
334
+ materialization: clone(entry.materialization),
335
+ } as ProjectEnvReplicaResult;
336
+ }
337
+
338
+ function scrubEntry(entry: ReplicaEntry): void {
339
+ entry.value = null;
340
+ }
341
+
342
+ function scrubProjects(projects: Map<string, ReplicaProject>): void {
343
+ for (const project of projects.values()) {
344
+ for (const entry of project.records.values()) scrubEntry(entry);
345
+ project.records.clear();
346
+ }
347
+ projects.clear();
348
+ }
349
+
350
+ function scrubAttempt(attempt: SnapshotAttempt | null): void {
351
+ if (attempt === null) return;
352
+ scrubProjects(attempt.projects);
353
+ }
354
+
355
+ function stateOrder(
356
+ incoming: ProjectEnvReplicaBindingState,
357
+ current: ProjectEnvReplicaBindingState,
358
+ ): "same" | "newer" | "older" | "conflict" {
359
+ const revisionDelta = incoming.head.revision - current.head.revision;
360
+ const generationDelta = incoming.head.generation - current.head.generation;
361
+ if (revisionDelta < 0 && generationDelta <= 0) return "older";
362
+ if (generationDelta < 0 && revisionDelta <= 0) return "older";
363
+ if (revisionDelta < 0 || generationDelta < 0) return "conflict";
364
+ if (revisionDelta === 0 && generationDelta === 0) {
365
+ if (!sameProjectEnvHead(incoming.head, current.head)) return "conflict";
366
+ if (
367
+ canonicalJson(incoming.binding) !== canonicalJson(current.binding) ||
368
+ incoming.policyEpoch !== current.policyEpoch
369
+ ) {
370
+ return "conflict";
371
+ }
372
+ if (incoming.listingGeneration < current.listingGeneration) return "older";
373
+ if (incoming.listingGeneration === current.listingGeneration) {
374
+ return canonicalJson(incoming) === canonicalJson(current)
375
+ ? "same"
376
+ : "conflict";
377
+ }
378
+ return "newer";
379
+ }
380
+ if (incoming.listingGeneration < current.listingGeneration) return "conflict";
381
+ if (
382
+ revisionDelta === 0 &&
383
+ (incoming.head.tombstone !== current.head.tombstone ||
384
+ canonicalJson(incoming.binding) !== canonicalJson(current.binding) ||
385
+ incoming.policyEpoch !== current.policyEpoch)
386
+ ) {
387
+ return "conflict";
388
+ }
389
+ if (
390
+ generationDelta === 0 &&
391
+ (incoming.head.tombstone !== current.head.tombstone ||
392
+ canonicalJson(incoming.head.valueRef) !==
393
+ canonicalJson(current.head.valueRef))
394
+ ) {
395
+ return "conflict";
396
+ }
397
+ return "newer";
398
+ }
399
+
400
+ function isReachableRecordAdvance(
401
+ incoming: ProjectEnvReplicaBindingState,
402
+ current: ProjectEnvReplicaBindingState | null,
403
+ ): boolean {
404
+ if (current === null) {
405
+ return (
406
+ (incoming.head.revision === 1 &&
407
+ incoming.head.generation === 1 &&
408
+ !incoming.head.tombstone) ||
409
+ (incoming.head.revision === 1 &&
410
+ incoming.head.generation === 0 &&
411
+ incoming.head.tombstone)
412
+ );
413
+ }
414
+ const revision = incoming.head.revision - current.head.revision;
415
+ const generation = incoming.head.generation - current.head.generation;
416
+ if (incoming.listingGeneration < current.listingGeneration) return false;
417
+ if (revision === 0 && generation === 0) {
418
+ return (
419
+ sameProjectEnvHead(incoming.head, current.head) &&
420
+ canonicalJson(incoming.binding) === canonicalJson(current.binding) &&
421
+ incoming.policyEpoch === current.policyEpoch &&
422
+ incoming.listingGeneration > current.listingGeneration
423
+ );
424
+ }
425
+ if (revision === 0 && generation === 1) {
426
+ return (
427
+ !current.head.tombstone &&
428
+ !incoming.head.tombstone &&
429
+ canonicalJson(incoming.binding) === canonicalJson(current.binding) &&
430
+ incoming.policyEpoch === current.policyEpoch
431
+ );
432
+ }
433
+ if (revision === 1 && generation === 0) {
434
+ return (
435
+ !current.head.tombstone &&
436
+ !incoming.head.tombstone &&
437
+ canonicalJson(incoming.head.valueRef) ===
438
+ canonicalJson(current.head.valueRef)
439
+ );
440
+ }
441
+ if (revision === 1 && generation === 1) {
442
+ if (current.head.generation === 0) {
443
+ return current.head.tombstone && !incoming.head.tombstone;
444
+ }
445
+ return (
446
+ current.head.tombstone !== incoming.head.tombstone &&
447
+ canonicalJson(incoming.binding) === canonicalJson(current.binding) &&
448
+ incoming.policyEpoch === current.policyEpoch
449
+ );
450
+ }
451
+ return false;
452
+ }
453
+
454
+ function parseStagedEnvelope(
455
+ raw: string,
456
+ orgId: string,
457
+ projectId: string,
458
+ key: string,
459
+ stagingId: string,
460
+ ): string | null {
461
+ const parsed: unknown = JSON.parse(raw);
462
+ if (
463
+ !exactObject(parsed, [
464
+ "v",
465
+ "kind",
466
+ "orgId",
467
+ "projectId",
468
+ "key",
469
+ "stagingId",
470
+ "value",
471
+ ]) ||
472
+ parsed.v !== 2 ||
473
+ parsed.kind !== "project-env" ||
474
+ parsed.orgId !== orgId ||
475
+ parsed.projectId !== projectId ||
476
+ parsed.key !== key ||
477
+ parsed.stagingId !== stagingId ||
478
+ typeof parsed.value !== "string"
479
+ ) {
480
+ return null;
481
+ }
482
+ return parsed.value;
483
+ }
484
+
485
+ function parseLegacyEnvelope(
486
+ raw: string,
487
+ orgId: string,
488
+ projectId: string,
489
+ key: string,
490
+ ): string | null {
491
+ const parsed: unknown = JSON.parse(raw);
492
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
493
+ return null;
494
+ }
495
+ const object = parsed as Record<string, unknown>;
496
+ const keys = Object.keys(object);
497
+ const required = ["v", "kind", "projectId", "key", "value", "updatedAt"];
498
+ if (
499
+ required.some((field) => !Object.hasOwn(object, field)) ||
500
+ keys.some((field) => !required.includes(field) && field !== "orgId") ||
501
+ object.v !== 1 ||
502
+ object.kind !== "project-env" ||
503
+ object.projectId !== projectId ||
504
+ object.key !== key ||
505
+ typeof object.value !== "string" ||
506
+ typeof object.updatedAt !== "number" ||
507
+ !Number.isFinite(object.updatedAt) ||
508
+ !(object.orgId === undefined || object.orgId === orgId)
509
+ ) {
510
+ return null;
511
+ }
512
+ return object.value;
513
+ }
514
+
515
+ function framePhase(frame: ProjectEnvTaskReplicaFrame): FramePhase {
516
+ switch (frame.kind) {
517
+ case "env.replica.v1.begin":
518
+ return "begun";
519
+ case "env.replica.v1.page":
520
+ return "page";
521
+ case "env.replica.v1.apply":
522
+ return "applied";
523
+ case "env.replica.v1.commit":
524
+ return "complete";
525
+ }
526
+ }
527
+
528
+ /**
529
+ * One process-local registry. Constructing a replacement with a new boot id
530
+ * starts empty; a WebSocket reconnect keeps using the same instance.
531
+ */
532
+ export class ProjectEnvTaskReplicaManager {
533
+ readonly runnerBootId: string;
534
+ readonly policyEpochs: readonly number[];
535
+
536
+ readonly #tasks = new Map<string, TaskReplica>();
537
+ /** Non-secret same-boot floor: cleanup cannot make an old grant current. */
538
+ readonly #runEpochFloors = new Map<string, number>();
539
+ /** A ledger-exhausted run cannot be recreated under the same boot fence. */
540
+ readonly #restartRequiredRuns = new Map<string, number>();
541
+ readonly #lanes = new Map<string, Promise<void>>();
542
+ /** Grants accepted and being installed whose reader is not active yet, by
543
+ * task (each grant's token, and its run): the run's frames are refused,
544
+ * retryably, while any is pending. */
545
+ readonly #pendingActivations = new Map<string, Map<number, number>>();
546
+ #activationSequence = 0;
547
+ readonly #onChange: (taskId: string) => void;
548
+ readonly #replayLedgerMaxTaskEntries: number;
549
+ readonly #replayLedgerMaxTaskBytes: number;
550
+ readonly #replayLedgerMaxTotalEntries: number;
551
+ readonly #replayLedgerMaxTotalBytes: number;
552
+ #replayLedgerEntries = 0;
553
+ #replayLedgerBytes = 0;
554
+
555
+ constructor(options: ProjectEnvTaskReplicaManagerOptions = {}) {
556
+ const runnerBootId = options.runnerBootId ?? RUNNER_BOOT_ID;
557
+ const policyEpochs = [...(options.policyEpochs ?? PROJECT_ENV_POLICY_EPOCHS)];
558
+ const replayLedgerMaxTaskEntries =
559
+ options.replayLedgerLimits?.maxTaskEntries ??
560
+ PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_ENTRIES;
561
+ const replayLedgerMaxTaskBytes =
562
+ options.replayLedgerLimits?.maxTaskBytes ??
563
+ PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_BYTES;
564
+ const replayLedgerMaxTotalEntries =
565
+ options.replayLedgerLimits?.maxTotalEntries ??
566
+ PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_ENTRIES;
567
+ const replayLedgerMaxTotalBytes =
568
+ options.replayLedgerLimits?.maxTotalBytes ??
569
+ PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_BYTES;
570
+ if (
571
+ !isHostOpId(runnerBootId) ||
572
+ !isProjectEnvPolicyEpochs(policyEpochs) ||
573
+ !validReplayLedgerLimit(replayLedgerMaxTaskEntries) ||
574
+ !validReplayLedgerLimit(replayLedgerMaxTaskBytes) ||
575
+ !validReplayLedgerLimit(replayLedgerMaxTotalEntries) ||
576
+ !validReplayLedgerLimit(replayLedgerMaxTotalBytes) ||
577
+ replayLedgerMaxTaskEntries > replayLedgerMaxTotalEntries ||
578
+ replayLedgerMaxTaskBytes > replayLedgerMaxTotalBytes
579
+ ) {
580
+ throw new Error("invalid task replica manager identity");
581
+ }
582
+ this.runnerBootId = runnerBootId;
583
+ this.policyEpochs = policyEpochs;
584
+ this.#replayLedgerMaxTaskEntries = replayLedgerMaxTaskEntries;
585
+ this.#replayLedgerMaxTaskBytes = replayLedgerMaxTaskBytes;
586
+ this.#replayLedgerMaxTotalEntries = replayLedgerMaxTotalEntries;
587
+ this.#replayLedgerMaxTotalBytes = replayLedgerMaxTotalBytes;
588
+ this.#onChange = options.onChange ?? (() => {});
589
+ }
590
+
591
+ /**
592
+ * Install or refresh the lease-owned reader for an assignment. Repeating
593
+ * the same assignment preserves its replica across a reconnect/renewal but
594
+ * fences any read still using the previous reader. A new run drops every
595
+ * plaintext reference before creating the closed replacement.
596
+ */
597
+ activateAssignment(
598
+ assignment: ProjectEnvTaskAssignment,
599
+ ): "created" | "retained" | "replaced" {
600
+ if (!validAssignment(assignment)) throw new Error("invalid task assignment");
601
+ const floor = this.#runEpochFloors.get(assignment.taskId) ?? 0;
602
+ if (assignment.runEpoch < floor) {
603
+ throw new Error("task assignment run epoch is stale");
604
+ }
605
+ const restartRequiredRun = this.#restartRequiredRuns.get(assignment.taskId);
606
+ if (restartRequiredRun === assignment.runEpoch) {
607
+ throw new Error("task replica requires a runner restart");
608
+ }
609
+ if (
610
+ restartRequiredRun !== undefined &&
611
+ assignment.runEpoch > restartRequiredRun
612
+ ) {
613
+ this.#restartRequiredRuns.delete(assignment.taskId);
614
+ }
615
+ const current = this.#tasks.get(assignment.taskId);
616
+ if (current?.runEpoch === assignment.runEpoch) {
617
+ if (current.orgId !== assignment.orgId) {
618
+ throw new Error("task assignment organization cannot change");
619
+ }
620
+ if (current.reader !== assignment.reader) {
621
+ current.reader = assignment.reader;
622
+ current.readerGeneration += 1;
623
+ current.generation += 1;
624
+ }
625
+ this.#activated(assignment);
626
+ return "retained";
627
+ }
628
+ if (current !== undefined) this.#destroyTask(current, "assignment replaced");
629
+ const task: TaskReplica = {
630
+ taskId: assignment.taskId,
631
+ runEpoch: assignment.runEpoch,
632
+ orgId: assignment.orgId,
633
+ reader: assignment.reader,
634
+ readerGeneration: 1,
635
+ snapshotReaderGeneration: null,
636
+ generation: 1,
637
+ gateOpen: false,
638
+ streamId: null,
639
+ appliedSequence: 0,
640
+ attempt: null,
641
+ projects: new Map(),
642
+ retiredStreams: new Set(),
643
+ retiredAttempts: new Set(),
644
+ applyReceipts: new Map(),
645
+ applyReceiptBytes: new Map(),
646
+ completedAttempts: new Map(),
647
+ replayLedgerEntries: 0,
648
+ replayLedgerBytes: 0,
649
+ restartRequired: false,
650
+ inFlightEntries: new Set(),
651
+ waiters: new Set(),
652
+ };
653
+ this.#runEpochFloors.set(task.taskId, task.runEpoch);
654
+ this.#tasks.set(task.taskId, task);
655
+ this.#activated(assignment);
656
+ this.#changed(task.taskId);
657
+ return current === undefined ? "created" : "replaced";
658
+ }
659
+
660
+ /** The run's grant reader is now active, and only now: its frames are
661
+ * taken from here. An activation that throws leaves them refused. */
662
+ #activated(assignment: ProjectEnvTaskAssignment): void {
663
+ const pending = this.#pendingActivations.get(assignment.taskId);
664
+ if (!pending) return;
665
+ for (const [sequence, runEpoch] of pending) {
666
+ if (runEpoch === assignment.runEpoch) pending.delete(sequence);
667
+ }
668
+ if (pending.size === 0) this.#pendingActivations.delete(assignment.taskId);
669
+ }
670
+
671
+ /**
672
+ * A grant for the run was accepted and is being installed: until its
673
+ * reader activates (`activateAssignment` succeeding), the run's frames are
674
+ * refused with `activation_pending`, for the cloud to retry, so that no
675
+ * snapshot is read through the reader the grant replaces. Returns what
676
+ * `settleActivation` takes, once the grant's handling is over.
677
+ */
678
+ expectActivation(taskId: string, runEpoch: number): number {
679
+ const sequence = ++this.#activationSequence;
680
+ const pending = this.#pendingActivations.get(taskId) ?? new Map<number, number>();
681
+ pending.set(sequence, runEpoch);
682
+ this.#pendingActivations.set(taskId, pending);
683
+ return sequence;
684
+ }
685
+
686
+ /** That grant's handling is over, its reader active or not: its token
687
+ * goes, and no other grant's. */
688
+ settleActivation(taskId: string, sequence: number): void {
689
+ const pending = this.#pendingActivations.get(taskId);
690
+ if (!pending) return;
691
+ pending.delete(sequence);
692
+ if (pending.size === 0) this.#pendingActivations.delete(taskId);
693
+ }
694
+
695
+ /** The run's grant reader generation now: what an install waits for a
696
+ * snapshot of, and what serving is bound to. */
697
+ readerGeneration(taskId: string, runEpoch: number): number | null {
698
+ const task = this.#tasks.get(taskId);
699
+ return task !== undefined && task.runEpoch === runEpoch ? task.readerGeneration : null;
700
+ }
701
+
702
+ /** A stale Stop/deny for an older run may not erase the current run. */
703
+ dropAssignment(taskId: string, runEpoch: number): boolean {
704
+ const task = this.#tasks.get(taskId);
705
+ if (task === undefined || task.runEpoch !== runEpoch) return false;
706
+ this.#tasks.delete(taskId);
707
+ this.#destroyTask(task, "task credentials were dropped");
708
+ this.#changed(taskId);
709
+ return true;
710
+ }
711
+
712
+ /** Process shutdown defense in depth; a new process starts with a new id. */
713
+ dropAll(): void {
714
+ const tasks = [...this.#tasks.values()];
715
+ this.#tasks.clear();
716
+ this.#pendingActivations.clear();
717
+ for (const task of tasks) {
718
+ this.#destroyTask(task, "runner process ended");
719
+ this.#changed(task.taskId);
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Serialized per task so page/apply order is deterministic even when a
725
+ * transport dispatcher calls this concurrently. Lifecycle drops remain
726
+ * synchronous and therefore can fence a read while it awaits AWS.
727
+ */
728
+ async handleFrame(value: unknown): Promise<ProjectEnvReplicaV1AckFrame> {
729
+ const frame = this.#parseFrame(value);
730
+ const previous = this.#lanes.get(frame.taskId) ?? Promise.resolve();
731
+ const operation = previous.catch(() => {}).then(() => this.#dispatch(frame));
732
+ const tail = operation.then(
733
+ () => {},
734
+ () => {},
735
+ );
736
+ this.#lanes.set(frame.taskId, tail);
737
+ void tail.finally(() => {
738
+ if (this.#lanes.get(frame.taskId) === tail) this.#lanes.delete(frame.taskId);
739
+ });
740
+ return operation;
741
+ }
742
+
743
+ readStatus(taskId: string): ProjectEnvTaskReplicaStatus | null {
744
+ const task = this.#tasks.get(taskId);
745
+ if (task === undefined) return null;
746
+ return {
747
+ taskId,
748
+ runEpoch: task.runEpoch,
749
+ runnerBootId: this.runnerBootId,
750
+ gateOpen: task.gateOpen,
751
+ restartRequired: task.restartRequired,
752
+ streamId: task.streamId,
753
+ attemptId: task.attempt?.attemptId ?? null,
754
+ appliedThrough:
755
+ task.streamId === null
756
+ ? null
757
+ : { streamId: task.streamId, sequence: task.appliedSequence },
758
+ projectIds: [...task.projects.keys()].sort(),
759
+ };
760
+ }
761
+
762
+ isReady(taskId: string, runEpoch: number): boolean {
763
+ const task = this.#tasks.get(taskId);
764
+ return Boolean(
765
+ task &&
766
+ task.runEpoch === runEpoch &&
767
+ task.gateOpen &&
768
+ task.attempt === null &&
769
+ task.streamId !== null,
770
+ );
771
+ }
772
+
773
+ awaitReady(taskId: string, runEpoch: number, timeoutMs: number): Promise<void> {
774
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
775
+ return Promise.reject(new Error("invalid task replica readiness timeout"));
776
+ }
777
+ const task = this.#tasks.get(taskId);
778
+ if (task === undefined || task.runEpoch !== runEpoch) {
779
+ return Promise.reject(new Error("task replica assignment is unavailable"));
780
+ }
781
+ if (task.restartRequired) {
782
+ return Promise.reject(new Error("task replica requires a runner restart"));
783
+ }
784
+ if (this.isReady(taskId, runEpoch)) return Promise.resolve();
785
+ return new Promise<void>((resolve, reject) => {
786
+ const waiter: ReadyWaiter = {
787
+ resolve: () => {
788
+ clearTimeout(waiter.timer);
789
+ task.waiters.delete(waiter);
790
+ resolve();
791
+ },
792
+ reject: (error) => {
793
+ clearTimeout(waiter.timer);
794
+ task.waiters.delete(waiter);
795
+ reject(error);
796
+ },
797
+ timer: setTimeout(() => {
798
+ waiter.reject(
799
+ new Error("task environment replica did not become ready in time"),
800
+ );
801
+ }, timeoutMs),
802
+ };
803
+ waiter.timer.unref?.();
804
+ task.waiters.add(waiter);
805
+ if (this.isReady(taskId, runEpoch)) waiter.resolve();
806
+ });
807
+ }
808
+
809
+ /**
810
+ * Fail closed unless the caller names the current assignment and exactly
811
+ * the committed project set. The caller's order is retained for env merge
812
+ * precedence, while set comparison remains canonical.
813
+ */
814
+ readView(
815
+ taskId: string,
816
+ runEpoch: number,
817
+ projectIds: readonly string[],
818
+ ): ProjectEnvTaskReplicaSnapshot | null {
819
+ const task = this.#tasks.get(taskId);
820
+ if (
821
+ task === undefined ||
822
+ task.runEpoch !== runEpoch ||
823
+ !task.gateOpen ||
824
+ task.attempt !== null ||
825
+ task.streamId === null
826
+ ) {
827
+ return null;
828
+ }
829
+ const requested = [...projectIds];
830
+ const canonical = [...new Set(requested)].sort();
831
+ const actual = [...task.projects.keys()].sort();
832
+ if (canonical.length !== requested.length || !sameStrings(canonical, actual)) {
833
+ return null;
834
+ }
835
+ return {
836
+ taskId,
837
+ runEpoch,
838
+ runnerBootId: this.runnerBootId,
839
+ appliedThrough: {
840
+ streamId: task.streamId,
841
+ sequence: task.appliedSequence,
842
+ },
843
+ projects: requested.map((projectId) => {
844
+ const project = task.projects.get(projectId)!;
845
+ return {
846
+ projectId,
847
+ recordVersion: project.recordVersion,
848
+ entries: [...project.records.values()]
849
+ .sort((left, right) => left.state.key.localeCompare(right.state.key))
850
+ .map((entry) => ({
851
+ state: clone(entry.state),
852
+ materialization: clone(entry.materialization),
853
+ value: entry.value,
854
+ })),
855
+ };
856
+ }),
857
+ };
858
+ }
859
+
860
+ /**
861
+ * `readView`, for a snapshot every value of which was read through the
862
+ * reader of `readerGeneration` (one grant's): a snapshot another grant's
863
+ * reader produced never answers for this grant, nor is served for it.
864
+ */
865
+ readViewAt(
866
+ taskId: string,
867
+ runEpoch: number,
868
+ projectIds: readonly string[],
869
+ readerGeneration: number,
870
+ ): ProjectEnvTaskReplicaSnapshot | null {
871
+ const task = this.#tasks.get(taskId);
872
+ if (task === undefined || task.snapshotReaderGeneration !== readerGeneration) return null;
873
+ return this.readView(taskId, runEpoch, projectIds);
874
+ }
875
+
876
+ #parseFrame(value: unknown): ProjectEnvTaskReplicaFrame {
877
+ if (isProjectEnvReplicaV1BeginFrame(value)) return clone(value);
878
+ if (isProjectEnvReplicaV1PageFrame(value)) return clone(value);
879
+ if (isProjectEnvReplicaV1ApplyFrame(value)) return clone(value);
880
+ if (isProjectEnvReplicaV1CommitFrame(value)) return clone(value);
881
+ throw new Error("invalid task project environment replica frame");
882
+ }
883
+
884
+ async #dispatch(
885
+ frame: ProjectEnvTaskReplicaFrame,
886
+ ): Promise<ProjectEnvReplicaV1AckFrame> {
887
+ const task = this.#tasks.get(frame.taskId);
888
+ const identityError = this.#identityError(task, frame);
889
+ if (identityError !== null) return this.#errorAck(frame, task, identityError);
890
+ if (task!.restartRequired) {
891
+ return this.#errorAck(frame, task, REPLAY_LEDGER_EXHAUSTED);
892
+ }
893
+ switch (frame.kind) {
894
+ case "env.replica.v1.begin":
895
+ return this.#begin(task!, frame);
896
+ case "env.replica.v1.page":
897
+ return this.#page(task!, frame);
898
+ case "env.replica.v1.apply":
899
+ return this.#apply(task!, frame);
900
+ case "env.replica.v1.commit":
901
+ return this.#commit(task!, frame);
902
+ }
903
+ }
904
+
905
+ #identityError(
906
+ task: TaskReplica | undefined,
907
+ frame: ProjectEnvTaskReplicaFrame,
908
+ ): string | null {
909
+ if (frame.runnerBootId !== this.runnerBootId) return "runner_boot_mismatch";
910
+ for (const runEpoch of this.#pendingActivations.get(frame.taskId)?.values() ?? []) {
911
+ if (runEpoch === frame.runEpoch) return PROJECT_ENV_ACTIVATION_PENDING;
912
+ }
913
+ if (task === undefined || task.runEpoch !== frame.runEpoch) {
914
+ return "task_assignment_mismatch";
915
+ }
916
+ return null;
917
+ }
918
+
919
+ #begin(
920
+ task: TaskReplica,
921
+ frame: ProjectEnvReplicaV1BeginFrame,
922
+ ): ProjectEnvReplicaV1AckFrame {
923
+ const fingerprint = canonicalJson({
924
+ streamId: frame.streamId,
925
+ attemptId: frame.attemptId,
926
+ projectIds: frame.projectIds,
927
+ });
928
+ if (task.retiredStreams.has(frame.streamId)) {
929
+ return this.#errorAck(frame, task, "stream_retired");
930
+ }
931
+ if (task.retiredAttempts.has(frame.attemptId)) {
932
+ return this.#errorAck(frame, task, "reconciliation_attempt_retired");
933
+ }
934
+ if (task.streamId === frame.streamId && task.attempt !== null) {
935
+ if (
936
+ task.attempt.attemptId === frame.attemptId &&
937
+ task.attempt.beginFingerprint === fingerprint
938
+ ) {
939
+ return this.#successBegin(frame, task);
940
+ }
941
+ if (task.attempt.attemptId === frame.attemptId) {
942
+ return this.#errorAck(frame, task, "reconciliation_attempt_conflict");
943
+ }
944
+ }
945
+ if (task.streamId !== null && task.streamId !== frame.streamId) {
946
+ // Receipts belong to the old ordered stream. Once a different stream's
947
+ // begin arrives, the retired stream id is the evidence that prevents a
948
+ // delayed old begin from becoming current again.
949
+ this.#clearApplyReceipts(task);
950
+ if (!this.#rememberRetiredStream(task, task.streamId)) {
951
+ return this.#exhaustReplayLedger(task, frame);
952
+ }
953
+ task.appliedSequence = 0;
954
+ }
955
+ if (task.attempt !== null) {
956
+ if (!this.#rememberRetiredAttempt(task, task.attempt.attemptId)) {
957
+ return this.#exhaustReplayLedger(task, frame);
958
+ }
959
+ }
960
+ scrubAttempt(task.attempt);
961
+ task.streamId = frame.streamId;
962
+ task.gateOpen = false;
963
+ task.generation += 1;
964
+ task.attempt = {
965
+ attemptId: frame.attemptId,
966
+ beginFingerprint: fingerprint,
967
+ readerGeneration: task.readerGeneration,
968
+ projectIds: [...frame.projectIds],
969
+ projects: new Map(
970
+ frame.projectIds.map((projectId) => [
971
+ projectId,
972
+ {
973
+ recordVersion: 0,
974
+ records: new Map(),
975
+ started: false,
976
+ complete: false,
977
+ lastKey: null,
978
+ pages: new Map(),
979
+ },
980
+ ]),
981
+ ),
982
+ };
983
+ this.#changed(task.taskId);
984
+ return this.#successBegin(frame, task);
985
+ }
986
+
987
+ async #page(
988
+ task: TaskReplica,
989
+ frame: ProjectEnvReplicaV1PageFrame,
990
+ ): Promise<ProjectEnvReplicaV1AckFrame> {
991
+ const attempt = task.attempt;
992
+ if (
993
+ task.streamId !== frame.streamId ||
994
+ attempt === null ||
995
+ attempt.attemptId !== frame.attemptId ||
996
+ task.gateOpen
997
+ ) {
998
+ return this.#errorAck(frame, task, "reconciliation_attempt_mismatch");
999
+ }
1000
+ if (attempt.readerGeneration !== task.readerGeneration) {
1001
+ return this.#errorAck(frame, task, PROJECT_ENV_READER_CHANGED);
1002
+ }
1003
+ const project = attempt.projects.get(frame.projectId);
1004
+ if (project === undefined) {
1005
+ return this.#errorAck(frame, task, "project_not_in_attempt");
1006
+ }
1007
+ const pageKey = frame.after ?? NULL_PAGE_CURSOR;
1008
+ const fingerprint = canonicalJson({
1009
+ projectId: frame.projectId,
1010
+ after: frame.after,
1011
+ recordVersion: frame.recordVersion,
1012
+ records: frame.records,
1013
+ complete: frame.complete,
1014
+ });
1015
+ const receipt = project.pages.get(pageKey);
1016
+ if (receipt !== undefined) {
1017
+ if (receipt.fingerprint !== fingerprint) {
1018
+ return this.#errorAck(frame, task, "snapshot_page_conflict");
1019
+ }
1020
+ return this.#successPage(frame, receipt, task);
1021
+ }
1022
+ if (
1023
+ (frame.after === null && project.started) ||
1024
+ (frame.after !== null &&
1025
+ (!project.started || project.complete || project.lastKey !== frame.after))
1026
+ ) {
1027
+ return this.#errorAck(frame, task, "snapshot_page_mismatch");
1028
+ }
1029
+ if (project.started && project.recordVersion !== frame.recordVersion) {
1030
+ return this.#errorAck(frame, task, "snapshot_version_conflict");
1031
+ }
1032
+ const liveProject = task.projects.get(frame.projectId);
1033
+ if (
1034
+ !project.started &&
1035
+ liveProject !== undefined &&
1036
+ frame.recordVersion < liveProject.recordVersion
1037
+ ) {
1038
+ return this.#errorAck(frame, task, "record_version_stale");
1039
+ }
1040
+ if (
1041
+ liveProject !== undefined &&
1042
+ frame.recordVersion === liveProject.recordVersion
1043
+ ) {
1044
+ const conflicts = frame.records.some((state) => {
1045
+ const live = liveProject.records.get(state.key);
1046
+ return live === undefined || canonicalJson(live.state) !== canonicalJson(state);
1047
+ });
1048
+ const prospectiveKeys = new Set([
1049
+ ...project.records.keys(),
1050
+ ...frame.records.map((state) => state.key),
1051
+ ]);
1052
+ if (
1053
+ conflicts ||
1054
+ (frame.complete &&
1055
+ (prospectiveKeys.size !== liveProject.records.size ||
1056
+ [...prospectiveKeys].some((key) => !liveProject.records.has(key))))
1057
+ ) {
1058
+ return this.#errorAck(frame, task, "snapshot_version_conflict");
1059
+ }
1060
+ }
1061
+
1062
+ const generation = task.generation;
1063
+ const reader = task.reader;
1064
+ const entries: ReplicaEntry[] = [];
1065
+ for (const state of frame.records) {
1066
+ const live = liveProject?.records.get(state.key);
1067
+ // Plaintext the live snapshot holds is kept only when the same reader
1068
+ // read it: a newer grant reads every value again, through itself.
1069
+ const mayKeepInstalled =
1070
+ live !== undefined &&
1071
+ task.snapshotReaderGeneration === attempt.readerGeneration &&
1072
+ live.materialization.kind === "installed" &&
1073
+ canonicalJson(live.state) === canonicalJson(state) &&
1074
+ desiredMaterialization(state, this.policyEpochs).kind !== "withheld";
1075
+ const entry = mayKeepInstalled
1076
+ ? {
1077
+ state: clone(live.state),
1078
+ materialization: clone(live.materialization),
1079
+ value: live.value,
1080
+ }
1081
+ : await this.#materialize(task, frame.projectId, state, reader);
1082
+ task.inFlightEntries.add(entry);
1083
+ if (!this.#readFenceCurrent(task, frame, generation, reader, attempt)) {
1084
+ for (const candidate of entries) {
1085
+ scrubEntry(candidate);
1086
+ task.inFlightEntries.delete(candidate);
1087
+ }
1088
+ scrubEntry(entry);
1089
+ task.inFlightEntries.delete(entry);
1090
+ return this.#errorAck(frame, this.#tasks.get(frame.taskId), this.#fenceCode(task, attempt.readerGeneration));
1091
+ }
1092
+ entries.push(entry);
1093
+ }
1094
+ const results = entries.map(resultOf);
1095
+ const appliedThrough = this.#cursor(task, frame.streamId);
1096
+ const prospective = this.#successPage(
1097
+ frame,
1098
+ {
1099
+ fingerprint,
1100
+ after: frame.after,
1101
+ recordVersion: frame.recordVersion,
1102
+ results,
1103
+ complete: frame.complete,
1104
+ appliedThrough,
1105
+ },
1106
+ task,
1107
+ );
1108
+ if (
1109
+ Buffer.byteLength(JSON.stringify(prospective), "utf8") >
1110
+ MAX_PROJECT_ENV_ACK_PAGE_BYTES
1111
+ ) {
1112
+ for (const entry of entries) {
1113
+ scrubEntry(entry);
1114
+ task.inFlightEntries.delete(entry);
1115
+ }
1116
+ return this.#errorAck(frame, task, "ack_page_too_large");
1117
+ }
1118
+
1119
+ for (const entry of entries) {
1120
+ task.inFlightEntries.delete(entry);
1121
+ project.records.set(entry.state.key, entry);
1122
+ }
1123
+ project.started = true;
1124
+ project.recordVersion = frame.recordVersion;
1125
+ project.lastKey = frame.records.at(-1)?.key ?? null;
1126
+ project.complete = frame.complete;
1127
+ project.pages.set(pageKey, {
1128
+ fingerprint,
1129
+ after: frame.after,
1130
+ recordVersion: frame.recordVersion,
1131
+ results: clone(results),
1132
+ complete: frame.complete,
1133
+ appliedThrough,
1134
+ });
1135
+ return prospective;
1136
+ }
1137
+
1138
+ async #apply(
1139
+ task: TaskReplica,
1140
+ frame: ProjectEnvReplicaV1ApplyFrame,
1141
+ ): Promise<ProjectEnvReplicaV1AckFrame> {
1142
+ if (task.streamId !== frame.streamId) {
1143
+ return this.#errorAck(frame, task, "stream_mismatch");
1144
+ }
1145
+ const fingerprint = canonicalJson({
1146
+ attemptId: frame.attemptId,
1147
+ projectId: frame.projectId,
1148
+ recordVersion: frame.recordVersion,
1149
+ state: frame.state,
1150
+ delivery: frame.delivery,
1151
+ });
1152
+ if (frame.delivery.sequence <= task.appliedSequence) {
1153
+ const receipt = task.applyReceipts.get(frame.delivery.sequence);
1154
+ if (receipt === undefined) {
1155
+ return this.#errorAck(frame, task, "receipt_missing");
1156
+ }
1157
+ if (receipt.fingerprint !== fingerprint) {
1158
+ return this.#errorAck(frame, task, "delivery_conflict");
1159
+ }
1160
+ return this.#ackFromApplyReceipt(frame, receipt, task);
1161
+ }
1162
+ if (
1163
+ task.gateOpen
1164
+ ? frame.attemptId !== null || task.attempt !== null
1165
+ : task.attempt === null || frame.attemptId !== task.attempt.attemptId
1166
+ ) {
1167
+ return this.#errorAck(frame, task, "reconciliation_attempt_mismatch");
1168
+ }
1169
+ if (frame.delivery.sequence !== task.appliedSequence + 1) {
1170
+ return this.#errorAck(frame, task, "delivery_gap");
1171
+ }
1172
+ // Read through the run's current reader, into what that same reader
1173
+ // read: an attempt begun under it, or a live snapshot it produced.
1174
+ if (
1175
+ task.attempt === null
1176
+ ? task.snapshotReaderGeneration !== task.readerGeneration
1177
+ : task.attempt.readerGeneration !== task.readerGeneration
1178
+ ) {
1179
+ return this.#errorAck(frame, task, PROJECT_ENV_READER_CHANGED);
1180
+ }
1181
+
1182
+ const project = task.attempt === null
1183
+ ? task.projects.get(frame.projectId)
1184
+ : task.attempt.projects.get(frame.projectId);
1185
+ if (project === undefined) {
1186
+ return this.#consumeApplyError(
1187
+ task,
1188
+ frame,
1189
+ fingerprint,
1190
+ "project_not_in_replica",
1191
+ );
1192
+ }
1193
+ if ("complete" in project && !project.complete) {
1194
+ return this.#errorAck(frame, task, "snapshot_incomplete");
1195
+ }
1196
+ if (frame.recordVersion > project.recordVersion + 1) {
1197
+ return this.#errorAck(frame, task, "record_version_gap");
1198
+ }
1199
+ const current = project.records.get(frame.state.key) ?? null;
1200
+ const advances =
1201
+ frame.recordVersion === project.recordVersion + 1 &&
1202
+ (current === null || stateOrder(frame.state, current.state) === "newer") &&
1203
+ isReachableRecordAdvance(frame.state, current?.state ?? null);
1204
+ if (!advances && current === null) {
1205
+ return this.#consumeApplyError(
1206
+ task,
1207
+ frame,
1208
+ fingerprint,
1209
+ "record_version_conflict",
1210
+ );
1211
+ }
1212
+
1213
+ const desiredState = advances ? frame.state : current!.state;
1214
+ const generation = task.generation;
1215
+ const reader = task.reader;
1216
+ const readerGeneration = task.readerGeneration;
1217
+ const attempt = task.attempt;
1218
+ let entry: ReplicaEntry;
1219
+ if (
1220
+ !advances &&
1221
+ current!.materialization.kind === "installed" &&
1222
+ desiredMaterialization(current!.state, this.policyEpochs).kind !== "withheld"
1223
+ ) {
1224
+ entry = {
1225
+ state: clone(current!.state),
1226
+ materialization: clone(current!.materialization),
1227
+ value: current!.value,
1228
+ };
1229
+ } else {
1230
+ entry = await this.#materialize(task, frame.projectId, desiredState, reader);
1231
+ task.inFlightEntries.add(entry);
1232
+ if (!this.#readFenceCurrent(task, frame, generation, reader, attempt)) {
1233
+ scrubEntry(entry);
1234
+ task.inFlightEntries.delete(entry);
1235
+ return this.#errorAck(frame, this.#tasks.get(frame.taskId), this.#fenceCode(task, readerGeneration));
1236
+ }
1237
+ }
1238
+
1239
+ const installedRecordVersion = advances
1240
+ ? frame.recordVersion
1241
+ : project.recordVersion;
1242
+ const appliedThrough = clone(frame.delivery);
1243
+ const receipt: ApplyReceipt = {
1244
+ fingerprint,
1245
+ ok: true,
1246
+ attemptId: frame.attemptId,
1247
+ projectId: frame.projectId,
1248
+ recordVersion: installedRecordVersion,
1249
+ result: resultOf(entry),
1250
+ appliedThrough,
1251
+ };
1252
+ if (!this.#rememberApplyReceipt(task, frame.delivery.sequence, receipt)) {
1253
+ scrubEntry(entry);
1254
+ task.inFlightEntries.delete(entry);
1255
+ return this.#exhaustReplayLedger(task, frame);
1256
+ }
1257
+ task.inFlightEntries.delete(entry);
1258
+ if (current !== null && current !== entry) scrubEntry(current);
1259
+ project.records.set(entry.state.key, entry);
1260
+ project.recordVersion = installedRecordVersion;
1261
+ task.appliedSequence = frame.delivery.sequence;
1262
+ if (task.gateOpen) this.#changed(task.taskId);
1263
+ return this.#ackFromApplyReceipt(frame, receipt, task);
1264
+ }
1265
+
1266
+ #commit(
1267
+ task: TaskReplica,
1268
+ frame: ProjectEnvReplicaV1CommitFrame,
1269
+ ): ProjectEnvReplicaV1AckFrame {
1270
+ const fingerprint = canonicalJson({
1271
+ streamId: frame.streamId,
1272
+ attemptId: frame.attemptId,
1273
+ highWater: frame.highWater,
1274
+ versions: frame.versions,
1275
+ });
1276
+ const completed = task.completedAttempts.get(frame.attemptId);
1277
+ if (completed !== undefined) {
1278
+ if (completed.fingerprint !== fingerprint) {
1279
+ return this.#errorAck(frame, task, "reconciliation_attempt_conflict");
1280
+ }
1281
+ return this.#successCommit(frame, completed.highWater, task);
1282
+ }
1283
+ const attempt = task.attempt;
1284
+ if (
1285
+ task.streamId !== frame.streamId ||
1286
+ attempt === null ||
1287
+ attempt.attemptId !== frame.attemptId ||
1288
+ task.gateOpen
1289
+ ) {
1290
+ return this.#errorAck(frame, task, "reconciliation_attempt_mismatch");
1291
+ }
1292
+ if (attempt.readerGeneration !== task.readerGeneration) {
1293
+ return this.#errorAck(frame, task, PROJECT_ENV_READER_CHANGED);
1294
+ }
1295
+ if (frame.highWater.sequence !== task.appliedSequence) {
1296
+ return this.#errorAck(frame, task, "delivery_incomplete");
1297
+ }
1298
+ if (
1299
+ frame.versions.length !== attempt.projectIds.length ||
1300
+ frame.versions.some((version, index) => {
1301
+ const projectId = attempt.projectIds[index];
1302
+ const project = projectId === undefined
1303
+ ? undefined
1304
+ : attempt.projects.get(projectId);
1305
+ return (
1306
+ project === undefined ||
1307
+ version.projectId !== projectId ||
1308
+ !project.complete ||
1309
+ project.recordVersion !== version.recordVersion
1310
+ );
1311
+ })
1312
+ ) {
1313
+ return this.#errorAck(frame, task, "record_version_changed");
1314
+ }
1315
+
1316
+ const completedAttempt: CompletedAttempt = {
1317
+ fingerprint,
1318
+ highWater: clone(frame.highWater),
1319
+ };
1320
+ if (
1321
+ !this.#rememberCompletedAttempt(
1322
+ task,
1323
+ frame.attemptId,
1324
+ completedAttempt,
1325
+ ) ||
1326
+ !this.#rememberRetiredAttempt(task, frame.attemptId)
1327
+ ) {
1328
+ return this.#exhaustReplayLedger(task, frame);
1329
+ }
1330
+
1331
+ const oldProjects = task.projects;
1332
+ task.projects = new Map(
1333
+ attempt.projectIds.map((projectId) => {
1334
+ const staged = attempt.projects.get(projectId)!;
1335
+ return [
1336
+ projectId,
1337
+ {
1338
+ recordVersion: staged.recordVersion,
1339
+ records: staged.records,
1340
+ },
1341
+ ];
1342
+ }),
1343
+ );
1344
+ // Transfer ownership before clearing the attempt; then scrub the old live
1345
+ // map so removed projects and superseded values lose their last reference.
1346
+ for (const project of attempt.projects.values()) project.records = new Map();
1347
+ task.attempt = null;
1348
+ task.snapshotReaderGeneration = attempt.readerGeneration;
1349
+ task.gateOpen = true;
1350
+ task.generation += 1;
1351
+ scrubProjects(oldProjects);
1352
+ this.#resolveReady(task);
1353
+ this.#changed(task.taskId);
1354
+ return this.#successCommit(frame, frame.highWater, task);
1355
+ }
1356
+
1357
+ async #materialize(
1358
+ task: TaskReplica,
1359
+ projectId: string,
1360
+ state: ProjectEnvReplicaBindingState,
1361
+ reader: ProjectEnvTaskValueReader,
1362
+ ): Promise<ReplicaEntry> {
1363
+ const materialization = desiredMaterialization(state, this.policyEpochs);
1364
+ if (materialization.kind === "withheld" || state.head.tombstone) {
1365
+ return { state: clone(state), materialization, value: null };
1366
+ }
1367
+ const ref = state.head.valueRef;
1368
+ if (ref === null) {
1369
+ return {
1370
+ state: clone(state),
1371
+ materialization: { kind: "unavailable", reason: "value_reference_invalid" },
1372
+ value: null,
1373
+ };
1374
+ }
1375
+ try {
1376
+ if (ref.kind === "staged") {
1377
+ const raw = await reader.readStaged(
1378
+ projectId,
1379
+ state.key,
1380
+ ref.stagingId,
1381
+ );
1382
+ if (raw === null) return this.#unavailable(state, "value_missing");
1383
+ const value = parseStagedEnvelope(
1384
+ raw,
1385
+ task.orgId,
1386
+ projectId,
1387
+ state.key,
1388
+ ref.stagingId,
1389
+ );
1390
+ return value === null
1391
+ ? this.#unavailable(state, "value_envelope_mismatch")
1392
+ : {
1393
+ state: clone(state),
1394
+ materialization: { kind: "installed" },
1395
+ value,
1396
+ };
1397
+ }
1398
+ const found = await reader.readLegacy(
1399
+ projectId,
1400
+ state.key,
1401
+ ref.ssmVersion,
1402
+ );
1403
+ if (found === null) return this.#unavailable(state, "value_missing");
1404
+ if (found.version !== ref.ssmVersion) {
1405
+ return this.#unavailable(state, "value_version_mismatch");
1406
+ }
1407
+ const value = parseLegacyEnvelope(
1408
+ found.value,
1409
+ task.orgId,
1410
+ projectId,
1411
+ state.key,
1412
+ );
1413
+ return value === null
1414
+ ? this.#unavailable(state, "value_envelope_mismatch")
1415
+ : {
1416
+ state: clone(state),
1417
+ materialization: { kind: "installed" },
1418
+ value,
1419
+ };
1420
+ } catch {
1421
+ return this.#unavailable(state, "value_unavailable");
1422
+ }
1423
+ }
1424
+
1425
+ #unavailable(
1426
+ state: ProjectEnvReplicaBindingState,
1427
+ reason: string,
1428
+ ): ReplicaEntry {
1429
+ return {
1430
+ state: clone(state),
1431
+ materialization: { kind: "unavailable", reason },
1432
+ value: null,
1433
+ };
1434
+ }
1435
+
1436
+ /** Why a read crossed its fence: the run's reader changed (a newer
1437
+ * grant's), or anything else. */
1438
+ #fenceCode(task: TaskReplica, readerGeneration: number): string {
1439
+ return this.#tasks.get(task.taskId) === task && task.readerGeneration !== readerGeneration
1440
+ ? PROJECT_ENV_READER_CHANGED
1441
+ : "replica_state_changed";
1442
+ }
1443
+
1444
+ #readFenceCurrent(
1445
+ task: TaskReplica,
1446
+ frame: ProjectEnvReplicaV1PageFrame | ProjectEnvReplicaV1ApplyFrame,
1447
+ generation: number,
1448
+ reader: ProjectEnvTaskValueReader,
1449
+ attempt: SnapshotAttempt | null,
1450
+ ): boolean {
1451
+ return (
1452
+ this.#tasks.get(task.taskId) === task &&
1453
+ task.runEpoch === frame.runEpoch &&
1454
+ frame.runnerBootId === this.runnerBootId &&
1455
+ task.generation === generation &&
1456
+ task.reader === reader &&
1457
+ task.streamId === frame.streamId &&
1458
+ task.attempt === attempt &&
1459
+ (attempt === null
1460
+ ? frame.kind === "env.replica.v1.apply" && frame.attemptId === null
1461
+ : frame.attemptId === attempt.attemptId)
1462
+ );
1463
+ }
1464
+
1465
+ #consumeApplyError(
1466
+ task: TaskReplica,
1467
+ frame: ProjectEnvReplicaV1ApplyFrame,
1468
+ fingerprint: string,
1469
+ code: string,
1470
+ ): ProjectEnvReplicaV1AckFrame {
1471
+ const receipt: ApplyReceipt = {
1472
+ fingerprint,
1473
+ ok: false,
1474
+ attemptId: frame.attemptId,
1475
+ code,
1476
+ appliedThrough: clone(frame.delivery),
1477
+ };
1478
+ if (!this.#rememberApplyReceipt(task, frame.delivery.sequence, receipt)) {
1479
+ return this.#exhaustReplayLedger(task, frame);
1480
+ }
1481
+ task.appliedSequence = frame.delivery.sequence;
1482
+ return this.#ackFromApplyReceipt(frame, receipt, task);
1483
+ }
1484
+
1485
+ #ackFromApplyReceipt(
1486
+ frame: ProjectEnvReplicaV1ApplyFrame,
1487
+ receipt: ApplyReceipt,
1488
+ task: TaskReplica,
1489
+ ): ProjectEnvReplicaV1AckFrame {
1490
+ const common = {
1491
+ kind: "env.replica.v1.ack" as const,
1492
+ opId: frame.opId,
1493
+ taskId: frame.taskId,
1494
+ runEpoch: frame.runEpoch,
1495
+ runnerBootId: frame.runnerBootId,
1496
+ streamId: frame.streamId,
1497
+ attemptId: frame.attemptId,
1498
+ phase: "applied" as const,
1499
+ policyEpochs: [...this.policyEpochs],
1500
+ appliedThrough: this.#cursor(task, frame.streamId),
1501
+ };
1502
+ if (!receipt.ok) {
1503
+ return { ...common, ok: false, code: receipt.code, error: receipt.code };
1504
+ }
1505
+ return {
1506
+ ...common,
1507
+ ok: true,
1508
+ projectId: receipt.projectId,
1509
+ recordVersion: receipt.recordVersion,
1510
+ result: clone(receipt.result),
1511
+ };
1512
+ }
1513
+
1514
+ #successBegin(
1515
+ frame: ProjectEnvReplicaV1BeginFrame,
1516
+ task: TaskReplica,
1517
+ ): ProjectEnvReplicaV1AckFrame {
1518
+ return {
1519
+ ...this.#ackCommon(frame, task),
1520
+ attemptId: frame.attemptId,
1521
+ ok: true,
1522
+ phase: "begun",
1523
+ };
1524
+ }
1525
+
1526
+ #successPage(
1527
+ frame: ProjectEnvReplicaV1PageFrame,
1528
+ receipt: PageReceipt,
1529
+ task: TaskReplica,
1530
+ ): ProjectEnvReplicaV1AckFrame {
1531
+ return {
1532
+ ...this.#ackCommon(frame, task, receipt.appliedThrough),
1533
+ attemptId: frame.attemptId,
1534
+ ok: true,
1535
+ phase: "page",
1536
+ projectId: frame.projectId,
1537
+ after: receipt.after,
1538
+ recordVersion: receipt.recordVersion,
1539
+ results: clone(receipt.results),
1540
+ complete: receipt.complete,
1541
+ };
1542
+ }
1543
+
1544
+ #successCommit(
1545
+ frame: ProjectEnvReplicaV1CommitFrame,
1546
+ highWater: ProjectEnvDeliveryCursor,
1547
+ task: TaskReplica,
1548
+ ): ProjectEnvReplicaV1AckFrame {
1549
+ return {
1550
+ ...this.#ackCommon(frame, task, highWater),
1551
+ attemptId: frame.attemptId,
1552
+ ok: true,
1553
+ phase: "complete",
1554
+ highWater: clone(highWater),
1555
+ };
1556
+ }
1557
+
1558
+ #errorAck(
1559
+ frame: ProjectEnvTaskReplicaFrame,
1560
+ task: TaskReplica | undefined,
1561
+ code: string,
1562
+ ): ProjectEnvReplicaV1AckFrame {
1563
+ return {
1564
+ ...this.#ackCommon(frame, task),
1565
+ ok: false,
1566
+ phase: framePhase(frame),
1567
+ code,
1568
+ error: code,
1569
+ } as ProjectEnvReplicaV1AckFrame;
1570
+ }
1571
+
1572
+ #ackCommon(
1573
+ frame: ProjectEnvTaskReplicaFrame,
1574
+ task: TaskReplica | undefined,
1575
+ cursor?: ProjectEnvDeliveryCursor,
1576
+ ) {
1577
+ const matchedTask =
1578
+ frame.runnerBootId === this.runnerBootId &&
1579
+ task?.runEpoch === frame.runEpoch
1580
+ ? task
1581
+ : undefined;
1582
+ return {
1583
+ kind: "env.replica.v1.ack" as const,
1584
+ opId: frame.opId,
1585
+ taskId: frame.taskId,
1586
+ runEpoch: frame.runEpoch,
1587
+ runnerBootId: frame.runnerBootId,
1588
+ streamId: frame.streamId,
1589
+ attemptId: frame.attemptId,
1590
+ policyEpochs: [...this.policyEpochs],
1591
+ appliedThrough:
1592
+ cursor ?? this.#cursor(matchedTask, frame.streamId),
1593
+ };
1594
+ }
1595
+
1596
+ #cursor(
1597
+ task: TaskReplica | undefined,
1598
+ streamId: string,
1599
+ ): ProjectEnvDeliveryCursor {
1600
+ return {
1601
+ streamId,
1602
+ sequence:
1603
+ task?.streamId === streamId ? task.appliedSequence : 0,
1604
+ };
1605
+ }
1606
+
1607
+ #reserveReplayEvidence(task: TaskReplica, evidence: unknown): number | null {
1608
+ const bytes = replayLedgerEntryBytes(evidence);
1609
+ if (
1610
+ task.replayLedgerEntries + 1 > this.#replayLedgerMaxTaskEntries ||
1611
+ task.replayLedgerBytes + bytes > this.#replayLedgerMaxTaskBytes ||
1612
+ this.#replayLedgerEntries + 1 > this.#replayLedgerMaxTotalEntries ||
1613
+ this.#replayLedgerBytes + bytes > this.#replayLedgerMaxTotalBytes
1614
+ ) {
1615
+ return null;
1616
+ }
1617
+ task.replayLedgerEntries += 1;
1618
+ task.replayLedgerBytes += bytes;
1619
+ this.#replayLedgerEntries += 1;
1620
+ this.#replayLedgerBytes += bytes;
1621
+ return bytes;
1622
+ }
1623
+
1624
+ #releaseReplayEvidence(task: TaskReplica, bytes: number): void {
1625
+ task.replayLedgerEntries -= 1;
1626
+ task.replayLedgerBytes -= bytes;
1627
+ this.#replayLedgerEntries -= 1;
1628
+ this.#replayLedgerBytes -= bytes;
1629
+ if (
1630
+ task.replayLedgerEntries < 0 ||
1631
+ task.replayLedgerBytes < 0 ||
1632
+ this.#replayLedgerEntries < 0 ||
1633
+ this.#replayLedgerBytes < 0
1634
+ ) {
1635
+ throw new Error("task replica replay ledger accounting underflow");
1636
+ }
1637
+ }
1638
+
1639
+ #rememberRetiredStream(task: TaskReplica, streamId: string): boolean {
1640
+ if (task.retiredStreams.has(streamId)) return true;
1641
+ const evidence = ["retired_stream", streamId];
1642
+ if (this.#reserveReplayEvidence(task, evidence) === null) return false;
1643
+ task.retiredStreams.add(streamId);
1644
+ return true;
1645
+ }
1646
+
1647
+ #rememberRetiredAttempt(task: TaskReplica, attemptId: string): boolean {
1648
+ if (task.retiredAttempts.has(attemptId)) return true;
1649
+ const evidence = ["retired_attempt", attemptId];
1650
+ if (this.#reserveReplayEvidence(task, evidence) === null) return false;
1651
+ task.retiredAttempts.add(attemptId);
1652
+ return true;
1653
+ }
1654
+
1655
+ #rememberApplyReceipt(
1656
+ task: TaskReplica,
1657
+ sequence: number,
1658
+ receipt: ApplyReceipt,
1659
+ ): boolean {
1660
+ if (task.applyReceipts.has(sequence)) return true;
1661
+ const evidence = ["apply", sequence, receipt];
1662
+ const bytes = this.#reserveReplayEvidence(task, evidence);
1663
+ if (bytes === null) return false;
1664
+ task.applyReceipts.set(sequence, receipt);
1665
+ task.applyReceiptBytes.set(sequence, bytes);
1666
+ return true;
1667
+ }
1668
+
1669
+ #rememberCompletedAttempt(
1670
+ task: TaskReplica,
1671
+ attemptId: string,
1672
+ completed: CompletedAttempt,
1673
+ ): boolean {
1674
+ if (task.completedAttempts.has(attemptId)) return true;
1675
+ const evidence = ["completed_attempt", attemptId, completed];
1676
+ if (this.#reserveReplayEvidence(task, evidence) === null) return false;
1677
+ task.completedAttempts.set(attemptId, completed);
1678
+ return true;
1679
+ }
1680
+
1681
+ #clearApplyReceipts(task: TaskReplica): void {
1682
+ for (const bytes of task.applyReceiptBytes.values()) {
1683
+ this.#releaseReplayEvidence(task, bytes);
1684
+ }
1685
+ task.applyReceipts.clear();
1686
+ task.applyReceiptBytes.clear();
1687
+ }
1688
+
1689
+ #clearReplayLedger(task: TaskReplica): void {
1690
+ this.#replayLedgerEntries -= task.replayLedgerEntries;
1691
+ this.#replayLedgerBytes -= task.replayLedgerBytes;
1692
+ if (this.#replayLedgerEntries < 0 || this.#replayLedgerBytes < 0) {
1693
+ throw new Error("task replica replay ledger accounting underflow");
1694
+ }
1695
+ task.retiredStreams.clear();
1696
+ task.retiredAttempts.clear();
1697
+ task.applyReceipts.clear();
1698
+ task.applyReceiptBytes.clear();
1699
+ task.completedAttempts.clear();
1700
+ task.replayLedgerEntries = 0;
1701
+ task.replayLedgerBytes = 0;
1702
+ }
1703
+
1704
+ #exhaustReplayLedger(
1705
+ task: TaskReplica,
1706
+ frame: ProjectEnvTaskReplicaFrame,
1707
+ ): ProjectEnvReplicaV1AckFrame {
1708
+ if (!task.restartRequired) {
1709
+ task.restartRequired = true;
1710
+ this.#restartRequiredRuns.set(task.taskId, task.runEpoch);
1711
+ task.generation += 1;
1712
+ task.gateOpen = false;
1713
+ task.reader = CLOSED_READER;
1714
+ scrubAttempt(task.attempt);
1715
+ task.attempt = null;
1716
+ scrubProjects(task.projects);
1717
+ for (const entry of task.inFlightEntries) scrubEntry(entry);
1718
+ task.inFlightEntries.clear();
1719
+ this.#clearReplayLedger(task);
1720
+ for (const waiter of [...task.waiters]) {
1721
+ waiter.reject(new Error("task replica requires a runner restart"));
1722
+ }
1723
+ this.#changed(task.taskId);
1724
+ }
1725
+ return this.#errorAck(frame, task, REPLAY_LEDGER_EXHAUSTED);
1726
+ }
1727
+
1728
+ #resolveReady(task: TaskReplica): void {
1729
+ for (const waiter of [...task.waiters]) waiter.resolve();
1730
+ }
1731
+
1732
+ #destroyTask(task: TaskReplica, reason: string): void {
1733
+ task.generation += 1;
1734
+ task.gateOpen = false;
1735
+ task.reader = CLOSED_READER;
1736
+ scrubAttempt(task.attempt);
1737
+ task.attempt = null;
1738
+ scrubProjects(task.projects);
1739
+ this.#clearReplayLedger(task);
1740
+ for (const entry of task.inFlightEntries) scrubEntry(entry);
1741
+ task.inFlightEntries.clear();
1742
+ for (const waiter of [...task.waiters]) waiter.reject(new Error(reason));
1743
+ }
1744
+
1745
+ #changed(taskId: string): void {
1746
+ try {
1747
+ this.#onChange(taskId);
1748
+ } catch {
1749
+ // Replica authority cannot be rolled back by a cache notification.
1750
+ }
1751
+ }
1752
+ }