@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.
@@ -31,7 +31,7 @@ import {
31
31
  BotTurnExecutionError,
32
32
  BotTurnReconciliationRequiredError,
33
33
  } from "./turn-errors.ts";
34
- import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
34
+ import { createStoredRunCodecV1 } from "./run-records.ts";
35
35
 
36
36
  const codec = createStoredRunCodecV1<undefined>({
37
37
  decodeRunId: (value) => value as string,
@@ -188,11 +188,11 @@ function createAuthority(
188
188
  });
189
189
  }
190
190
 
191
- function storedRun(
192
- storage: MemoryStorage,
191
+ async function storedRun(
192
+ authority: BotDurableAuthority<undefined>,
193
193
  runId: string,
194
- ): StoredRunV1<undefined> {
195
- return codec.require(storage.values.get(`run:${runId}`));
194
+ ) {
195
+ return (await authority.readRun(runId))!;
196
196
  }
197
197
 
198
198
  describe("a model request that ran out of time", () => {
@@ -205,7 +205,7 @@ describe("a model request that ran out of time", () => {
205
205
  const completion = await authority.run(command("run-1", "build me one"));
206
206
 
207
207
  expect(completion.runId).toBe("run-1");
208
- const run = storedRun(storage, "run-1");
208
+ const run = await storedRun(authority, "run-1");
209
209
  expect(run.status).toBe("failed");
210
210
  // The ordinary run-terminal path: the open Turn is closed rather than left
211
211
  // for the next message to trip over.
@@ -233,7 +233,7 @@ describe("a model request that ran out of time", () => {
233
233
  authority.run(command("run-1", "build me one")),
234
234
  ).rejects.toThrow();
235
235
 
236
- const run = storedRun(storage, "run-1");
236
+ const run = await storedRun(authority, "run-1");
237
237
  expect(run.status).toBe("reconciliation-required");
238
238
  expect(run.events.some((event) => event.type === "turn/end")).toBe(false);
239
239
  });
@@ -252,7 +252,7 @@ describe("a model request that ran out of time", () => {
252
252
  const completion = await authority.run(command("run-1", "hello"));
253
253
 
254
254
  expect(completion.runId).toBe("run-1");
255
- const run = storedRun(storage, "run-1");
255
+ const run = await storedRun(authority, "run-1");
256
256
  expect(run.status).toBe("failed");
257
257
  expect(
258
258
  run.events.findLast((event) => event.type === "turn/end"),
@@ -28,6 +28,7 @@ import {
28
28
  STALE_RUNNING_RUN_FAILURE_V1,
29
29
  STALE_RUNNING_RUN_GRACE_MS_V1,
30
30
  } from "./run-liveness.ts";
31
+ import { SessionEventLog } from "./session-event-log.ts";
31
32
  import {
32
33
  ACTIVE_RUN_KEY,
33
34
  IDENTITY_KEY,
@@ -255,7 +256,7 @@ describe("the read that repairs what it finds", () => {
255
256
  // The Bot is free: nothing holds the object, and the next Turn admits
256
257
  // against a log that reads as a complete history.
257
258
  expect(await storage.get<string>(ACTIVE_RUN_KEY)).toBeUndefined();
258
- const log = (await storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? [];
259
+ const log = await new SessionEventLog(storage).read("user-1:primary");
259
260
  expect(log.some((entry) => entry.type === "turn/end")).toBe(true);
260
261
  });
261
262
 
@@ -133,6 +133,12 @@ export interface StoredRunV1<Snapshot = unknown> {
133
133
  acceptedAt: string;
134
134
  input: string;
135
135
  events: SessionEvent[];
136
+ /**
137
+ * Inclusive/exclusive coordinates of this Turn in the authoritative Session
138
+ * log. New durable records carry this instead of embedding `events`; the
139
+ * in-memory record is hydrated through the Session log accessor.
140
+ */
141
+ eventRange?: StoredRunEventRangeV1;
136
142
  effectAdmissions: StoredEffectAdmission[];
137
143
  status: StoredRunStatus;
138
144
  responseText?: string;
@@ -159,6 +165,44 @@ export interface StoredRunV1<Snapshot = unknown> {
159
165
  directTool?: DirectToolCommandV1;
160
166
  }
161
167
 
168
+ export interface StoredRunEventRangeV1 {
169
+ startSeq: number;
170
+ endSeq: number;
171
+ }
172
+
173
+ /** Keeps a hydrated journal and its durable coordinates in lockstep. */
174
+ export function storedRunEventFieldsV2(
175
+ previousEventCount: number,
176
+ events: SessionEvent[],
177
+ ): { events: SessionEvent[]; eventRange: StoredRunEventRangeV1 } {
178
+ return {
179
+ events,
180
+ eventRange: {
181
+ startSeq: previousEventCount,
182
+ endSeq: previousEventCount + events.length,
183
+ },
184
+ };
185
+ }
186
+
187
+ /**
188
+ * The compact durable run shape. Keeping this encoder beside the strict
189
+ * decoder makes it difficult for a metadata update to accidentally put a
190
+ * hydrated multi-megabyte journal back into one SQLite value.
191
+ */
192
+ export function storedRunRecordV2<Snapshot>(
193
+ run: StoredRunV1<Snapshot>,
194
+ ): Omit<StoredRunV1<Snapshot>, "events"> {
195
+ const { events, ...record } = run;
196
+ const eventRange =
197
+ events.length === 0 && run.eventRange
198
+ ? run.eventRange
199
+ : {
200
+ startSeq: run.previousEventCount,
201
+ endSeq: run.previousEventCount + events.length,
202
+ };
203
+ return { ...record, eventRange };
204
+ }
205
+
162
206
  export interface DirectToolCommandV1 {
163
207
  generationId: string;
164
208
  packageId: string;
@@ -246,7 +290,6 @@ const STORED_RUN_REQUIRED_KEYS = [
246
290
  "sessionId",
247
291
  "acceptedAt",
248
292
  "input",
249
- "events",
250
293
  "effectAdmissions",
251
294
  "status",
252
295
  "phase",
@@ -255,6 +298,8 @@ const STORED_RUN_REQUIRED_KEYS = [
255
298
  "previousEventCount",
256
299
  ] as const;
257
300
  const STORED_RUN_OPTIONAL_KEYS = [
301
+ "events",
302
+ "eventRange",
258
303
  "responseText",
259
304
  "failure",
260
305
  "stopRequestedAt",
@@ -483,6 +528,32 @@ function decodeStoredRunEvents(value: unknown): SessionEvent[] {
483
528
  return value.map(decodeSessionEvent);
484
529
  }
485
530
 
531
+ function decodeStoredRunEventRange(
532
+ value: unknown,
533
+ runId: string,
534
+ ): StoredRunEventRangeV1 {
535
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
536
+ throw new Error(`run "${runId}" has an invalid event range`);
537
+ }
538
+ const candidate = value as Record<PropertyKey, unknown>;
539
+ if (
540
+ Reflect.ownKeys(candidate).length !== 2 ||
541
+ Object.keys(candidate).length !== 2 ||
542
+ !Object.hasOwn(candidate, "startSeq") ||
543
+ !Object.hasOwn(candidate, "endSeq") ||
544
+ !Number.isSafeInteger(candidate.startSeq) ||
545
+ !Number.isSafeInteger(candidate.endSeq) ||
546
+ (candidate.startSeq as number) < 0 ||
547
+ (candidate.endSeq as number) < (candidate.startSeq as number)
548
+ ) {
549
+ throw new Error(`run "${runId}" has an invalid event range`);
550
+ }
551
+ return {
552
+ startSeq: candidate.startSeq as number,
553
+ endSeq: candidate.endSeq as number,
554
+ };
555
+ }
556
+
486
557
  const STORED_EFFECT_ADMISSIONS_MAX = 256;
487
558
  const STORED_EFFECT_ID_MAX_BYTES = 512;
488
559
 
@@ -582,7 +653,17 @@ function requireStoredRunRecordV1<Snapshot>(
582
653
  if (!boundedString(candidate.input, 32_000)) {
583
654
  throw new Error(`run "${runId}" has no valid input`);
584
655
  }
585
- const events = decodeStoredRunEvents(candidate.events);
656
+ if (candidate.events === undefined && candidate.eventRange === undefined) {
657
+ throw new Error(`run "${runId}" has no event journal reference`);
658
+ }
659
+ const events =
660
+ candidate.events === undefined
661
+ ? []
662
+ : decodeStoredRunEvents(candidate.events);
663
+ const eventRange =
664
+ candidate.eventRange === undefined
665
+ ? undefined
666
+ : decodeStoredRunEventRange(candidate.eventRange, runId);
586
667
  const effectAdmissions = decodeStoredEffectAdmissions(
587
668
  candidate.effectAdmissions,
588
669
  );
@@ -605,6 +686,15 @@ function requireStoredRunRecordV1<Snapshot>(
605
686
  ) {
606
687
  throw new Error(`run "${runId}" has no valid previous event count`);
607
688
  }
689
+ if (
690
+ eventRange &&
691
+ (eventRange.startSeq !== candidate.previousEventCount ||
692
+ (events.length > 0 &&
693
+ (events[0]?.seq !== eventRange.startSeq ||
694
+ events.at(-1)!.seq + 1 !== eventRange.endSeq)))
695
+ ) {
696
+ throw new Error(`run "${runId}" has an inconsistent event range`);
697
+ }
608
698
  const configurationSnapshot = options.decodeConfigurationSnapshot(
609
699
  candidate.configurationSnapshot,
610
700
  );
@@ -679,6 +769,7 @@ function requireStoredRunRecordV1<Snapshot>(
679
769
  acceptedAt: candidate.acceptedAt,
680
770
  input: candidate.input,
681
771
  events,
772
+ ...(eventRange ? { eventRange } : {}),
682
773
  effectAdmissions,
683
774
  status,
684
775
  phase,
@@ -14,6 +14,7 @@ import {
14
14
  validateToolOccurrenceJournal,
15
15
  } from "@frockbot/kernel-contracts";
16
16
  import { MemoryStorage } from "./memory-storage.fixture.ts";
17
+ import { SessionEventLog } from "./session-event-log.ts";
17
18
  import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
18
19
  import {
19
20
  cancelStoredRun,
@@ -117,7 +118,7 @@ async function settled(
117
118
 
118
119
  return {
119
120
  storage,
120
- latest: storage.values.get(KEYS.latestEvents) as SessionEvent[],
121
+ latest: await new SessionEventLog(storage).read(SESSION_ID),
121
122
  };
122
123
  }
123
124
 
@@ -141,9 +142,13 @@ describe("settling a Turn interrupted mid-answer", () => {
141
142
  expect(() => admitNextTurn(latest)).not.toThrow();
142
143
  expect(latest.at(-1)).toMatchObject({ type: "turn/end", turn: 1 });
143
144
  // The settled record carries the same closed account, not a different one.
144
- const record = storage.values.get(KEYS.run) as StoredRunV1<null>;
145
+ const record = storage.values.get(KEYS.run) as Omit<
146
+ StoredRunV1<null>,
147
+ "events"
148
+ >;
145
149
  expect(record.status).toBe("superseded");
146
- expect(record.events.at(-1)).toMatchObject({ type: "turn/end", turn: 1 });
150
+ expect(Object.hasOwn(record, "events")).toBe(false);
151
+ expect(record.eventRange).toEqual({ startSeq: 0, endSeq: latest.length });
147
152
  });
148
153
 
149
154
  test("a stopped run leaves a log the next Turn can start on", async () => {
@@ -196,7 +201,7 @@ describe("settling a Turn interrupted mid-answer", () => {
196
201
 
197
202
  await cancelStoredRun(codec, storage, KEYS, "run-1", [], events);
198
203
 
199
- const latest = storage.values.get(KEYS.latestEvents) as SessionEvent[];
204
+ const latest = await new SessionEventLog(storage).read(SESSION_ID);
200
205
  expect(latest.filter((event) => event.type === "turn/end")).toHaveLength(1);
201
206
  expect(() => admitNextTurn(latest)).not.toThrow();
202
207
  });
@@ -8,7 +8,13 @@ import type {
8
8
  StoredRunCodecV1,
9
9
  StoredRunV1,
10
10
  } from "./run-records.js";
11
+ import { storedRunRecordV2 } from "./run-records.js";
12
+ import { storedRunEventFieldsV2 } from "./run-records.js";
11
13
  import { repairedSessionLogV1 } from "./run-recovery.js";
14
+ import {
15
+ SessionEventLog,
16
+ type SessionEventLogStorage,
17
+ } from "./session-event-log.js";
12
18
 
13
19
  /**
14
20
  * The events a terminal settlement commits, with any Turn they were left
@@ -57,11 +63,7 @@ function settledEventsV1(
57
63
  };
58
64
  }
59
65
 
60
- export interface RunTerminalStorage {
61
- get<T>(key: string): Promise<T | undefined>;
62
- put(entries: Record<string, unknown>): Promise<void>;
63
- delete(key: string): Promise<boolean>;
64
- }
66
+ export interface RunTerminalStorage extends SessionEventLogStorage {}
65
67
 
66
68
  export interface RunTerminalKeys {
67
69
  run: string;
@@ -70,6 +72,24 @@ export interface RunTerminalKeys {
70
72
  notificationPrefix: string;
71
73
  }
72
74
 
75
+ async function hydratedRun<Snapshot>(
76
+ codec: StoredRunCodecV1<Snapshot>,
77
+ storage: RunTerminalStorage,
78
+ key: string,
79
+ ): Promise<StoredRunV1<Snapshot> | undefined> {
80
+ const stored = codec.optional(await storage.get<unknown>(key));
81
+ if (!stored?.eventRange) return stored;
82
+ const events = await new SessionEventLog(storage).readRange(
83
+ stored.sessionId,
84
+ stored.eventRange.startSeq,
85
+ stored.eventRange.endSeq,
86
+ );
87
+ if (events.length !== stored.eventRange.endSeq - stored.eventRange.startSeq) {
88
+ throw new Error(`run "${stored.runId}" has an incomplete event range`);
89
+ }
90
+ return codec.require({ ...stored, events });
91
+ }
92
+
73
93
  /**
74
94
  * Records a Package writes in the same transaction that settles a Turn. The
75
95
  * kernel never reads them: it is handed opaque key/value pairs and a reader
@@ -126,9 +146,8 @@ export async function supersedeStoredRun<Snapshot>(
126
146
  events: readonly SessionEvent[],
127
147
  packageRecords?: SupersededPackageRecords<Snapshot>,
128
148
  ): Promise<"superseded"> {
129
- const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
130
- if (!stored) throw new Error(`run "${runId}" was not accepted`);
131
- const run = codec.require(stored);
149
+ const run = await hydratedRun(codec, storage, keys.run);
150
+ if (!run) throw new Error(`run "${runId}" was not accepted`);
132
151
  if (!run.supersededAt) {
133
152
  throw new Error(`run "${runId}" has no durable supersede intent`);
134
153
  }
@@ -141,7 +160,10 @@ export async function supersedeStoredRun<Snapshot>(
141
160
  const queued = settled.phase === "queued";
142
161
  const superseded = codec.require({
143
162
  ...settled,
144
- events: queued ? [] : decodedEvents,
163
+ ...storedRunEventFieldsV2(
164
+ run.previousEventCount,
165
+ queued ? [] : decodedEvents,
166
+ ),
145
167
  status: "superseded",
146
168
  phase:
147
169
  settled.phase === "reconciliation-required"
@@ -151,12 +173,7 @@ export async function supersedeStoredRun<Snapshot>(
151
173
  : settled.phase,
152
174
  } satisfies StoredRunV1<Snapshot>);
153
175
  const records: Record<string, unknown> = {
154
- [keys.run]: structuredClone(superseded),
155
- ...(queued
156
- ? {}
157
- : {
158
- [keys.latestEvents]: structuredClone(settledEvents.latestEvents),
159
- }),
176
+ [keys.run]: structuredClone(storedRunRecordV2(superseded)),
160
177
  };
161
178
  if (packageRecords && !queued) {
162
179
  const contributed = await packageRecords({
@@ -168,6 +185,12 @@ export async function supersedeStoredRun<Snapshot>(
168
185
  records[key] = structuredClone(value);
169
186
  }
170
187
  }
188
+ if (!queued) {
189
+ await new SessionEventLog(storage).rewrite(
190
+ run.sessionId,
191
+ settledEvents.latestEvents,
192
+ );
193
+ }
171
194
  await storage.put(records);
172
195
  if ((await storage.get<string>(keys.activeRun)) === runId) {
173
196
  await storage.delete(keys.activeRun);
@@ -187,9 +210,8 @@ export async function completeStoredRun<Snapshot>(
187
210
  ): Promise<"completed" | "cancelled" | "superseded"> {
188
211
  const activeRunId = await storage.get<string>(keys.activeRun);
189
212
  if (activeRunId !== runId) throw new Error(`run "${runId}" is not active`);
190
- const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
191
- if (!stored) throw new Error(`run "${runId}" was not accepted`);
192
- const run = codec.require(stored);
213
+ const run = await hydratedRun(codec, storage, keys.run);
214
+ if (!run) throw new Error(`run "${runId}" was not accepted`);
193
215
  const events = result.events.map(decodeSessionEvent);
194
216
  const latestEvents = [...previous, ...events].map(decodeSessionEvent);
195
217
  // Stop outranks supersede: the User asked for this Turn to stop, and a
@@ -212,29 +234,28 @@ export async function completeStoredRun<Snapshot>(
212
234
  const { responseText: _text, failure: _failure, ...settled } = run;
213
235
  const cancelled = codec.require({
214
236
  ...settled,
215
- events,
237
+ ...storedRunEventFieldsV2(run.previousEventCount, events),
216
238
  status: "cancelled",
217
239
  phase:
218
240
  settled.phase === "reconciliation-required"
219
241
  ? "executing"
220
242
  : settled.phase,
221
243
  } satisfies StoredRunV1<Snapshot>);
244
+ await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
222
245
  await storage.put({
223
- [keys.run]: structuredClone(cancelled),
224
- [keys.latestEvents]: structuredClone(latestEvents),
246
+ [keys.run]: structuredClone(storedRunRecordV2(cancelled)),
225
247
  });
226
248
  await storage.delete(keys.activeRun);
227
249
  return "cancelled";
228
250
  }
229
251
  const completed = codec.require({
230
252
  ...run,
231
- events,
253
+ ...storedRunEventFieldsV2(run.previousEventCount, events),
232
254
  status: "completed",
233
255
  responseText: result.text,
234
256
  } satisfies StoredRunV1<Snapshot>);
235
257
  const records: Record<string, unknown> = {
236
- [keys.run]: structuredClone(completed),
237
- [keys.latestEvents]: structuredClone(latestEvents),
258
+ [keys.run]: structuredClone(storedRunRecordV2(completed)),
238
259
  };
239
260
  if (result.notification) {
240
261
  records[`${keys.notificationPrefix}${result.notification.notificationId}`] =
@@ -250,6 +271,7 @@ export async function completeStoredRun<Snapshot>(
250
271
  records[key] = structuredClone(value);
251
272
  }
252
273
  }
274
+ await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
253
275
  await storage.put(records);
254
276
  await storage.delete(keys.activeRun);
255
277
  return "completed";
@@ -267,9 +289,8 @@ export async function cancelStoredRun<Snapshot>(
267
289
  previous: readonly SessionEvent[],
268
290
  events: readonly SessionEvent[],
269
291
  ): Promise<"cancelled" | "preserved-completion" | "missing"> {
270
- const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
271
- if (!stored) return "missing";
272
- const run = codec.require(stored);
292
+ const run = await hydratedRun(codec, storage, keys.run);
293
+ if (!run) return "missing";
273
294
  if (run.status === "completed") return "preserved-completion";
274
295
  if (!run.stopRequestedAt) {
275
296
  throw new Error(`run "${runId}" has no durable stop intent`);
@@ -280,14 +301,14 @@ export async function cancelStoredRun<Snapshot>(
280
301
  const { responseText: _text, failure: _failure, ...settled } = run;
281
302
  const cancelled = codec.require({
282
303
  ...settled,
283
- events: decodedEvents,
304
+ ...storedRunEventFieldsV2(run.previousEventCount, decodedEvents),
284
305
  status: "cancelled",
285
306
  phase:
286
307
  settled.phase === "reconciliation-required" ? "executing" : settled.phase,
287
308
  } satisfies StoredRunV1<Snapshot>);
309
+ await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
288
310
  await storage.put({
289
- [keys.run]: structuredClone(cancelled),
290
- [keys.latestEvents]: structuredClone(latestEvents),
311
+ [keys.run]: structuredClone(storedRunRecordV2(cancelled)),
291
312
  });
292
313
  if ((await storage.get<string>(keys.activeRun)) === runId) {
293
314
  await storage.delete(keys.activeRun);
@@ -307,9 +328,8 @@ export async function failStoredRun<Snapshot>(
307
328
  ): Promise<
308
329
  "failed" | "cancelled" | "superseded" | "preserved-completion" | "missing"
309
330
  > {
310
- const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
311
- if (!stored) return "missing";
312
- const run = codec.require(stored);
331
+ const run = await hydratedRun(codec, storage, keys.run);
332
+ if (!run) return "missing";
313
333
  if (run.status === "completed") return "preserved-completion";
314
334
  // A stopped run never becomes `failed`: Stop is the durable outcome.
315
335
  if (run.stopRequestedAt) {
@@ -332,15 +352,13 @@ export async function failStoredRun<Snapshot>(
332
352
  const latestEvents = settledEvents.latestEvents;
333
353
  const failed = codec.require({
334
354
  ...run,
335
- events: decodedEvents,
355
+ ...storedRunEventFieldsV2(run.previousEventCount, decodedEvents),
336
356
  status: "failed",
337
357
  phase: run.phase === "reconciliation-required" ? "executing" : run.phase,
338
358
  failure,
339
359
  } satisfies StoredRunV1<Snapshot>);
340
- await storage.put({
341
- [keys.run]: structuredClone(failed),
342
- [keys.latestEvents]: structuredClone(latestEvents),
343
- });
360
+ await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
361
+ await storage.put({ [keys.run]: structuredClone(storedRunRecordV2(failed)) });
344
362
  if ((await storage.get<string>(keys.activeRun)) === runId) {
345
363
  await storage.delete(keys.activeRun);
346
364
  }
@@ -358,20 +376,19 @@ export async function requireStoredRunReconciliation<Snapshot>(
358
376
  ): Promise<void> {
359
377
  const activeRunId = await storage.get<string>(keys.activeRun);
360
378
  if (activeRunId !== runId) throw new Error(`run "${runId}" is not active`);
361
- const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
362
- if (!stored) throw new Error(`run "${runId}" was not accepted`);
363
- const run = codec.require(stored);
379
+ const run = await hydratedRun(codec, storage, keys.run);
380
+ if (!run) throw new Error(`run "${runId}" was not accepted`);
364
381
  const decodedEvents = events.map(decodeSessionEvent);
365
382
  const latestEvents = [...previous, ...decodedEvents].map(decodeSessionEvent);
366
383
  const reconciliation = codec.require({
367
384
  ...run,
368
- events: decodedEvents,
385
+ ...storedRunEventFieldsV2(run.previousEventCount, decodedEvents),
369
386
  status: "reconciliation-required",
370
387
  phase: "reconciliation-required",
371
388
  failure,
372
389
  } satisfies StoredRunV1<Snapshot>);
390
+ await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
373
391
  await storage.put({
374
- [keys.run]: structuredClone(reconciliation),
375
- [keys.latestEvents]: structuredClone(latestEvents),
392
+ [keys.run]: structuredClone(storedRunRecordV2(reconciliation)),
376
393
  });
377
394
  }