@frockbot/kernel-do 0.3.15 → 0.3.16

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,250 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ bootstrapGeneration,
4
+ type CompositionGenerationV1,
5
+ } from "@frockbot/kernel-composition/generation";
6
+ import { Session, type SessionEvent } from "@frockbot/kernel-contracts";
7
+ import {
8
+ BotDurableAuthority,
9
+ type BotDurableAuthorityHooks,
10
+ } from "./authority.ts";
11
+ import { MemoryStorage } from "./memory-storage.fixture.ts";
12
+ import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
13
+
14
+ const SQLITE_VALUE_LIMIT_BYTES = 2 * 1024 * 1024;
15
+ const LARGE_REQUEST_BYTES = 80_000;
16
+ const STEPS = 30;
17
+ const SESSION_ID = "user-1:primary";
18
+
19
+ const codec = createStoredRunCodecV1<undefined>({
20
+ decodeRunId: (value) => String(value),
21
+ decodeConfigurationSnapshot: () => undefined,
22
+ });
23
+
24
+ /** A storage double that enforces Durable Object SQLite's per-value ceiling. */
25
+ class SqliteLimitedMemoryStorage extends MemoryStorage {
26
+ readonly rejectedKeys: string[] = [];
27
+
28
+ override put(
29
+ key: string | Record<string, unknown>,
30
+ value?: unknown,
31
+ ): Promise<void> {
32
+ const entries =
33
+ typeof key === "string" ? [[key, value] as const] : Object.entries(key);
34
+ for (const [entryKey, entry] of entries) {
35
+ const bytes = new TextEncoder().encode(JSON.stringify(entry)).byteLength;
36
+ if (bytes > SQLITE_VALUE_LIMIT_BYTES) {
37
+ this.rejectedKeys.push(entryKey);
38
+ throw new Error("string or blob too big: SQLITE_TOOBIG");
39
+ }
40
+ }
41
+ return super.put(key, value);
42
+ }
43
+
44
+ override async transaction<T>(
45
+ callback: (storage: MemoryStorage) => Promise<T>,
46
+ ): Promise<T> {
47
+ const before = structuredClone([...this.values.entries()]);
48
+ try {
49
+ return await callback(this);
50
+ } catch (error) {
51
+ this.values.clear();
52
+ for (const [key, value] of before) this.values.set(key, value);
53
+ throw error;
54
+ }
55
+ }
56
+ }
57
+
58
+ function bootstrap(): Promise<CompositionGenerationV1> {
59
+ return bootstrapGeneration(
60
+ [
61
+ {
62
+ packageId: "shell",
63
+ specifier: "@frockbot/plugin-shell",
64
+ version: "0.0.1",
65
+ manifest: { id: "shell", version: "0.0.1" },
66
+ },
67
+ ],
68
+ { createdAt: "2026-09-04T00:00:00.000Z" },
69
+ );
70
+ }
71
+
72
+ function legacyEvents(): SessionEvent[] {
73
+ const session = new Session(SESSION_ID, () => {});
74
+ session.appendBatch([
75
+ { type: "turn/start", turn: 1 },
76
+ { type: "step/start", turn: 1, step: 1 },
77
+ {
78
+ type: "user/message",
79
+ turn: 1,
80
+ step: 1,
81
+ messageId: "legacy-message",
82
+ text: "Earlier work",
83
+ },
84
+ {
85
+ type: "model/request",
86
+ turn: 1,
87
+ step: 1,
88
+ request: {
89
+ requestId: "legacy-request",
90
+ provider: "fake",
91
+ model: "large-context",
92
+ system: "p".repeat(1_900_000),
93
+ messages: [{ role: "user", content: "Earlier work" }],
94
+ tools: [],
95
+ },
96
+ },
97
+ {
98
+ type: "assistant/message",
99
+ turn: 1,
100
+ step: 1,
101
+ requestId: "legacy-request",
102
+ text: "Done earlier.",
103
+ toolCalls: [],
104
+ },
105
+ { type: "step/end", turn: 1, step: 1, outcome: "completed" },
106
+ { type: "turn/end", turn: 1, outcome: "completed" },
107
+ ]);
108
+ return [...session.events];
109
+ }
110
+
111
+ function legacyRun(events: SessionEvent[]): StoredRunV1<undefined> {
112
+ return {
113
+ runId: "legacy-run",
114
+ commandFingerprint: "legacy-fingerprint",
115
+ sessionId: SESSION_ID,
116
+ acceptedAt: "2026-09-04T00:00:00.000Z",
117
+ input: "Earlier work",
118
+ events,
119
+ effectAdmissions: [],
120
+ status: "completed",
121
+ responseText: "Done earlier.",
122
+ phase: "executing",
123
+ compositionGenerationId: "legacy-generation",
124
+ configurationSnapshot: undefined,
125
+ previousEventCount: 0,
126
+ };
127
+ }
128
+
129
+ function createAuthority(
130
+ storage: SqliteLimitedMemoryStorage,
131
+ ): BotDurableAuthority<undefined> {
132
+ const hooks: BotDurableAuthorityHooks<undefined> = {
133
+ resolveAdmissionSnapshot: () => Promise.resolve(undefined),
134
+ bootstrapComposition: () => bootstrap(),
135
+ admittedSnapshot: () => Promise.resolve(undefined),
136
+ executeTurn: async (input) => {
137
+ let seq = input.previousEvents.length;
138
+ const events: SessionEvent[] = [];
139
+ const persist = async (
140
+ batch: Array<Omit<SessionEvent, "seq" | "timestamp">>,
141
+ ) => {
142
+ const stamped = batch.map(
143
+ (event) =>
144
+ ({
145
+ ...event,
146
+ seq: seq++,
147
+ timestamp: "2026-09-04T00:01:00.000Z",
148
+ }) as SessionEvent,
149
+ );
150
+ events.push(...stamped);
151
+ await input.persistSessionEvents(input.command.sessionId, stamped);
152
+ };
153
+
154
+ await persist([{ type: "turn/start", turn: 2 } as never]);
155
+ for (let step = 1; step <= STEPS; step += 1) {
156
+ await persist([
157
+ { type: "step/start", turn: 2, step } as never,
158
+ ...(step === 1
159
+ ? [
160
+ {
161
+ type: "user/message",
162
+ turn: 2,
163
+ step,
164
+ messageId: "message-2",
165
+ text: input.command.text,
166
+ } as never,
167
+ ]
168
+ : []),
169
+ {
170
+ type: "model/request",
171
+ turn: 2,
172
+ step,
173
+ request: {
174
+ requestId: `request-${step}`,
175
+ provider: "fake",
176
+ model: "large-context",
177
+ system: "s".repeat(LARGE_REQUEST_BYTES),
178
+ messages: [{ role: "user", content: input.command.text }],
179
+ tools: [],
180
+ },
181
+ } as never,
182
+ {
183
+ type: "assistant/message",
184
+ turn: 2,
185
+ step,
186
+ requestId: `request-${step}`,
187
+ text: step === STEPS ? "All done." : "",
188
+ toolCalls: [],
189
+ } as never,
190
+ { type: "step/end", turn: 2, step, outcome: "completed" } as never,
191
+ ]);
192
+ }
193
+ await persist([
194
+ { type: "turn/end", turn: 2, outcome: "completed" } as never,
195
+ ]);
196
+ return { runId: input.command.runId, text: "All done.", events };
197
+ },
198
+ notification: () => undefined,
199
+ scheduledDeadlines: () => Promise.resolve([]),
200
+ scheduledWorkInFlight: () => false,
201
+ deferScheduledWork: () => Promise.resolve(),
202
+ settleScheduledWork: () => Promise.resolve(),
203
+ };
204
+ return new BotDurableAuthority<undefined>({
205
+ state: { storage } as unknown as DurableObjectState,
206
+ codec,
207
+ hooks,
208
+ });
209
+ }
210
+
211
+ describe("a long Turn on a legacy near-limit Session", () => {
212
+ test("migrates to bounded values and completes thirty large model steps", async () => {
213
+ const storage = new SqliteLimitedMemoryStorage();
214
+ const previous = legacyEvents();
215
+ const legacyBytes = new TextEncoder().encode(
216
+ JSON.stringify(previous),
217
+ ).byteLength;
218
+ expect(legacyBytes).toBeGreaterThan(1_900_000);
219
+ expect(legacyBytes).toBeLessThan(SQLITE_VALUE_LIMIT_BYTES);
220
+ // Seed through the backing map because these are values an older deploy
221
+ // already wrote. Every write made by the code under test is size-checked.
222
+ storage.values.set("latest-events", structuredClone(previous));
223
+ storage.values.set("run:legacy-run", structuredClone(legacyRun(previous)));
224
+
225
+ const completion = await createAuthority(storage).run({
226
+ userId: "user-1",
227
+ botId: "primary",
228
+ runId: "large-run",
229
+ sessionId: SESSION_ID,
230
+ acceptedAt: "2026-09-04T00:01:00.000Z",
231
+ text: "Keep going",
232
+ });
233
+
234
+ const stored = codec.require(storage.values.get("run:large-run"));
235
+ expect(storage.rejectedKeys).not.toContain("run:large-run");
236
+ expect(stored.failure ?? "").not.toContain("SQLITE_TOOBIG");
237
+ expect(completion.text).toBe("All done.");
238
+ expect(
239
+ completion.events.filter((event) => event.type === "model/request"),
240
+ ).toHaveLength(STEPS);
241
+ expect(storage.values.has("latest-events")).toBe(false);
242
+ expect(
243
+ [...storage.values.values()].every(
244
+ (value) =>
245
+ new TextEncoder().encode(JSON.stringify(value)).byteLength <=
246
+ SQLITE_VALUE_LIMIT_BYTES,
247
+ ),
248
+ ).toBe(true);
249
+ });
250
+ });
@@ -16,7 +16,11 @@ export const ACTIVE_RUN_KEY = "active-run";
16
16
  * working through a backlog of things the User has already replaced.
17
17
  */
18
18
  export const PENDING_RUN_KEY = "pending-run";
19
+ /** Legacy pre-ADR-0033 Session value, read only for transparent migration. */
19
20
  export const LATEST_EVENTS_KEY = "latest-events";
21
+ export const SESSION_EVENT_LOG_INDEX_PREFIX = "session-events:index:";
22
+ export const SESSION_EVENT_LOG_PAGE_PREFIX = "session-events:page:";
23
+ export const SESSION_EVENT_PAYLOAD_PREFIX = "session-events:payload:";
20
24
  export const IDENTITY_KEY = "identity";
21
25
  export const NOTIFICATION_PREFIX = "notification:";
22
26
  export const COMPOSITION_CURRENT_KEY = "composition:current";
@@ -10,6 +10,7 @@ import {
10
10
  type BotTurnExecutionInput,
11
11
  } from "./authority.ts";
12
12
  import { MemoryStorage } from "./memory-storage.fixture.ts";
13
+ import { SessionEventLog } from "./session-event-log.ts";
13
14
  import {
14
15
  botTurnCommandFingerprintV1,
15
16
  createStoredRunCodecV1,
@@ -359,9 +360,14 @@ function command(runId: string, turnType?: TurnTypeV1) {
359
360
  };
360
361
  }
361
362
 
362
- function admittedEvent(storage: MemoryStorage, runId: string) {
363
+ async function admittedEvent(storage: MemoryStorage, runId: string) {
363
364
  const run = storage.values.get(`run:${runId}`) as StoredRunV1<undefined>;
364
- return run.events.find((event) => event.type === "turn/admission");
365
+ const events = await new SessionEventLog(storage).readRange(
366
+ run.sessionId,
367
+ run.eventRange!.startSeq,
368
+ run.eventRange!.endSeq,
369
+ );
370
+ return events.find((event) => event.type === "turn/admission");
365
371
  }
366
372
 
367
373
  describe("an admitted Turn re-mounts on its recorded turn type", () => {
@@ -389,7 +395,7 @@ describe("an admitted Turn re-mounts on its recorded turn type", () => {
389
395
  turnType: "automation",
390
396
  });
391
397
  expect(probe.observed[0]?.command.turnType).toBe("automation");
392
- expect(admittedEvent(storage, "run-1")).toMatchObject({
398
+ expect(await admittedEvent(storage, "run-1")).toMatchObject({
393
399
  turnType: "automation",
394
400
  });
395
401
  });
@@ -407,17 +413,17 @@ describe("an admitted Turn re-mounts on its recorded turn type", () => {
407
413
  status: "running",
408
414
  phase: "executing",
409
415
  responseText: undefined,
410
- events: [],
416
+ eventRange: { startSeq: 0, endSeq: 0 },
411
417
  });
412
418
  storage.values.set("active-run", "run-1");
413
419
  storage.values.set("identity", { userId: "user-1", botId: "primary" });
414
- storage.values.set("latest-events", []);
420
+ await new SessionEventLog(storage).rewrite("user-1:primary", []);
415
421
 
416
422
  const resumed = createAuthority(storage);
417
423
  await resumed.authority.recoverActiveRun();
418
424
 
419
425
  expect(resumed.observed.at(-1)?.command.turnType).toBe("automation");
420
- expect(admittedEvent(storage, "run-1")).toMatchObject({
426
+ expect(await admittedEvent(storage, "run-1")).toMatchObject({
421
427
  turnType: "automation",
422
428
  });
423
429
  });
@@ -433,17 +439,17 @@ describe("an admitted Turn re-mounts on its recorded turn type", () => {
433
439
  status: "running",
434
440
  phase: "executing",
435
441
  responseText: undefined,
436
- events: [],
442
+ eventRange: { startSeq: 0, endSeq: 0 },
437
443
  });
438
444
  storage.values.set("active-run", "run-1");
439
445
  storage.values.set("identity", { userId: "user-1", botId: "primary" });
440
- storage.values.set("latest-events", []);
446
+ await new SessionEventLog(storage).rewrite("user-1:primary", []);
441
447
 
442
448
  const resumed = createAuthority(storage);
443
449
  await resumed.authority.recoverActiveRun();
444
450
 
445
451
  expect(resumed.observed.at(-1)?.command.turnType).toBe("chat");
446
- expect(admittedEvent(storage, "run-1")).toMatchObject({
452
+ expect(await admittedEvent(storage, "run-1")).toMatchObject({
447
453
  turnType: "chat",
448
454
  });
449
455
  });
@@ -15,6 +15,7 @@ import {
15
15
  type OwnedBotTurnCommand,
16
16
  } from "./authority.ts";
17
17
  import { MemoryStorage } from "./memory-storage.fixture.ts";
18
+ import { SessionEventLog } from "./session-event-log.ts";
18
19
  import {
19
20
  BotTurnReconciliationRequiredError,
20
21
  BotTurnRecoveryRequiredError,
@@ -92,8 +93,13 @@ interface Deferred<T> {
92
93
  * requests never reach a Durable Object in the same microtask, and the tests
93
94
  * that send two messages are describing two requests.
94
95
  */
95
- function admitted(): Promise<void> {
96
- return new Promise((resolve) => setTimeout(resolve, 0));
96
+ async function admitted(): Promise<void> {
97
+ // Admission now also hashes any legacy event payloads it migrates. Let the
98
+ // Web Crypto promises and the Durable Object transaction both drain before
99
+ // inspecting the durable queue.
100
+ for (let turn = 0; turn < 8; turn += 1) {
101
+ await new Promise((resolve) => setTimeout(resolve, 0));
102
+ }
97
103
  }
98
104
 
99
105
  function deferred<T>(): Deferred<T> {
@@ -357,7 +363,9 @@ describe("a user message supersedes the running Turn", () => {
357
363
  probe.handle("run-2").finish();
358
364
  const result = await second;
359
365
 
360
- const superseded = storedRun(storage, "run-1");
366
+ const superseded = await probe.authority.readRun("run-1");
367
+ expect(superseded).toBeDefined();
368
+ if (superseded === undefined) throw new Error("run-1 was not stored");
361
369
  expect(superseded.status).toBe("superseded");
362
370
  expect(superseded.supersededBy).toBe("run-2");
363
371
  expect(turnEndOf(superseded)).toMatchObject({
@@ -681,10 +689,7 @@ describe("a durable log left inside a Turn", () => {
681
689
  probe.handle("run-1").finish();
682
690
  await run;
683
691
 
684
- const events = storage.values.get("latest-events") as Array<{
685
- type: string;
686
- turn?: number;
687
- }>;
692
+ const events = await new SessionEventLog(storage).read("user-1:primary");
688
693
  // The orphaned Turn is closed, so the new one starts.
689
694
  expect(events[2]).toMatchObject({
690
695
  type: "turn/end",
@@ -738,7 +743,7 @@ describe("a durable log left inside a Turn", () => {
738
743
  probe.handle("run-1").finish();
739
744
  await run;
740
745
 
741
- const events = storage.values.get("latest-events") as SessionEvent[];
746
+ const events = await new SessionEventLog(storage).read("user-1:primary");
742
747
  // Turn 1 is closed where it was abandoned, not after the Turns that
743
748
  // followed it, and the log is resequenced around the insertion.
744
749
  expect(