@frockbot/kernel-do 0.3.15 → 0.3.17

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.
@@ -16,6 +16,7 @@ import {
16
16
  } from "./conversations.ts";
17
17
  import { MemoryStorage } from "./memory-storage.fixture.ts";
18
18
  import { createStoredRunCodecV1 } from "./run-records.ts";
19
+ import { SessionEventLog } from "./session-event-log.ts";
19
20
 
20
21
  const codec = createStoredRunCodecV1<undefined>({
21
22
  decodeRunId: (value) => value as string,
@@ -108,20 +109,17 @@ describe("starting a new conversation", () => {
108
109
  const probe = createAuthority(storage);
109
110
 
110
111
  await probe.authority.run(command("run-1"));
111
- expect((storage.values.get("latest-events") as SessionEvent[]).length).toBe(
112
- 1,
113
- );
112
+ const log = new SessionEventLog(storage);
113
+ expect((await log.read("user-1:primary")).length).toBe(1);
114
114
 
115
115
  const started = await probe.authority.startConversation(IDENTITY);
116
116
  expect(started.ordinal).toBe(2);
117
117
  expect(started.sessionId).toBe("user-1:primary#2");
118
- // The unbounded log is the bug: the next Turn starts from nothing.
119
- expect(storage.values.get("latest-events")).toEqual([]);
118
+ // The next Turn starts from a distinct, empty paged log.
119
+ expect(await log.read("user-1:primary#2")).toEqual([]);
120
120
  // The conversation just ended is still on disk, Turn for Turn.
121
- expect(
122
- (storage.values.get("run:run-1") as { events: SessionEvent[] }).events
123
- .length,
124
- ).toBe(1);
121
+ expect((await probe.authority.readRun("run-1"))?.events.length).toBe(1);
122
+ expect(storage.values.get("run:run-1")).not.toHaveProperty("events");
125
123
 
126
124
  await probe.authority.run(command("run-2"));
127
125
  // The new Turn ran in the new Session, and saw none of the old history.
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./run-records.js";
7
7
  export * from "./run-liveness.js";
8
8
  export * from "./run-recovery.js";
9
9
  export * from "./run-terminal.js";
10
+ export * from "./session-event-log.js";
10
11
  export * from "./storage-keys.js";
11
12
  export * from "./turn-errors.js";
12
13
  export * from "./workspace-generations.js";
@@ -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
 
@@ -38,9 +38,9 @@ export type StoredRunStatus =
38
38
  * decision to interrupt is made in the admission transaction and has to
39
39
  * survive eviction alongside the run it interrupted.
40
40
  */
41
- export type RunLaneV1 = "user" | "background";
41
+ export type RunLaneV1 = "user" | "agent" | "background";
42
42
 
43
- const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "background"];
43
+ const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "agent", "background"];
44
44
 
45
45
  /**
46
46
  * The lane a turn type belongs to when its record names none. Chat is the
@@ -48,7 +48,8 @@ const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "background"];
48
48
  * Bot started for itself.
49
49
  */
50
50
  export function defaultRunLaneV1(turnType: TurnTypeV1): RunLaneV1 {
51
- return turnType === "chat" ? "user" : "background";
51
+ if (turnType === "chat") return "user";
52
+ return turnType === "agent" ? "agent" : "background";
52
53
  }
53
54
 
54
55
  export type StoredEffectAdmissionOutcome = "admitted" | "fenced";
@@ -85,9 +86,26 @@ export interface StoredRunSubagentOriginV1 {
85
86
  parentRunId: string;
86
87
  }
87
88
 
89
+ /** A same-User Bot asking this Bot a question. */
90
+ export interface StoredRunBotOriginV1 {
91
+ kind: "bot";
92
+ fromBotId: string;
93
+ fromBotName: string;
94
+ messageId: string;
95
+ }
96
+
97
+ /** The Voice Package asking this Bot on behalf of its User. */
98
+ export interface StoredRunVoiceOriginV1 {
99
+ kind: "voice";
100
+ messageId: string;
101
+ }
102
+
88
103
  /** What produced a Turn, when it was not a person speaking to the Bot. */
89
104
  export type StoredRunOriginV1 =
90
- StoredRunRoutineOriginV1 | StoredRunSubagentOriginV1;
105
+ | StoredRunRoutineOriginV1
106
+ | StoredRunSubagentOriginV1
107
+ | StoredRunBotOriginV1
108
+ | StoredRunVoiceOriginV1;
91
109
 
92
110
  const STORED_RUN_ORIGIN_TRIGGERS: readonly StoredRunTriggerV1[] = [
93
111
  "cron",
@@ -133,6 +151,12 @@ export interface StoredRunV1<Snapshot = unknown> {
133
151
  acceptedAt: string;
134
152
  input: string;
135
153
  events: SessionEvent[];
154
+ /**
155
+ * Inclusive/exclusive coordinates of this Turn in the authoritative Session
156
+ * log. New durable records carry this instead of embedding `events`; the
157
+ * in-memory record is hydrated through the Session log accessor.
158
+ */
159
+ eventRange?: StoredRunEventRangeV1;
136
160
  effectAdmissions: StoredEffectAdmission[];
137
161
  status: StoredRunStatus;
138
162
  responseText?: string;
@@ -159,6 +183,44 @@ export interface StoredRunV1<Snapshot = unknown> {
159
183
  directTool?: DirectToolCommandV1;
160
184
  }
161
185
 
186
+ export interface StoredRunEventRangeV1 {
187
+ startSeq: number;
188
+ endSeq: number;
189
+ }
190
+
191
+ /** Keeps a hydrated journal and its durable coordinates in lockstep. */
192
+ export function storedRunEventFieldsV2(
193
+ previousEventCount: number,
194
+ events: SessionEvent[],
195
+ ): { events: SessionEvent[]; eventRange: StoredRunEventRangeV1 } {
196
+ return {
197
+ events,
198
+ eventRange: {
199
+ startSeq: previousEventCount,
200
+ endSeq: previousEventCount + events.length,
201
+ },
202
+ };
203
+ }
204
+
205
+ /**
206
+ * The compact durable run shape. Keeping this encoder beside the strict
207
+ * decoder makes it difficult for a metadata update to accidentally put a
208
+ * hydrated multi-megabyte journal back into one SQLite value.
209
+ */
210
+ export function storedRunRecordV2<Snapshot>(
211
+ run: StoredRunV1<Snapshot>,
212
+ ): Omit<StoredRunV1<Snapshot>, "events"> {
213
+ const { events, ...record } = run;
214
+ const eventRange =
215
+ events.length === 0 && run.eventRange
216
+ ? run.eventRange
217
+ : {
218
+ startSeq: run.previousEventCount,
219
+ endSeq: run.previousEventCount + events.length,
220
+ };
221
+ return { ...record, eventRange };
222
+ }
223
+
162
224
  export interface DirectToolCommandV1 {
163
225
  generationId: string;
164
226
  packageId: string;
@@ -246,7 +308,6 @@ const STORED_RUN_REQUIRED_KEYS = [
246
308
  "sessionId",
247
309
  "acceptedAt",
248
310
  "input",
249
- "events",
250
311
  "effectAdmissions",
251
312
  "status",
252
313
  "phase",
@@ -255,6 +316,8 @@ const STORED_RUN_REQUIRED_KEYS = [
255
316
  "previousEventCount",
256
317
  ] as const;
257
318
  const STORED_RUN_OPTIONAL_KEYS = [
319
+ "events",
320
+ "eventRange",
258
321
  "responseText",
259
322
  "failure",
260
323
  "stopRequestedAt",
@@ -391,6 +454,33 @@ function decodeStoredRunOrigin(
391
454
  parentRunId: candidate.parentRunId,
392
455
  };
393
456
  }
457
+ if (candidate.kind === "bot") {
458
+ requireExactOriginFields(
459
+ candidate,
460
+ ["kind", "fromBotId", "fromBotName", "messageId"],
461
+ runId,
462
+ );
463
+ if (
464
+ !boundedString(candidate.fromBotId, 128) ||
465
+ !boundedString(candidate.fromBotName, 100) ||
466
+ !boundedString(candidate.messageId, 256)
467
+ ) {
468
+ throw new Error(`run "${runId}" has an invalid admission origin`);
469
+ }
470
+ return {
471
+ kind: "bot",
472
+ fromBotId: candidate.fromBotId,
473
+ fromBotName: candidate.fromBotName,
474
+ messageId: candidate.messageId,
475
+ };
476
+ }
477
+ if (candidate.kind === "voice") {
478
+ requireExactOriginFields(candidate, ["kind", "messageId"], runId);
479
+ if (!boundedString(candidate.messageId, 256)) {
480
+ throw new Error(`run "${runId}" has an invalid admission origin id`);
481
+ }
482
+ return { kind: "voice", messageId: candidate.messageId };
483
+ }
394
484
  if (candidate.kind !== "routine") {
395
485
  throw new Error(`run "${runId}" has an invalid admission origin kind`);
396
486
  }
@@ -483,6 +573,32 @@ function decodeStoredRunEvents(value: unknown): SessionEvent[] {
483
573
  return value.map(decodeSessionEvent);
484
574
  }
485
575
 
576
+ function decodeStoredRunEventRange(
577
+ value: unknown,
578
+ runId: string,
579
+ ): StoredRunEventRangeV1 {
580
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
581
+ throw new Error(`run "${runId}" has an invalid event range`);
582
+ }
583
+ const candidate = value as Record<PropertyKey, unknown>;
584
+ if (
585
+ Reflect.ownKeys(candidate).length !== 2 ||
586
+ Object.keys(candidate).length !== 2 ||
587
+ !Object.hasOwn(candidate, "startSeq") ||
588
+ !Object.hasOwn(candidate, "endSeq") ||
589
+ !Number.isSafeInteger(candidate.startSeq) ||
590
+ !Number.isSafeInteger(candidate.endSeq) ||
591
+ (candidate.startSeq as number) < 0 ||
592
+ (candidate.endSeq as number) < (candidate.startSeq as number)
593
+ ) {
594
+ throw new Error(`run "${runId}" has an invalid event range`);
595
+ }
596
+ return {
597
+ startSeq: candidate.startSeq as number,
598
+ endSeq: candidate.endSeq as number,
599
+ };
600
+ }
601
+
486
602
  const STORED_EFFECT_ADMISSIONS_MAX = 256;
487
603
  const STORED_EFFECT_ID_MAX_BYTES = 512;
488
604
 
@@ -582,7 +698,17 @@ function requireStoredRunRecordV1<Snapshot>(
582
698
  if (!boundedString(candidate.input, 32_000)) {
583
699
  throw new Error(`run "${runId}" has no valid input`);
584
700
  }
585
- const events = decodeStoredRunEvents(candidate.events);
701
+ if (candidate.events === undefined && candidate.eventRange === undefined) {
702
+ throw new Error(`run "${runId}" has no event journal reference`);
703
+ }
704
+ const events =
705
+ candidate.events === undefined
706
+ ? []
707
+ : decodeStoredRunEvents(candidate.events);
708
+ const eventRange =
709
+ candidate.eventRange === undefined
710
+ ? undefined
711
+ : decodeStoredRunEventRange(candidate.eventRange, runId);
586
712
  const effectAdmissions = decodeStoredEffectAdmissions(
587
713
  candidate.effectAdmissions,
588
714
  );
@@ -605,6 +731,15 @@ function requireStoredRunRecordV1<Snapshot>(
605
731
  ) {
606
732
  throw new Error(`run "${runId}" has no valid previous event count`);
607
733
  }
734
+ if (
735
+ eventRange &&
736
+ (eventRange.startSeq !== candidate.previousEventCount ||
737
+ (events.length > 0 &&
738
+ (events[0]?.seq !== eventRange.startSeq ||
739
+ events.at(-1)!.seq + 1 !== eventRange.endSeq)))
740
+ ) {
741
+ throw new Error(`run "${runId}" has an inconsistent event range`);
742
+ }
608
743
  const configurationSnapshot = options.decodeConfigurationSnapshot(
609
744
  candidate.configurationSnapshot,
610
745
  );
@@ -679,6 +814,7 @@ function requireStoredRunRecordV1<Snapshot>(
679
814
  acceptedAt: candidate.acceptedAt,
680
815
  input: candidate.input,
681
816
  events,
817
+ ...(eventRange ? { eventRange } : {}),
682
818
  effectAdmissions,
683
819
  status,
684
820
  phase,
@@ -82,7 +82,8 @@ export function latestModelRequestJournalState(
82
82
  ) {
83
83
  state = { status: "no-effect", request: state.request, outcome: event };
84
84
  } else if (
85
- event.type === "assistant/message" &&
85
+ (event.type === "assistant/message" ||
86
+ event.type === "model/response-failed") &&
86
87
  state.status !== "none" &&
87
88
  event.requestId === state.request.request.requestId
88
89
  ) {
@@ -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
  }