@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-do",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,8 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/kernel-composition": "0.3.15",
16
- "@frockbot/kernel-contracts": "0.3.15",
15
+ "@frockbot/kernel-composition": "0.3.16",
16
+ "@frockbot/kernel-contracts": "0.3.16",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  botTurnCommandFingerprintV1,
17
17
  defaultRunLaneV1,
18
18
  storedRunAdmissionV1,
19
+ storedRunEventFieldsV2,
20
+ storedRunRecordV2,
19
21
  storedRunSubagentRoleV1,
20
22
  storedRunTurnTypeV1,
21
23
  type BotNotificationIntent,
@@ -41,6 +43,10 @@ import {
41
43
  unresolvedModelRequestFailure,
42
44
  } from "./run-recovery.js";
43
45
  import { runLivenessV1, STALE_RUNNING_RUN_FAILURE_V1 } from "./run-liveness.js";
46
+ import {
47
+ SessionEventLog,
48
+ type SessionEventLogStorage,
49
+ } from "./session-event-log.js";
44
50
  import {
45
51
  BotTurnReconciliationRequiredError,
46
52
  BotTurnRecoveryRequiredError,
@@ -395,9 +401,8 @@ export class BotDurableAuthority<Snapshot> {
395
401
  if (await transaction.get<string>(ACTIVE_RUN_KEY)) {
396
402
  return "blocked" as const;
397
403
  }
398
- const storedEvents = (
399
- (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
400
- ).map(decodeSessionEvent);
404
+ const eventLog = new SessionEventLog(transaction);
405
+ const storedEvents = await eventLog.migrate(run.sessionId);
401
406
  // A queued Turn was admitted while another was executing, so admission
402
407
  // could not repair the log: something was still entitled to close that
403
408
  // Turn. Here the active-run marker is gone and nothing is, so the same
@@ -408,17 +413,12 @@ export class BotDurableAuthority<Snapshot> {
408
413
  ...run,
409
414
  phase: "admitted",
410
415
  previousEventCount: latestEvents.length,
416
+ ...storedRunEventFieldsV2(latestEvents.length, []),
411
417
  } satisfies StoredRunV1<Snapshot>);
418
+ if (repaired) await eventLog.rewrite(run.sessionId, latestEvents);
412
419
  await transaction.put({
413
- [key]: structuredClone(promoted),
420
+ [key]: structuredClone(storedRunRecordV2(promoted)),
414
421
  [ACTIVE_RUN_KEY]: runId,
415
- ...(repaired
416
- ? {
417
- [LATEST_EVENTS_KEY]: structuredClone(
418
- repaired.map(decodeSessionEvent),
419
- ),
420
- }
421
- : {}),
422
422
  });
423
423
  await transaction.delete(PENDING_RUN_KEY);
424
424
  await this.refreshRecoveryAlarm(transaction);
@@ -437,7 +437,7 @@ export class BotDurableAuthority<Snapshot> {
437
437
  await this.assertIdentity(identity);
438
438
  const key = `${RUN_PREFIX}${runId}`;
439
439
  const recovery = await this.ctx.storage.transaction(async (transaction) => {
440
- const run = this.codec.optional(await transaction.get<unknown>(key));
440
+ const run = await this.readRunFrom(transaction, runId);
441
441
  const activeRunId = await transaction.get<string>(ACTIVE_RUN_KEY);
442
442
  if (
443
443
  !run ||
@@ -446,9 +446,7 @@ export class BotDurableAuthority<Snapshot> {
446
446
  ) {
447
447
  throw new Error(`run "${runId}" does not require reconciliation`);
448
448
  }
449
- const latest = (
450
- (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
451
- ).map(decodeSessionEvent);
449
+ const latest = await new SessionEventLog(transaction).read(run.sessionId);
452
450
  const settings = run.configurationSnapshot;
453
451
  // The failure is *removed*, not set to `undefined`: a running run that
454
452
  // carries a `failure` key is a shape the run record does not allow, and
@@ -456,15 +454,14 @@ export class BotDurableAuthority<Snapshot> {
456
454
  // read afterwards. `require` checks it here, where the write is, rather
457
455
  // than leaving the projector to fail on every later read.
458
456
  const { failure: _failure, ...resumed } = run;
457
+ const resumedRun = this.codec.require({
458
+ ...resumed,
459
+ status: "running",
460
+ phase: "executing",
461
+ } satisfies StoredRunV1<Snapshot>);
459
462
  await transaction.put(
460
463
  key,
461
- structuredClone(
462
- this.codec.require({
463
- ...resumed,
464
- status: "running",
465
- phase: "executing",
466
- } satisfies StoredRunV1<Snapshot>),
467
- ),
464
+ structuredClone(storedRunRecordV2(resumedRun)),
468
465
  );
469
466
  await this.refreshRecoveryAlarm(transaction);
470
467
  return { run, latest, settings };
@@ -477,9 +474,7 @@ export class BotDurableAuthority<Snapshot> {
477
474
  recovery.settings,
478
475
  );
479
476
  } catch (error) {
480
- const current = this.codec.optional(
481
- await this.ctx.storage.get<unknown>(key),
482
- );
477
+ const current = await this.readRun(runId);
483
478
  if (current?.status === "reconciliation-required") {
484
479
  const previous = recovery.latest.slice(0, current.previousEventCount);
485
480
  const failure =
@@ -511,9 +506,7 @@ export class BotDurableAuthority<Snapshot> {
511
506
  private async settledTerminalRunResult(
512
507
  runId: string,
513
508
  ): Promise<BotTurnCompletion | undefined> {
514
- const run = this.codec.optional(
515
- await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
516
- );
509
+ const run = await this.readRun(runId);
517
510
  if (
518
511
  run?.status !== "failed" &&
519
512
  run?.status !== "cancelled" &&
@@ -572,10 +565,13 @@ export class BotDurableAuthority<Snapshot> {
572
565
  if (!run || run.status !== "running") {
573
566
  throw new Error(`run "${command.runId}" is not resumable`);
574
567
  }
575
- await transaction.put(key, {
576
- ...run,
577
- phase: "executing",
578
- } satisfies StoredRunV1<Snapshot>);
568
+ await transaction.put(
569
+ key,
570
+ storedRunRecordV2({
571
+ ...run,
572
+ phase: "executing",
573
+ } satisfies StoredRunV1<Snapshot>),
574
+ );
579
575
  await this.refreshRecoveryAlarm(transaction);
580
576
  });
581
577
  const result = await this.hooks.executeTurn({
@@ -592,9 +588,7 @@ export class BotDurableAuthority<Snapshot> {
592
588
  await this.completeRun(command.runId, previous, completed, settings);
593
589
  return completed;
594
590
  } catch (error) {
595
- const durableRun = this.codec.optional(
596
- await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${command.runId}`),
597
- );
591
+ const durableRun = await this.readRun(command.runId);
598
592
  const events = eventsForFailedRun(durableRun, error);
599
593
  const message =
600
594
  error instanceof Error ? error.message : "Bot turn failed";
@@ -652,9 +646,7 @@ export class BotDurableAuthority<Snapshot> {
652
646
  private async discardedRunResult(
653
647
  runId: string,
654
648
  ): Promise<BotTurnCompletion | undefined> {
655
- const run = this.codec.optional(
656
- await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
657
- );
649
+ const run = await this.readRun(runId);
658
650
  if (run?.status !== "superseded" && run?.status !== "cancelled") {
659
651
  return undefined;
660
652
  }
@@ -669,9 +661,7 @@ export class BotDurableAuthority<Snapshot> {
669
661
  private async terminalRunResult(
670
662
  runId: string,
671
663
  ): Promise<BotTurnCompletion | undefined> {
672
- const run = this.codec.optional(
673
- await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
674
- );
664
+ const run = await this.readRun(runId);
675
665
  if (run?.status === "superseded" || run?.status === "cancelled") {
676
666
  return { runId, text: "", events: structuredClone(run.events) };
677
667
  }
@@ -730,9 +720,7 @@ export class BotDurableAuthority<Snapshot> {
730
720
  ? { admittedRequest: modelState.request.request }
731
721
  : {}),
732
722
  });
733
- const durableRun = this.codec.optional(
734
- await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${run.runId}`),
735
- );
723
+ const durableRun = await this.readRun(run.runId);
736
724
  if (!durableRun) throw new Error(`run "${run.runId}" was not accepted`);
737
725
  const fullResult = {
738
726
  ...result,
@@ -742,9 +730,7 @@ export class BotDurableAuthority<Snapshot> {
742
730
  await this.completeRun(run.runId, previous, completed, settings);
743
731
  return completed;
744
732
  } catch (error) {
745
- const durableRun = this.codec.optional(
746
- await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${run.runId}`),
747
- );
733
+ const durableRun = await this.readRun(run.runId);
748
734
  const events = durableRun?.events ?? run.events;
749
735
  const message =
750
736
  error instanceof Error ? error.message : "Bot turn failed";
@@ -780,16 +766,17 @@ export class BotDurableAuthority<Snapshot> {
780
766
 
781
767
  private async deferRunRecovery(runId: string): Promise<void> {
782
768
  await this.ctx.storage.transaction(async (transaction) => {
783
- const run = this.codec.optional(
784
- await transaction.get<unknown>(`${RUN_PREFIX}${runId}`),
785
- );
769
+ const run = await this.readRunFrom(transaction, runId);
786
770
  if (!run || run.status !== "running") {
787
771
  throw new Error(`run "${runId}" is not resumable`);
788
772
  }
789
- await transaction.put(`${RUN_PREFIX}${runId}`, {
790
- ...run,
791
- phase: "executing",
792
- } satisfies StoredRunV1<Snapshot>);
773
+ await transaction.put(
774
+ `${RUN_PREFIX}${runId}`,
775
+ storedRunRecordV2({
776
+ ...run,
777
+ phase: "executing",
778
+ } satisfies StoredRunV1<Snapshot>),
779
+ );
793
780
  await this.refreshRecoveryAlarm(transaction);
794
781
  });
795
782
  }
@@ -798,9 +785,7 @@ export class BotDurableAuthority<Snapshot> {
798
785
  command: OwnedBotTurnCommand,
799
786
  ): Promise<BotTurnCompletion | undefined> {
800
787
  const { runId } = command;
801
- const run = this.codec.optional(
802
- await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
803
- );
788
+ const run = await this.readRun(runId);
804
789
  if (!run) return undefined;
805
790
  if (run.commandFingerprint !== botTurnCommandFingerprintV1(command)) {
806
791
  throw new BotTurnRefusedError(
@@ -900,10 +885,13 @@ export class BotDurableAuthority<Snapshot> {
900
885
  throw new Error(`run "${runId}" is not resumable`);
901
886
  }
902
887
  if (run.compositionGenerationId === compositionGenerationId) return;
903
- await transaction.put(key, {
904
- ...run,
905
- compositionGenerationId,
906
- } satisfies StoredRunV1<Snapshot>);
888
+ await transaction.put(
889
+ key,
890
+ storedRunRecordV2({
891
+ ...run,
892
+ compositionGenerationId,
893
+ } satisfies StoredRunV1<Snapshot>),
894
+ );
907
895
  });
908
896
  }
909
897
 
@@ -1083,12 +1071,17 @@ export class BotDurableAuthority<Snapshot> {
1083
1071
  ordinal: current.ordinal + 1,
1084
1072
  startedAt: endedAt,
1085
1073
  };
1074
+ const currentSessionId = conversationSessionIdV1(base, current.ordinal);
1075
+ const nextSessionId = conversationSessionIdV1(base, next.ordinal);
1076
+ const eventLog = new SessionEventLog(transaction);
1077
+ await eventLog.migrate(currentSessionId);
1078
+ await eventLog.clearCurrent(nextSessionId);
1086
1079
  await transaction.put({
1087
1080
  [CONVERSATION_KEY]: next,
1088
1081
  [CONVERSATION_INDEX_KEY]: [
1089
1082
  {
1090
1083
  schemaVersion: 1 as const,
1091
- sessionId: conversationSessionIdV1(base, current.ordinal),
1084
+ sessionId: currentSessionId,
1092
1085
  ordinal: current.ordinal,
1093
1086
  startedAt: current.startedAt,
1094
1087
  endedAt,
@@ -1097,13 +1090,10 @@ export class BotDurableAuthority<Snapshot> {
1097
1090
  ]
1098
1091
  .sort((left, right) => right.ordinal - left.ordinal)
1099
1092
  .slice(0, MAX_LISTED_CONVERSATIONS),
1100
- // The next Turn derives its messages from an empty log. Nothing is
1101
- // deleted: `run:<id>` still holds every event of every Turn.
1102
- [LATEST_EVENTS_KEY]: [],
1103
1093
  });
1104
1094
  return {
1105
1095
  schemaVersion: 1 as const,
1106
- sessionId: conversationSessionIdV1(base, next.ordinal),
1096
+ sessionId: nextSessionId,
1107
1097
  ordinal: next.ordinal,
1108
1098
  startedAt: next.startedAt,
1109
1099
  };
@@ -1135,9 +1125,64 @@ export class BotDurableAuthority<Snapshot> {
1135
1125
  async readStoredRun(
1136
1126
  runId: string,
1137
1127
  ): Promise<StoredRunV1<Snapshot> | undefined> {
1138
- return this.codec.optional(
1128
+ return this.readRunFrom(this.ctx.storage, runId);
1129
+ }
1130
+
1131
+ private async readRunFrom(
1132
+ storage: SessionEventLogStorage,
1133
+ runId: string,
1134
+ ): Promise<StoredRunV1<Snapshot> | undefined> {
1135
+ const run = this.codec.optional(
1136
+ await storage.get<unknown>(`${RUN_PREFIX}${runId}`),
1137
+ );
1138
+ if (!run?.eventRange) return run;
1139
+ const events = await new SessionEventLog(storage).readRange(
1140
+ run.sessionId,
1141
+ run.eventRange.startSeq,
1142
+ run.eventRange.endSeq,
1143
+ );
1144
+ if (events.length !== run.eventRange.endSeq - run.eventRange.startSeq) {
1145
+ throw new Error(`run "${run.runId}" has an incomplete event range`);
1146
+ }
1147
+ return this.codec.require({ ...run, events });
1148
+ }
1149
+
1150
+ /** Exact Session history, reconstructed through the paged durable log. */
1151
+ async readSessionEvents(sessionId: string): Promise<SessionEvent[]> {
1152
+ return new SessionEventLog(this.ctx.storage).read(sessionId);
1153
+ }
1154
+
1155
+ /**
1156
+ * The bounded durable event projections for a run. This is the inspection
1157
+ * path: recovery and client transcript projection use `readStoredRun` and
1158
+ * therefore receive exact events, while a debug snapshot never hydrates a
1159
+ * multi-megabyte prompt merely to cut it again.
1160
+ */
1161
+ async readRunEventProjections(runId: string): Promise<
1162
+ | {
1163
+ run: StoredRunV1<Snapshot>;
1164
+ events: unknown[];
1165
+ eventCount: number;
1166
+ }
1167
+ | undefined
1168
+ > {
1169
+ const run = this.codec.optional(
1139
1170
  await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
1140
1171
  );
1172
+ if (!run) return undefined;
1173
+ if (!run.eventRange) {
1174
+ return { run, events: run.events, eventCount: run.events.length };
1175
+ }
1176
+ const events = await new SessionEventLog(this.ctx.storage).readProjections(
1177
+ run.sessionId,
1178
+ run.eventRange.startSeq,
1179
+ run.eventRange.endSeq,
1180
+ );
1181
+ const eventCount = run.eventRange.endSeq - run.eventRange.startSeq;
1182
+ if (events.length !== eventCount) {
1183
+ throw new Error(`run "${run.runId}" has an incomplete event range`);
1184
+ }
1185
+ return { run, events, eventCount };
1141
1186
  }
1142
1187
 
1143
1188
  /** Durable run record, checked against the key it was looked up by. */
@@ -1183,9 +1228,9 @@ export class BotDurableAuthority<Snapshot> {
1183
1228
  if (runId === this.executingRunId) return true;
1184
1229
  const run = await this.readRun(runId);
1185
1230
  if (!run || run.status !== "running") return false;
1186
- const sessionEvents = (
1187
- (await this.ctx.storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
1188
- ).map(decodeSessionEvent);
1231
+ const sessionEvents = await new SessionEventLog(this.ctx.storage).read(
1232
+ run.sessionId,
1233
+ );
1189
1234
  if (runLivenessV1({ run, sessionEvents }).working) return true;
1190
1235
  await this.settleStaleRun(runId);
1191
1236
  return false;
@@ -1202,13 +1247,9 @@ export class BotDurableAuthority<Snapshot> {
1202
1247
  private async settleStaleRun(runId: string): Promise<void> {
1203
1248
  await this.ctx.storage.transaction(async (transaction) => {
1204
1249
  if (runId === this.executingRunId) return;
1205
- const run = this.codec.optional(
1206
- await transaction.get<unknown>(`${RUN_PREFIX}${runId}`),
1207
- );
1250
+ const run = await this.readRunFrom(transaction, runId);
1208
1251
  if (!run || run.runId !== runId || run.status !== "running") return;
1209
- const latest = (
1210
- (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
1211
- ).map(decodeSessionEvent);
1252
+ const latest = await new SessionEventLog(transaction).read(run.sessionId);
1212
1253
  if (runLivenessV1({ run, sessionEvents: latest }).working) return;
1213
1254
  await failStoredRun(
1214
1255
  this.codec,
@@ -1389,9 +1430,8 @@ export class BotDurableAuthority<Snapshot> {
1389
1430
  const supersede = activeRunId
1390
1431
  ? await this.planSupersede(transaction, command, activeRunId)
1391
1432
  : undefined;
1392
- const storedEvents = (
1393
- (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
1394
- ).map(decodeSessionEvent);
1433
+ const eventLog = new SessionEventLog(transaction);
1434
+ const storedEvents = await eventLog.migrate(command.sessionId);
1395
1435
  // A Turn that died between `turn/start` and `turn/end` — an event the
1396
1436
  // encoder refused, a durable write that failed — left the log open, and
1397
1437
  // every later Turn failed validation with "turn N started while turn
@@ -1426,12 +1466,7 @@ export class BotDurableAuthority<Snapshot> {
1426
1466
  ? undefined
1427
1467
  : repairedSessionLogV1(command.sessionId, storedEvents);
1428
1468
  const latestEvents = repaired ?? storedEvents;
1429
- if (repaired) {
1430
- await transaction.put(
1431
- LATEST_EVENTS_KEY,
1432
- structuredClone(latestEvents.map(decodeSessionEvent)),
1433
- );
1434
- }
1469
+ if (repaired) await eventLog.rewrite(command.sessionId, latestEvents);
1435
1470
  const admittedSettings = await this.hooks.admittedSnapshot(
1436
1471
  transaction,
1437
1472
  settings,
@@ -1464,7 +1499,7 @@ export class BotDurableAuthority<Snapshot> {
1464
1499
  : {}),
1465
1500
  } satisfies StoredRunV1<Snapshot>);
1466
1501
  await transaction.put({
1467
- [key]: admittedRun,
1502
+ [key]: storedRunRecordV2(admittedRun),
1468
1503
  [runIndexKey(command.acceptedAt, command.runId)]: command.runId,
1469
1504
  ...(activeRunId
1470
1505
  ? { [PENDING_RUN_KEY]: command.runId }
@@ -1521,9 +1556,7 @@ export class BotDurableAuthority<Snapshot> {
1521
1556
  if (lane !== "user" || !command.supersedes) {
1522
1557
  throw new BotTurnRefusedError("busy", "bot already has an active run");
1523
1558
  }
1524
- const active = this.codec.optional(
1525
- await transaction.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
1526
- );
1559
+ const active = await this.readRunFrom(transaction, activeRunId);
1527
1560
  if (!active)
1528
1561
  throw new BotTurnRefusedError("busy", "bot already has an active run");
1529
1562
  if (active.status === "reconciliation-required") {
@@ -1551,15 +1584,14 @@ export class BotDurableAuthority<Snapshot> {
1551
1584
  }
1552
1585
  if (!dispatched) return false;
1553
1586
  if (active.supersededAt) return true;
1587
+ const superseded = this.codec.require({
1588
+ ...active,
1589
+ supersededAt: new Date().toISOString(),
1590
+ supersededBy,
1591
+ } satisfies StoredRunV1<Snapshot>);
1554
1592
  await transaction.put(
1555
1593
  `${RUN_PREFIX}${activeRunId}`,
1556
- structuredClone(
1557
- this.codec.require({
1558
- ...active,
1559
- supersededAt: new Date().toISOString(),
1560
- supersededBy,
1561
- } satisfies StoredRunV1<Snapshot>),
1562
- ),
1594
+ structuredClone(storedRunRecordV2(superseded)),
1563
1595
  );
1564
1596
  return true;
1565
1597
  };
@@ -1580,18 +1612,14 @@ export class BotDurableAuthority<Snapshot> {
1580
1612
  return;
1581
1613
  }
1582
1614
  const { responseText: _text, failure: _failure, ...settled } = queued;
1583
- await transaction.put(
1584
- key,
1585
- structuredClone(
1586
- this.codec.require({
1587
- ...settled,
1588
- status: "superseded",
1589
- phase: "admitted",
1590
- supersededAt: new Date().toISOString(),
1591
- supersededBy,
1592
- } satisfies StoredRunV1<Snapshot>),
1593
- ),
1594
- );
1615
+ const superseded = this.codec.require({
1616
+ ...settled,
1617
+ status: "superseded",
1618
+ phase: "admitted",
1619
+ supersededAt: new Date().toISOString(),
1620
+ supersededBy,
1621
+ } satisfies StoredRunV1<Snapshot>);
1622
+ await transaction.put(key, structuredClone(storedRunRecordV2(superseded)));
1595
1623
  }
1596
1624
 
1597
1625
  private async persistRunEvents(
@@ -1604,11 +1632,10 @@ export class BotDurableAuthority<Snapshot> {
1604
1632
  if (durableEvents.length === 0) return;
1605
1633
  const key = `${RUN_PREFIX}${runId}`;
1606
1634
  await this.ctx.storage.transaction(async (transaction) => {
1607
- const run = this.codec.optional(await transaction.get<unknown>(key));
1635
+ const run = await this.readRunFrom(transaction, runId);
1608
1636
  if (!run) throw new Error(`run "${runId}" was not accepted`);
1609
- const latest = (
1610
- (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
1611
- ).map(decodeSessionEvent);
1637
+ const eventLog = new SessionEventLog(transaction);
1638
+ const latest = await eventLog.read(run.sessionId);
1612
1639
  for (const [index, event] of durableEvents.entries()) {
1613
1640
  if (event.seq !== latest.length + index) {
1614
1641
  throw new Error(
@@ -1618,12 +1645,13 @@ export class BotDurableAuthority<Snapshot> {
1618
1645
  }
1619
1646
  const next = this.codec.require({
1620
1647
  ...run,
1621
- events: [...run.events, ...durableEvents],
1648
+ ...storedRunEventFieldsV2(run.previousEventCount, [
1649
+ ...run.events,
1650
+ ...durableEvents,
1651
+ ]),
1622
1652
  } satisfies StoredRunV1<Snapshot>);
1623
- await transaction.put({
1624
- [key]: structuredClone(next),
1625
- [LATEST_EVENTS_KEY]: structuredClone([...latest, ...durableEvents]),
1626
- });
1653
+ await eventLog.append(run.sessionId, durableEvents);
1654
+ await transaction.put(key, structuredClone(storedRunRecordV2(next)));
1627
1655
  });
1628
1656
  }
1629
1657
 
@@ -1841,7 +1869,7 @@ export class BotDurableAuthority<Snapshot> {
1841
1869
  const recovery = await this.ctx.storage.transaction(async (transaction) => {
1842
1870
  const current = await transaction.get<string>(ACTIVE_RUN_KEY);
1843
1871
  if (!current || current === this.executingRunId) return undefined;
1844
- const run = this.codec.optional(await transaction.get<unknown>(key));
1872
+ const run = await this.readRunFrom(transaction, activeRunId);
1845
1873
  if (run?.status === "reconciliation-required") {
1846
1874
  await this.refreshRecoveryAlarm(transaction);
1847
1875
  return undefined;
@@ -1850,9 +1878,8 @@ export class BotDurableAuthority<Snapshot> {
1850
1878
  await this.refreshRecoveryAlarm(transaction);
1851
1879
  return undefined;
1852
1880
  }
1853
- const latest = (
1854
- (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
1855
- ).map(decodeSessionEvent);
1881
+ const eventLog = new SessionEventLog(transaction);
1882
+ const latest = await eventLog.read(run.sessionId);
1856
1883
  // A Turn the User stopped, or one a later message replaced, is terminal
1857
1884
  // in intent before recovery ever looks at it. There is nothing to
1858
1885
  // recover: no answer is owed, and the provider outcome cannot change what
@@ -1928,14 +1955,20 @@ export class BotDurableAuthority<Snapshot> {
1928
1955
  }
1929
1956
  if (plan.kind === "restart") {
1930
1957
  const settings = run.configurationSnapshot;
1931
- await transaction.put({
1932
- [key]: {
1958
+ await eventLog.rewrite(run.sessionId, plan.previous);
1959
+ await transaction.put(
1960
+ key,
1961
+ storedRunRecordV2({
1933
1962
  ...run,
1934
1963
  events: [],
1964
+ eventRange: {
1965
+ startSeq: plan.previous.length,
1966
+ endSeq: plan.previous.length,
1967
+ },
1968
+ previousEventCount: plan.previous.length,
1935
1969
  phase: "admitted",
1936
- } satisfies StoredRunV1<Snapshot>,
1937
- [LATEST_EVENTS_KEY]: plan.previous,
1938
- });
1970
+ } satisfies StoredRunV1<Snapshot>),
1971
+ );
1939
1972
  await this.refreshRecoveryAlarm(transaction);
1940
1973
  return {
1941
1974
  kind: "restart" as const,
@@ -1946,24 +1979,31 @@ export class BotDurableAuthority<Snapshot> {
1946
1979
  }
1947
1980
  if (plan.kind === "resume") {
1948
1981
  const settings = run.configurationSnapshot;
1949
- await transaction.put(key, {
1950
- ...run,
1951
- phase: "executing",
1952
- } satisfies StoredRunV1<Snapshot>);
1982
+ await transaction.put(
1983
+ key,
1984
+ storedRunRecordV2({
1985
+ ...run,
1986
+ phase: "executing",
1987
+ } satisfies StoredRunV1<Snapshot>),
1988
+ );
1953
1989
  await this.refreshRecoveryAlarm(transaction);
1954
1990
  return { kind: "resume" as const, run, latest, settings };
1955
1991
  }
1956
- await transaction.put({
1957
- [key]: {
1992
+ await eventLog.append(run.sessionId, plan.repairs);
1993
+ await transaction.put(
1994
+ key,
1995
+ storedRunRecordV2({
1958
1996
  ...run,
1959
- events: [...run.events, ...plan.repairs],
1997
+ ...storedRunEventFieldsV2(run.previousEventCount, [
1998
+ ...run.events,
1999
+ ...plan.repairs,
2000
+ ]),
1960
2001
  status: "reconciliation-required",
1961
2002
  phase: "reconciliation-required",
1962
2003
  failure:
1963
2004
  "Execution outcome requires reconciliation before it can resume",
1964
- } satisfies StoredRunV1<Snapshot>,
1965
- [LATEST_EVENTS_KEY]: [...latest, ...plan.repairs],
1966
- });
2005
+ } satisfies StoredRunV1<Snapshot>),
2006
+ );
1967
2007
  await this.refreshRecoveryAlarm(transaction);
1968
2008
  return undefined;
1969
2009
  });
@@ -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";