@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.
- package/package.json +3 -3
- package/src/authority.ts +176 -136
- package/src/conversations.test.ts +7 -9
- package/src/index.ts +1 -0
- package/src/model-timeout-settlement.test.ts +8 -8
- package/src/run-liveness.test.ts +2 -1
- package/src/run-records.ts +93 -2
- package/src/run-terminal-open-turn.test.ts +9 -4
- package/src/run-terminal.ts +61 -44
- package/src/session-event-log.test.ts +267 -0
- package/src/session-event-log.ts +731 -0
- package/src/session-log-size.test.ts +250 -0
- package/src/storage-keys.ts +4 -0
- package/src/turn-admission.test.ts +15 -9
- package/src/turn-supersede.test.ts +13 -8
|
@@ -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
|
+
});
|
package/src/storage-keys.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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(
|