@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.
@@ -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
+ }