@frockbot/kernel-do 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -6
- package/src/authority.ts +989 -0
- package/src/composition-failures.test.ts +405 -0
- package/src/composition-failures.ts +108 -0
- package/src/composition-store.test.ts +497 -0
- package/src/composition-store.ts +473 -0
- package/src/index.ts +10 -0
- package/src/memory-storage.fixture.ts +61 -0
- package/src/run-records.ts +624 -0
- package/src/run-recovery.ts +163 -0
- package/src/run-terminal.ts +220 -0
- package/src/storage-keys.ts +129 -0
- package/src/turn-admission.test.ts +450 -0
- package/src/turn-errors.ts +33 -0
- package/src/workspace-generations.test.ts +228 -0
- package/src/workspace-generations.ts +189 -0
- package/src/workspace-sync-effects.ts +103 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
// The Bot Durable Object is the authority for the Composition generations its
|
|
3
|
+
// Turns pin. Generations are durable records: proposing or committing one never
|
|
4
|
+
// mutates a recorded generation, and an in-flight Turn keeps the pin it was
|
|
5
|
+
// admitted under.
|
|
6
|
+
import type { CompositionPinV1 } from "@frockbot/kernel-contracts";
|
|
7
|
+
import { decodeCompositionFailureV1 } from "@frockbot/kernel-composition/activation";
|
|
8
|
+
import {
|
|
9
|
+
assertCompositionArtifactSetHashV1,
|
|
10
|
+
compositionGenerationIdV1,
|
|
11
|
+
type CompositionGenerationV1,
|
|
12
|
+
type CompositionOriginV1,
|
|
13
|
+
type CompositionStore,
|
|
14
|
+
decodeCompositionGenerationV1,
|
|
15
|
+
} from "@frockbot/kernel-composition/generation";
|
|
16
|
+
import {
|
|
17
|
+
COMPOSITION_CURRENT_KEY,
|
|
18
|
+
COMPOSITION_INDEX_PREFIX,
|
|
19
|
+
COMPOSITION_LAST_KNOWN_GOOD_KEY,
|
|
20
|
+
compositionFailureCountKey,
|
|
21
|
+
compositionFailureKey,
|
|
22
|
+
compositionGenerationKey,
|
|
23
|
+
compositionIndexKey,
|
|
24
|
+
} from "./storage-keys.js";
|
|
25
|
+
|
|
26
|
+
const MAX_COMPOSITION_PAGE = 100;
|
|
27
|
+
|
|
28
|
+
/** The `composition:current` pointer. */
|
|
29
|
+
export function decodeCompositionPinV1(input: unknown): CompositionPinV1 {
|
|
30
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
31
|
+
throw new Error("composition pointer is invalid");
|
|
32
|
+
}
|
|
33
|
+
const candidate = input as Record<string, unknown>;
|
|
34
|
+
const keys = ["generationId", "artifactSetHash"];
|
|
35
|
+
if (
|
|
36
|
+
!keys.every((key) => Object.hasOwn(candidate, key)) ||
|
|
37
|
+
!Object.keys(candidate).every((key) => keys.includes(key)) ||
|
|
38
|
+
typeof candidate.generationId !== "string" ||
|
|
39
|
+
candidate.generationId.length === 0 ||
|
|
40
|
+
candidate.generationId.length > 256 ||
|
|
41
|
+
typeof candidate.artifactSetHash !== "string" ||
|
|
42
|
+
!/^[0-9a-f]{64}$/.test(candidate.artifactSetHash)
|
|
43
|
+
) {
|
|
44
|
+
throw new Error("composition pointer is invalid");
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
generationId: candidate.generationId,
|
|
48
|
+
artifactSetHash: candidate.artifactSetHash,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function compositionPinV1(
|
|
53
|
+
generation: CompositionGenerationV1,
|
|
54
|
+
): CompositionPinV1 {
|
|
55
|
+
return {
|
|
56
|
+
generationId: generation.generationId,
|
|
57
|
+
artifactSetHash: generation.artifactSetHash,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DurableCompositionStoreOptions {
|
|
62
|
+
state: DurableObjectState;
|
|
63
|
+
/** Builds the first-party generation a Bot starts on. Supplied by the Package. */
|
|
64
|
+
bootstrap(): Promise<CompositionGenerationV1>;
|
|
65
|
+
/** Injected clock; the revert generation is stamped with it. */
|
|
66
|
+
now?(): Date;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `CompositionStore` over the Bot object's prefixed keys: `composition:current`,
|
|
71
|
+
* `composition:generation:<id>`, `composition:index:<createdAt>:<id>`, and
|
|
72
|
+
* `composition:last-known-good`.
|
|
73
|
+
*/
|
|
74
|
+
export class DurableCompositionStore implements CompositionStore {
|
|
75
|
+
private readonly ctx: DurableObjectState;
|
|
76
|
+
private readonly buildBootstrap: () => Promise<CompositionGenerationV1>;
|
|
77
|
+
private readonly now: () => Date;
|
|
78
|
+
|
|
79
|
+
constructor(options: DurableCompositionStoreOptions) {
|
|
80
|
+
this.ctx = options.state;
|
|
81
|
+
this.buildBootstrap = options.bootstrap;
|
|
82
|
+
this.now = options.now ?? (() => new Date());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* First use with no records materializes the bootstrap generation, exactly
|
|
87
|
+
* once, the same way durable identity is materialized.
|
|
88
|
+
*/
|
|
89
|
+
async materialize(): Promise<CompositionPinV1> {
|
|
90
|
+
const existing = await this.ctx.storage.get<unknown>(
|
|
91
|
+
COMPOSITION_CURRENT_KEY,
|
|
92
|
+
);
|
|
93
|
+
if (existing !== undefined) return decodeCompositionPinV1(existing);
|
|
94
|
+
const bootstrap = await this.buildBootstrap();
|
|
95
|
+
await assertCompositionArtifactSetHashV1(bootstrap);
|
|
96
|
+
const generation = decodeCompositionGenerationV1({
|
|
97
|
+
...bootstrap,
|
|
98
|
+
status: "active",
|
|
99
|
+
});
|
|
100
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
101
|
+
const current = await transaction.get<unknown>(COMPOSITION_CURRENT_KEY);
|
|
102
|
+
if (current !== undefined) return decodeCompositionPinV1(current);
|
|
103
|
+
const pin = compositionPinV1(generation);
|
|
104
|
+
await transaction.put({
|
|
105
|
+
[compositionGenerationKey(generation.generationId)]: generation,
|
|
106
|
+
[compositionIndexKey(generation.createdAt, generation.generationId)]:
|
|
107
|
+
generation.generationId,
|
|
108
|
+
[COMPOSITION_CURRENT_KEY]: pin,
|
|
109
|
+
[COMPOSITION_LAST_KNOWN_GOOD_KEY]: generation.generationId,
|
|
110
|
+
});
|
|
111
|
+
return pin;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The pinned pointer, read inside the caller's transaction. */
|
|
116
|
+
async pin(transaction: DurableObjectTransaction): Promise<CompositionPinV1> {
|
|
117
|
+
return decodeCompositionPinV1(
|
|
118
|
+
await transaction.get<unknown>(COMPOSITION_CURRENT_KEY),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async read(
|
|
123
|
+
generationId: string,
|
|
124
|
+
): Promise<CompositionGenerationV1 | undefined> {
|
|
125
|
+
const stored = await this.ctx.storage.get<unknown>(
|
|
126
|
+
compositionGenerationKey(generationId),
|
|
127
|
+
);
|
|
128
|
+
if (stored === undefined) return undefined;
|
|
129
|
+
const generation = decodeCompositionGenerationV1(stored);
|
|
130
|
+
if (generation.generationId !== generationId) {
|
|
131
|
+
throw new Error("composition generation does not match its lookup key");
|
|
132
|
+
}
|
|
133
|
+
return generation;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async current(): Promise<CompositionGenerationV1> {
|
|
137
|
+
const pin = await this.materialize();
|
|
138
|
+
return this.require(pin.generationId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async lastKnownGood(): Promise<CompositionGenerationV1> {
|
|
142
|
+
await this.materialize();
|
|
143
|
+
const generationId = await this.ctx.storage.get<string>(
|
|
144
|
+
COMPOSITION_LAST_KNOWN_GOOD_KEY,
|
|
145
|
+
);
|
|
146
|
+
if (typeof generationId !== "string" || generationId.length === 0) {
|
|
147
|
+
throw new Error("bot has no last known good Composition generation");
|
|
148
|
+
}
|
|
149
|
+
return this.require(generationId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async propose(
|
|
153
|
+
generation: CompositionGenerationV1,
|
|
154
|
+
options: { pin?: boolean } = {},
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const proposed = decodeCompositionGenerationV1(generation);
|
|
157
|
+
if (proposed.status !== "pending") {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`composition generation "${proposed.generationId}" must be proposed as pending`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
await assertCompositionArtifactSetHashV1(proposed);
|
|
163
|
+
await this.materialize();
|
|
164
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
165
|
+
const key = compositionGenerationKey(proposed.generationId);
|
|
166
|
+
if ((await transaction.get<unknown>(key)) !== undefined) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`composition generation "${proposed.generationId}" already exists`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
await transaction.put({
|
|
172
|
+
[key]: proposed,
|
|
173
|
+
[compositionIndexKey(proposed.createdAt, proposed.generationId)]:
|
|
174
|
+
proposed.generationId,
|
|
175
|
+
// Activation takes effect at the next admitted Turn: the pointer moves
|
|
176
|
+
// now, the status stays pending until that Turn mounts and commits it.
|
|
177
|
+
...(options.pin
|
|
178
|
+
? { [COMPOSITION_CURRENT_KEY]: compositionPinV1(proposed) }
|
|
179
|
+
: {}),
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** How many generations this Bot retains; the per-User retention quota reads it. */
|
|
185
|
+
async retainedCount(): Promise<number> {
|
|
186
|
+
await this.materialize();
|
|
187
|
+
const entries = await this.ctx.storage.list<string>({
|
|
188
|
+
prefix: COMPOSITION_INDEX_PREFIX,
|
|
189
|
+
});
|
|
190
|
+
return entries.size;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Activates a proposed generation and supersedes the one it replaces. */
|
|
194
|
+
async commit(generationId: string): Promise<void> {
|
|
195
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
196
|
+
const stored = await transaction.get<unknown>(
|
|
197
|
+
compositionGenerationKey(generationId),
|
|
198
|
+
);
|
|
199
|
+
if (stored === undefined) {
|
|
200
|
+
throw new Error(`composition generation "${generationId}" is unknown`);
|
|
201
|
+
}
|
|
202
|
+
const generation = decodeCompositionGenerationV1(stored);
|
|
203
|
+
if (generation.generationId !== generationId) {
|
|
204
|
+
throw new Error("composition generation does not match its lookup key");
|
|
205
|
+
}
|
|
206
|
+
// A `failed` generation may still commit: a retry that finally mounts
|
|
207
|
+
// and verifies is exactly what clears a fail-closed activation. A
|
|
208
|
+
// `quarantined` or `superseded` one never does.
|
|
209
|
+
if (
|
|
210
|
+
generation.status !== "pending" &&
|
|
211
|
+
generation.status !== "active" &&
|
|
212
|
+
generation.status !== "failed"
|
|
213
|
+
) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`composition generation "${generationId}" is ${generation.status}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const currentPointer = await transaction.get<unknown>(
|
|
219
|
+
COMPOSITION_CURRENT_KEY,
|
|
220
|
+
);
|
|
221
|
+
const previous =
|
|
222
|
+
currentPointer === undefined
|
|
223
|
+
? undefined
|
|
224
|
+
: decodeCompositionPinV1(currentPointer);
|
|
225
|
+
const active = decodeCompositionGenerationV1({
|
|
226
|
+
...generation,
|
|
227
|
+
status: "active",
|
|
228
|
+
});
|
|
229
|
+
const writes: Record<string, unknown> = {
|
|
230
|
+
[compositionGenerationKey(generationId)]: active,
|
|
231
|
+
[COMPOSITION_CURRENT_KEY]: compositionPinV1(active),
|
|
232
|
+
[COMPOSITION_LAST_KNOWN_GOOD_KEY]: generationId,
|
|
233
|
+
};
|
|
234
|
+
// The generation being committed may already be the pointer — a proposal
|
|
235
|
+
// pinned for the next Turn is. Superseding its parent is what records
|
|
236
|
+
// that a re-authored Package replaced the member set before it.
|
|
237
|
+
const supersede = new Set<string>();
|
|
238
|
+
if (previous && previous.generationId !== generationId) {
|
|
239
|
+
supersede.add(previous.generationId);
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
generation.parentGenerationId &&
|
|
243
|
+
generation.parentGenerationId !== generationId
|
|
244
|
+
) {
|
|
245
|
+
supersede.add(generation.parentGenerationId);
|
|
246
|
+
}
|
|
247
|
+
for (const supersededId of supersede) {
|
|
248
|
+
const storedPrevious = await transaction.get<unknown>(
|
|
249
|
+
compositionGenerationKey(supersededId),
|
|
250
|
+
);
|
|
251
|
+
if (storedPrevious === undefined) continue;
|
|
252
|
+
const decoded = decodeCompositionGenerationV1(storedPrevious);
|
|
253
|
+
if (decoded.status !== "active" && decoded.status !== "pending") {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
writes[compositionGenerationKey(supersededId)] =
|
|
257
|
+
decodeCompositionGenerationV1({ ...decoded, status: "superseded" });
|
|
258
|
+
}
|
|
259
|
+
await transaction.put(writes);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Fail-closed: records that a generation did not activate. The generation is
|
|
265
|
+
* marked `failed` so the next admitted Turn retries it, or `quarantined` on
|
|
266
|
+
* its third consecutive failure, in which case the pointer moves back to the
|
|
267
|
+
* last known good and the generation is never retried until a User acts.
|
|
268
|
+
*/
|
|
269
|
+
async fail(
|
|
270
|
+
generationId: string,
|
|
271
|
+
options: { quarantined: boolean },
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
274
|
+
const stored = await transaction.get<unknown>(
|
|
275
|
+
compositionGenerationKey(generationId),
|
|
276
|
+
);
|
|
277
|
+
const writes: Record<string, unknown> = {};
|
|
278
|
+
// A pin whose generation record is missing is exactly the `resolve`
|
|
279
|
+
// failure this method exists for: there is no status to write, but the
|
|
280
|
+
// pointer still has to stop naming it once it is quarantined.
|
|
281
|
+
if (stored !== undefined) {
|
|
282
|
+
const generation = decodeCompositionGenerationV1(stored);
|
|
283
|
+
if (generation.status === "active") {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`composition generation "${generationId}" is active and cannot fail closed`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const status = options.quarantined ? "quarantined" : "failed";
|
|
289
|
+
writes[compositionGenerationKey(generationId)] =
|
|
290
|
+
decodeCompositionGenerationV1({ ...generation, status });
|
|
291
|
+
}
|
|
292
|
+
if (options.quarantined) {
|
|
293
|
+
const lastKnownGoodId = await transaction.get<string>(
|
|
294
|
+
COMPOSITION_LAST_KNOWN_GOOD_KEY,
|
|
295
|
+
);
|
|
296
|
+
const lastKnownGood =
|
|
297
|
+
lastKnownGoodId === undefined
|
|
298
|
+
? undefined
|
|
299
|
+
: await transaction.get<unknown>(
|
|
300
|
+
compositionGenerationKey(lastKnownGoodId),
|
|
301
|
+
);
|
|
302
|
+
if (lastKnownGood !== undefined) {
|
|
303
|
+
writes[COMPOSITION_CURRENT_KEY] = compositionPinV1(
|
|
304
|
+
decodeCompositionGenerationV1(lastKnownGood),
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
// The last known good record is gone, so quarantine has nothing to
|
|
308
|
+
// fail into and the pointer would keep naming the quarantined
|
|
309
|
+
// generation — every later Turn would throw with nothing recorded.
|
|
310
|
+
// The bootstrap generation always exists: it is the oldest indexed
|
|
311
|
+
// one, materialized before any other. Falling back to it keeps the
|
|
312
|
+
// Bot admitting Turns, and the fallback is itself a recorded,
|
|
313
|
+
// visible failure rather than a silent repair.
|
|
314
|
+
const bootstrap = await this.bootstrapGeneration(transaction);
|
|
315
|
+
writes[COMPOSITION_CURRENT_KEY] = compositionPinV1(bootstrap);
|
|
316
|
+
writes[COMPOSITION_LAST_KNOWN_GOOD_KEY] = bootstrap.generationId;
|
|
317
|
+
Object.assign(
|
|
318
|
+
writes,
|
|
319
|
+
await this.missingLastKnownGoodFailure(
|
|
320
|
+
transaction,
|
|
321
|
+
generationId,
|
|
322
|
+
lastKnownGoodId,
|
|
323
|
+
bootstrap.generationId,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await transaction.put(writes);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Reverting is itself a recorded generation: a **new** pending generation
|
|
334
|
+
* whose members equal the target's, parented on the generation that is
|
|
335
|
+
* current right now. The recorded target is never mutated, and the revert
|
|
336
|
+
* takes effect at the next admitted Turn like any other activation — which
|
|
337
|
+
* is why it is proposed *pinned*: the pointer moves now so the next admitted
|
|
338
|
+
* Turn mounts it, verifies it, and commits it through the fail-closed path
|
|
339
|
+
* like any other proposal. Without the pin the pointer would keep naming the
|
|
340
|
+
* generation the revert replaces and the revert would never take effect.
|
|
341
|
+
*/
|
|
342
|
+
async revert(
|
|
343
|
+
toGenerationId: string,
|
|
344
|
+
origin: Extract<CompositionOriginV1, { kind: "revert" }>,
|
|
345
|
+
): Promise<CompositionGenerationV1> {
|
|
346
|
+
if (origin.kind !== "revert" || origin.revertsTo !== toGenerationId) {
|
|
347
|
+
throw new Error("composition revert origin does not name its target");
|
|
348
|
+
}
|
|
349
|
+
const current = await this.current();
|
|
350
|
+
if (toGenerationId === current.generationId) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`composition generation "${toGenerationId}" is already current`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const target = await this.read(toGenerationId);
|
|
356
|
+
if (!target) {
|
|
357
|
+
throw new Error(`composition generation "${toGenerationId}" is unknown`);
|
|
358
|
+
}
|
|
359
|
+
const createdAt = this.now().toISOString();
|
|
360
|
+
const generation = decodeCompositionGenerationV1({
|
|
361
|
+
schemaVersion: 1,
|
|
362
|
+
generationId: compositionGenerationIdV1(
|
|
363
|
+
createdAt,
|
|
364
|
+
target.artifactSetHash,
|
|
365
|
+
),
|
|
366
|
+
artifactSetHash: target.artifactSetHash,
|
|
367
|
+
parentGenerationId: current.generationId,
|
|
368
|
+
createdAt,
|
|
369
|
+
origin,
|
|
370
|
+
members: target.members,
|
|
371
|
+
status: "pending",
|
|
372
|
+
});
|
|
373
|
+
await this.propose(generation, { pin: true });
|
|
374
|
+
return generation;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Newest first; `cursor` continues from the previous page. */
|
|
378
|
+
async list(query: {
|
|
379
|
+
limit: number;
|
|
380
|
+
cursor?: string;
|
|
381
|
+
}): Promise<{ generations: CompositionGenerationV1[]; cursor?: string }> {
|
|
382
|
+
if (!Number.isSafeInteger(query.limit) || query.limit <= 0) {
|
|
383
|
+
throw new Error("composition list limit must be a positive integer");
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
query.cursor !== undefined &&
|
|
387
|
+
!query.cursor.startsWith(COMPOSITION_INDEX_PREFIX)
|
|
388
|
+
) {
|
|
389
|
+
throw new Error("composition list cursor is invalid");
|
|
390
|
+
}
|
|
391
|
+
await this.materialize();
|
|
392
|
+
const limit = Math.min(query.limit, MAX_COMPOSITION_PAGE);
|
|
393
|
+
const entries = await this.ctx.storage.list<string>({
|
|
394
|
+
prefix: COMPOSITION_INDEX_PREFIX,
|
|
395
|
+
reverse: true,
|
|
396
|
+
limit,
|
|
397
|
+
...(query.cursor ? { end: query.cursor } : {}),
|
|
398
|
+
});
|
|
399
|
+
const page = [...entries];
|
|
400
|
+
const generations = await Promise.all(
|
|
401
|
+
page.map(([, generationId]) => this.require(generationId)),
|
|
402
|
+
);
|
|
403
|
+
const last = page.at(-1);
|
|
404
|
+
return {
|
|
405
|
+
generations,
|
|
406
|
+
...(page.length === limit && last ? { cursor: last[0] } : {}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The generation this Bot started on. `materialize` writes it before any
|
|
412
|
+
* other, so the oldest index entry names it and it always exists.
|
|
413
|
+
*/
|
|
414
|
+
private async bootstrapGeneration(
|
|
415
|
+
transaction: DurableObjectTransaction,
|
|
416
|
+
): Promise<CompositionGenerationV1> {
|
|
417
|
+
const oldest = await transaction.list<string>({
|
|
418
|
+
prefix: COMPOSITION_INDEX_PREFIX,
|
|
419
|
+
limit: 1,
|
|
420
|
+
});
|
|
421
|
+
const generationId = [...oldest.values()][0];
|
|
422
|
+
const stored =
|
|
423
|
+
generationId === undefined
|
|
424
|
+
? undefined
|
|
425
|
+
: await transaction.get<unknown>(
|
|
426
|
+
compositionGenerationKey(generationId),
|
|
427
|
+
);
|
|
428
|
+
if (stored === undefined) {
|
|
429
|
+
throw new Error("bot has no bootstrap Composition generation");
|
|
430
|
+
}
|
|
431
|
+
return decodeCompositionGenerationV1(stored);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The durable, visible record that quarantine had no last known good to fail
|
|
436
|
+
* into. Written against the generation that was named last known good, whose
|
|
437
|
+
* record is what went missing.
|
|
438
|
+
*/
|
|
439
|
+
private async missingLastKnownGoodFailure(
|
|
440
|
+
transaction: DurableObjectTransaction,
|
|
441
|
+
quarantinedId: string,
|
|
442
|
+
lastKnownGoodId: string | undefined,
|
|
443
|
+
bootstrapId: string,
|
|
444
|
+
): Promise<Record<string, unknown>> {
|
|
445
|
+
const generationId = lastKnownGoodId ?? bootstrapId;
|
|
446
|
+
const attempt =
|
|
447
|
+
((await transaction.get<number>(
|
|
448
|
+
compositionFailureCountKey(generationId),
|
|
449
|
+
)) ?? 0) + 1;
|
|
450
|
+
const failure = decodeCompositionFailureV1({
|
|
451
|
+
generationId,
|
|
452
|
+
attempt,
|
|
453
|
+
at: this.now().toISOString(),
|
|
454
|
+
phase: "resolve",
|
|
455
|
+
message: `composition generation "${quarantinedId}" was quarantined with no last known good record; falling back to the bootstrap generation "${bootstrapId}"`,
|
|
456
|
+
diagnostics: [`lastKnownGood:${lastKnownGoodId ?? "unrecorded"}`],
|
|
457
|
+
});
|
|
458
|
+
return {
|
|
459
|
+
[compositionFailureKey(generationId, attempt)]: failure,
|
|
460
|
+
[compositionFailureCountKey(generationId)]: attempt,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private async require(
|
|
465
|
+
generationId: string,
|
|
466
|
+
): Promise<CompositionGenerationV1> {
|
|
467
|
+
const generation = await this.read(generationId);
|
|
468
|
+
if (!generation) {
|
|
469
|
+
throw new Error(`composition generation "${generationId}" is unknown`);
|
|
470
|
+
}
|
|
471
|
+
return generation;
|
|
472
|
+
}
|
|
473
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./authority.js";
|
|
2
|
+
export * from "./composition-failures.js";
|
|
3
|
+
export * from "./composition-store.js";
|
|
4
|
+
export * from "./run-records.js";
|
|
5
|
+
export * from "./run-recovery.js";
|
|
6
|
+
export * from "./run-terminal.js";
|
|
7
|
+
export * from "./storage-keys.js";
|
|
8
|
+
export * from "./turn-errors.js";
|
|
9
|
+
export * from "./workspace-generations.js";
|
|
10
|
+
export * from "./workspace-sync-effects.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory `DurableObjectStorage` stand-in for kernel-do unit tests: the
|
|
3
|
+
* same key/value, prefix-list, transaction, and alarm surface the authority
|
|
4
|
+
* uses, with none of the workerd host. Eviction is modelled by constructing a
|
|
5
|
+
* second authority over the same instance.
|
|
6
|
+
*/
|
|
7
|
+
export class MemoryStorage {
|
|
8
|
+
readonly values = new Map<string, unknown>();
|
|
9
|
+
alarmAt: number | undefined;
|
|
10
|
+
|
|
11
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
12
|
+
return Promise.resolve(this.values.get(key) as T | undefined);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
|
|
16
|
+
if (typeof key === "string") this.values.set(key, structuredClone(value));
|
|
17
|
+
else {
|
|
18
|
+
for (const [entry, item] of Object.entries(key)) {
|
|
19
|
+
this.values.set(entry, structuredClone(item));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
delete(key: string): Promise<boolean> {
|
|
26
|
+
return Promise.resolve(this.values.delete(key));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
list<T>(options: {
|
|
30
|
+
prefix?: string;
|
|
31
|
+
end?: string;
|
|
32
|
+
reverse?: boolean;
|
|
33
|
+
limit?: number;
|
|
34
|
+
}): Promise<Map<string, T>> {
|
|
35
|
+
const entries = [...this.values.entries()]
|
|
36
|
+
.filter(
|
|
37
|
+
([key]) =>
|
|
38
|
+
key.startsWith(options.prefix ?? "") &&
|
|
39
|
+
(options.end === undefined || key < options.end),
|
|
40
|
+
)
|
|
41
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
42
|
+
if (options.reverse) entries.reverse();
|
|
43
|
+
return Promise.resolve(
|
|
44
|
+
new Map(entries.slice(0, options.limit) as Array<[string, T]>),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
|
|
49
|
+
return callback(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setAlarm(scheduledTime: number): Promise<void> {
|
|
53
|
+
this.alarmAt = scheduledTime;
|
|
54
|
+
return Promise.resolve();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
deleteAlarm(): Promise<void> {
|
|
58
|
+
this.alarmAt = undefined;
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
}
|
|
61
|
+
}
|