@frockbot/kernel-do 0.0.0 → 0.1.1

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,405 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ activateCompositionV1,
4
+ CompositionMountFailureError,
5
+ type CompositionFailureV1,
6
+ } from "@frockbot/kernel-composition/activation";
7
+ import {
8
+ bootstrapGeneration,
9
+ compositionArtifactSetHashV1,
10
+ compositionGenerationIdV1,
11
+ type CompositionGenerationV1,
12
+ type CompositionMemberV1,
13
+ type MountedComposition,
14
+ } from "@frockbot/kernel-composition/generation";
15
+ import type { Context } from "cordis";
16
+ import { DurableCompositionFailureLog } from "./composition-failures.ts";
17
+ import { DurableCompositionStore } from "./composition-store.ts";
18
+ import {
19
+ COMPOSITION_CURRENT_KEY,
20
+ compositionFailureCountKey,
21
+ compositionFailureKey,
22
+ compositionQuarantineKey,
23
+ } from "./storage-keys.ts";
24
+
25
+ class MemoryStorage {
26
+ readonly values = new Map<string, unknown>();
27
+
28
+ get<T>(key: string): Promise<T | undefined> {
29
+ return Promise.resolve(this.values.get(key) as T | undefined);
30
+ }
31
+
32
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
33
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
34
+ else {
35
+ for (const [entry, item] of Object.entries(key)) {
36
+ this.values.set(entry, structuredClone(item));
37
+ }
38
+ }
39
+ return Promise.resolve();
40
+ }
41
+
42
+ delete(key: string): Promise<boolean> {
43
+ return Promise.resolve(this.values.delete(key));
44
+ }
45
+
46
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
47
+ return Promise.resolve(
48
+ new Map(
49
+ [...this.values.entries()]
50
+ .filter(([key]) => key.startsWith(options.prefix ?? ""))
51
+ .sort(([left], [right]) => left.localeCompare(right)) as Array<
52
+ [string, T]
53
+ >,
54
+ ),
55
+ );
56
+ }
57
+
58
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
59
+ return callback(this);
60
+ }
61
+ }
62
+
63
+ const authoredMember: CompositionMemberV1 = {
64
+ packageId: "bot-authored-greeter",
65
+ specifier: "bot:greeter",
66
+ version: "0.0.1",
67
+ manifestHash: "a".repeat(64),
68
+ provenance: {
69
+ kind: "bot",
70
+ packageId: "bot-authored-greeter",
71
+ version: "0.0.1",
72
+ botId: "primary",
73
+ sessionId: "user-1:primary",
74
+ turnId: "turn-1",
75
+ runId: "run-1",
76
+ authoredAt: "2026-08-31T12:00:00.000Z",
77
+ },
78
+ artifact: {
79
+ contentHash: "b".repeat(64),
80
+ size: 512,
81
+ mediaType: "application/javascript",
82
+ bundlerVersion: "worker-bundler@0.2.3",
83
+ },
84
+ };
85
+
86
+ function bootstrap(): Promise<CompositionGenerationV1> {
87
+ return bootstrapGeneration(
88
+ [
89
+ {
90
+ packageId: "shell",
91
+ specifier: "@frockbot/plugin-shell",
92
+ version: "0.0.1",
93
+ manifest: { id: "shell", version: "0.0.1" },
94
+ },
95
+ ],
96
+ { createdAt: "2026-08-31T00:00:00.000Z" },
97
+ );
98
+ }
99
+
100
+ async function authored(
101
+ parent: CompositionGenerationV1,
102
+ createdAt: string,
103
+ ): Promise<CompositionGenerationV1> {
104
+ const members = [...parent.members, authoredMember].sort((left, right) =>
105
+ left.packageId.localeCompare(right.packageId),
106
+ );
107
+ const artifactSetHash = await compositionArtifactSetHashV1(members);
108
+ return {
109
+ schemaVersion: 1,
110
+ generationId: compositionGenerationIdV1(createdAt, artifactSetHash),
111
+ artifactSetHash,
112
+ parentGenerationId: parent.generationId,
113
+ createdAt,
114
+ origin: {
115
+ kind: "bot-authored",
116
+ runId: "run-1",
117
+ sessionId: "user-1:primary",
118
+ turnId: "turn-1",
119
+ },
120
+ members,
121
+ status: "pending",
122
+ };
123
+ }
124
+
125
+ function mounted(generation: CompositionGenerationV1): MountedComposition {
126
+ return {
127
+ generation,
128
+ root: {} as Context,
129
+ verify: () => Promise.resolve(),
130
+ dispose: () => Promise.resolve(),
131
+ };
132
+ }
133
+
134
+ interface Fixture {
135
+ storage: MemoryStorage;
136
+ store: DurableCompositionStore;
137
+ failures: DurableCompositionFailureLog;
138
+ lastKnownGood: CompositionGenerationV1;
139
+ broken: CompositionGenerationV1;
140
+ }
141
+
142
+ async function fixture(): Promise<Fixture> {
143
+ const storage = new MemoryStorage();
144
+ const state = { storage } as unknown as DurableObjectState;
145
+ const store = new DurableCompositionStore({ state, bootstrap });
146
+ const failures = new DurableCompositionFailureLog({
147
+ state,
148
+ now: () => new Date("2026-09-01T00:00:00.000Z"),
149
+ });
150
+ const lastKnownGood = await store.current();
151
+ const broken = await authored(lastKnownGood, "2026-09-01T00:00:00.000Z");
152
+ await store.propose(broken, { pin: true });
153
+ return { storage, store, failures, lastKnownGood, broken };
154
+ }
155
+
156
+ function activationStore(store: DurableCompositionStore) {
157
+ return {
158
+ read: (generationId: string) => store.read(generationId),
159
+ lastKnownGood: () => store.lastKnownGood(),
160
+ commit: (generationId: string) => store.commit(generationId),
161
+ fail: (generationId: string, options: { quarantined: boolean }) =>
162
+ store.fail(generationId, options),
163
+ };
164
+ }
165
+
166
+ /** Mounts everything except the named generation, which fails at `phase`. */
167
+ function brokenHost(
168
+ brokenGenerationId: string,
169
+ phase: "resolve" | "mount" | "health",
170
+ ) {
171
+ return {
172
+ mount: (generation: CompositionGenerationV1) =>
173
+ generation.generationId === brokenGenerationId
174
+ ? Promise.reject(
175
+ new CompositionMountFailureError(
176
+ phase,
177
+ `package "bot-authored-greeter" failed at ${phase}`,
178
+ [`${phase}: diagnostic`],
179
+ ),
180
+ )
181
+ : Promise.resolve(mounted(generation)),
182
+ };
183
+ }
184
+
185
+ describe("fail-closed Composition activation", () => {
186
+ test("a broken generation leaves the last known good running and records a visible failure", async () => {
187
+ const { store, failures, storage, lastKnownGood, broken } = await fixture();
188
+ const raised: CompositionFailureV1[] = [];
189
+
190
+ const activation = await activateCompositionV1({
191
+ generationId: broken.generationId,
192
+ store: activationStore(store),
193
+ failures,
194
+ host: brokenHost(broken.generationId, "mount"),
195
+ signal: new AbortController().signal,
196
+ now: () => new Date("2026-09-01T00:01:00.000Z"),
197
+ onFailure: (failure) => {
198
+ raised.push(failure);
199
+ return Promise.resolve();
200
+ },
201
+ });
202
+
203
+ expect(activation.status).toBe("failed-closed");
204
+ if (activation.status !== "failed-closed") return;
205
+ expect(activation.mounted.generation.generationId).toBe(
206
+ lastKnownGood.generationId,
207
+ );
208
+ expect(activation.quarantined).toBe(false);
209
+ expect(activation.failure).toMatchObject({
210
+ generationId: broken.generationId,
211
+ attempt: 1,
212
+ phase: "mount",
213
+ });
214
+ // The failure is durable, visible, and repairable.
215
+ expect(raised).toHaveLength(1);
216
+ expect(
217
+ storage.values.get(compositionFailureKey(broken.generationId, 1)),
218
+ ).toMatchObject({ attempt: 1, phase: "mount" });
219
+ expect(
220
+ storage.values.get(compositionFailureCountKey(broken.generationId)),
221
+ ).toBe(1);
222
+ expect((await store.read(broken.generationId))?.status).toBe("failed");
223
+ expect((await store.lastKnownGood()).generationId).toBe(
224
+ lastKnownGood.generationId,
225
+ );
226
+ });
227
+
228
+ test("each of the three load sites records its own phase", async () => {
229
+ for (const phase of ["resolve", "mount", "health"] as const) {
230
+ const { store, failures, broken } = await fixture();
231
+ const activation = await activateCompositionV1({
232
+ generationId: broken.generationId,
233
+ store: activationStore(store),
234
+ failures,
235
+ host: brokenHost(broken.generationId, phase),
236
+ signal: new AbortController().signal,
237
+ });
238
+ expect(activation.status).toBe("failed-closed");
239
+ if (activation.status !== "failed-closed") return;
240
+ expect(activation.failure?.phase).toBe(phase);
241
+ expect(activation.failure?.diagnostics).toEqual([`${phase}: diagnostic`]);
242
+ }
243
+ });
244
+
245
+ test("an unresolvable generation fails closed at the resolve phase", async () => {
246
+ const { store, failures, lastKnownGood } = await fixture();
247
+ const activation = await activateCompositionV1({
248
+ generationId: "2026-09-02T00:00:00.000Z:deadbeefdeadbeef",
249
+ store: activationStore(store),
250
+ failures,
251
+ host: { mount: (generation) => Promise.resolve(mounted(generation)) },
252
+ signal: new AbortController().signal,
253
+ });
254
+ expect(activation.status).toBe("failed-closed");
255
+ if (activation.status !== "failed-closed") return;
256
+ expect(activation.failure?.phase).toBe("resolve");
257
+ expect(activation.mounted.generation.generationId).toBe(
258
+ lastKnownGood.generationId,
259
+ );
260
+ });
261
+
262
+ test("a third consecutive failure quarantines the generation and is never retried", async () => {
263
+ const { store, failures, storage, lastKnownGood, broken } = await fixture();
264
+ const host = brokenHost(broken.generationId, "health");
265
+ const activationStoreForTurn = activationStore(store);
266
+
267
+ for (const attempt of [1, 2, 3]) {
268
+ // Every Turn re-pins the failed generation until it is quarantined.
269
+ if (attempt > 1) {
270
+ await storage.put(COMPOSITION_CURRENT_KEY, {
271
+ generationId: broken.generationId,
272
+ artifactSetHash: broken.artifactSetHash,
273
+ });
274
+ }
275
+ const activation = await activateCompositionV1({
276
+ generationId: broken.generationId,
277
+ store: activationStoreForTurn,
278
+ failures,
279
+ host,
280
+ signal: new AbortController().signal,
281
+ });
282
+ expect(activation.status).toBe("failed-closed");
283
+ if (activation.status !== "failed-closed") return;
284
+ expect(activation.failure?.attempt).toBe(attempt);
285
+ expect(activation.quarantined).toBe(attempt === 3);
286
+ }
287
+
288
+ expect((await store.read(broken.generationId))?.status).toBe("quarantined");
289
+ expect(
290
+ storage.values.get(compositionQuarantineKey(broken.generationId)),
291
+ ).toMatchObject({ failures: 3 });
292
+ // Quarantine moved the pointer back, so the fourth Turn pins the good one.
293
+ expect((await store.current()).generationId).toBe(
294
+ lastKnownGood.generationId,
295
+ );
296
+
297
+ // A fourth activation of the quarantined generation attempts no mount.
298
+ let attempted = 0;
299
+ const fourth = await activateCompositionV1({
300
+ generationId: broken.generationId,
301
+ store: activationStoreForTurn,
302
+ failures,
303
+ host: {
304
+ mount: (generation) => {
305
+ if (generation.generationId === broken.generationId) attempted += 1;
306
+ return Promise.resolve(mounted(generation));
307
+ },
308
+ },
309
+ signal: new AbortController().signal,
310
+ });
311
+ expect(attempted).toBe(0);
312
+ expect(fourth.status).toBe("failed-closed");
313
+ if (fourth.status !== "failed-closed") return;
314
+ expect(fourth.quarantined).toBe(true);
315
+ expect(await failures.list(broken.generationId)).toHaveLength(3);
316
+ });
317
+
318
+ test("quarantine is per generation, so a later unrelated generation still activates", async () => {
319
+ const { store, failures, broken, lastKnownGood } = await fixture();
320
+ await activateCompositionV1({
321
+ generationId: broken.generationId,
322
+ store: activationStore(store),
323
+ failures,
324
+ host: brokenHost(broken.generationId, "mount"),
325
+ signal: new AbortController().signal,
326
+ });
327
+ await failures.record({
328
+ generationId: broken.generationId,
329
+ at: "2026-09-01T00:02:00.000Z",
330
+ phase: "mount",
331
+ message: "second",
332
+ diagnostics: [],
333
+ });
334
+ await failures.record({
335
+ generationId: broken.generationId,
336
+ at: "2026-09-01T00:03:00.000Z",
337
+ phase: "mount",
338
+ message: "third",
339
+ diagnostics: [],
340
+ });
341
+ await store.fail(broken.generationId, { quarantined: true });
342
+
343
+ const later = await authored(lastKnownGood, "2026-09-03T00:00:00.000Z");
344
+ await store.propose(later, { pin: true });
345
+ const activation = await activateCompositionV1({
346
+ generationId: later.generationId,
347
+ store: activationStore(store),
348
+ failures,
349
+ host: { mount: (generation) => Promise.resolve(mounted(generation)) },
350
+ signal: new AbortController().signal,
351
+ });
352
+
353
+ expect(activation.status).toBe("activated");
354
+ expect((await store.read(later.generationId))?.status).toBe("active");
355
+ expect((await store.lastKnownGood()).generationId).toBe(later.generationId);
356
+ expect(await failures.quarantine(later.generationId)).toBeUndefined();
357
+ expect(await failures.quarantine(broken.generationId)).toMatchObject({
358
+ failures: 3,
359
+ });
360
+ expect((await store.read(broken.generationId))?.status).toBe("quarantined");
361
+ });
362
+
363
+ test("a generation that finally activates commits and clears its consecutive count", async () => {
364
+ const { store, failures, storage, broken } = await fixture();
365
+ await activateCompositionV1({
366
+ generationId: broken.generationId,
367
+ store: activationStore(store),
368
+ failures,
369
+ host: brokenHost(broken.generationId, "mount"),
370
+ signal: new AbortController().signal,
371
+ });
372
+ expect(
373
+ storage.values.get(compositionFailureCountKey(broken.generationId)),
374
+ ).toBe(1);
375
+
376
+ const activation = await activateCompositionV1({
377
+ generationId: broken.generationId,
378
+ store: activationStore(store),
379
+ failures,
380
+ host: { mount: (generation) => Promise.resolve(mounted(generation)) },
381
+ signal: new AbortController().signal,
382
+ });
383
+
384
+ expect(activation.status).toBe("activated");
385
+ expect((await store.read(broken.generationId))?.status).toBe("active");
386
+ expect(
387
+ storage.values.get(compositionFailureCountKey(broken.generationId)),
388
+ ).toBeUndefined();
389
+ // The recorded failure survives: it is the repair history a User reads.
390
+ expect(await failures.list(broken.generationId)).toHaveLength(1);
391
+ });
392
+
393
+ test("a last known good that will not mount has nothing to fail into", async () => {
394
+ const { store, failures, lastKnownGood } = await fixture();
395
+ await expect(
396
+ activateCompositionV1({
397
+ generationId: lastKnownGood.generationId,
398
+ store: activationStore(store),
399
+ failures,
400
+ host: brokenHost(lastKnownGood.generationId, "mount"),
401
+ signal: new AbortController().signal,
402
+ }),
403
+ ).rejects.toThrow(/failed at mount/);
404
+ });
405
+ });
@@ -0,0 +1,108 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ // The Bot Durable Object is the authority for why a Composition generation
3
+ // failed to activate and for whether it is quarantined. Failures are durable,
4
+ // visible, and repairable: nothing here deletes a recorded failure, and the
5
+ // consecutive counter is reset only by a generation that finally activates.
6
+ import {
7
+ COMPOSITION_QUARANTINE_THRESHOLD,
8
+ decodeCompositionFailureV1,
9
+ decodeCompositionQuarantineV1,
10
+ type CompositionFailureInputV1,
11
+ type CompositionFailureLog,
12
+ type CompositionFailureOutcomeV1,
13
+ type CompositionFailureV1,
14
+ type CompositionQuarantineV1,
15
+ } from "@frockbot/kernel-composition/activation";
16
+ import {
17
+ compositionFailureCountKey,
18
+ compositionFailureKey,
19
+ compositionFailurePrefix,
20
+ compositionQuarantineKey,
21
+ } from "./storage-keys.js";
22
+
23
+ export interface DurableCompositionFailureLogOptions {
24
+ state: DurableObjectState;
25
+ /** Injected clock; the quarantine record is stamped with it. */
26
+ now?(): Date;
27
+ /** Consecutive failures that quarantine a generation. Defaults to three. */
28
+ threshold?: number;
29
+ }
30
+
31
+ /**
32
+ * `CompositionFailureLog` over the Bot object's prefixed keys:
33
+ * `composition:failure:<generationId>:<attempt>`,
34
+ * `composition:failure-count:<generationId>`, and
35
+ * `composition:quarantine:<generationId>`.
36
+ *
37
+ * Quarantine is per generation, never per Bot: a later, unrelated generation is
38
+ * unaffected by an earlier one being quarantined.
39
+ */
40
+ export class DurableCompositionFailureLog implements CompositionFailureLog {
41
+ private readonly ctx: DurableObjectState;
42
+ private readonly now: () => Date;
43
+ private readonly threshold: number;
44
+
45
+ constructor(options: DurableCompositionFailureLogOptions) {
46
+ this.ctx = options.state;
47
+ this.now = options.now ?? (() => new Date());
48
+ this.threshold = options.threshold ?? COMPOSITION_QUARANTINE_THRESHOLD;
49
+ }
50
+
51
+ /** The attempt number is assigned here, inside the counter's transaction. */
52
+ async record(
53
+ failure: CompositionFailureInputV1,
54
+ ): Promise<CompositionFailureOutcomeV1> {
55
+ const generationId = failure.generationId;
56
+ return this.ctx.storage.transaction(async (transaction) => {
57
+ const previous =
58
+ (await transaction.get<number>(
59
+ compositionFailureCountKey(generationId),
60
+ )) ?? 0;
61
+ const attempt = previous + 1;
62
+ const recorded = decodeCompositionFailureV1({ ...failure, attempt });
63
+ const quarantined = attempt >= this.threshold;
64
+ const writes: Record<string, unknown> = {
65
+ [compositionFailureKey(generationId, attempt)]: recorded,
66
+ [compositionFailureCountKey(generationId)]: attempt,
67
+ };
68
+ if (quarantined) {
69
+ writes[compositionQuarantineKey(generationId)] =
70
+ decodeCompositionQuarantineV1({
71
+ generationId,
72
+ quarantinedAt: this.now().toISOString(),
73
+ reason: recorded.message,
74
+ failures: attempt,
75
+ });
76
+ }
77
+ await transaction.put(writes);
78
+ return { consecutiveFailures: attempt, quarantined };
79
+ });
80
+ }
81
+
82
+ /** Oldest attempt first. */
83
+ async list(generationId: string): Promise<CompositionFailureV1[]> {
84
+ const entries = await this.ctx.storage.list<unknown>({
85
+ prefix: compositionFailurePrefix(generationId),
86
+ });
87
+ return [...entries.values()].map(decodeCompositionFailureV1);
88
+ }
89
+
90
+ async quarantine(
91
+ generationId: string,
92
+ ): Promise<CompositionQuarantineV1 | undefined> {
93
+ const stored = await this.ctx.storage.get<unknown>(
94
+ compositionQuarantineKey(generationId),
95
+ );
96
+ return stored === undefined
97
+ ? undefined
98
+ : decodeCompositionQuarantineV1(stored);
99
+ }
100
+
101
+ /**
102
+ * A generation that finally activates starts its consecutive count over. The
103
+ * recorded failures survive: they are the repair history a User reads.
104
+ */
105
+ async clear(generationId: string): Promise<void> {
106
+ await this.ctx.storage.delete(compositionFailureCountKey(generationId));
107
+ }
108
+ }