@frockbot/kernel-do 0.3.15 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,731 @@
1
+ import {
2
+ decodeSessionEvent,
3
+ type LlmMessage,
4
+ type SessionEvent,
5
+ } from "@frockbot/kernel-contracts";
6
+ import {
7
+ LATEST_EVENTS_KEY,
8
+ RUN_PREFIX,
9
+ SESSION_EVENT_LOG_INDEX_PREFIX,
10
+ SESSION_EVENT_LOG_PAGE_PREFIX,
11
+ SESSION_EVENT_PAYLOAD_PREFIX,
12
+ } from "./storage-keys.js";
13
+
14
+ /** Maximum serialized size of one Session page value. */
15
+ export const SESSION_EVENT_PAGE_BYTES_V1 = 256 * 1024;
16
+ /** Events at or below this size may live directly in a page. */
17
+ export const SESSION_EVENT_INLINE_BYTES_V1 = 16 * 1024;
18
+ /** Maximum UTF-8 bytes retained in a cut event's diagnostic excerpt. */
19
+ export const SESSION_EVENT_EXCERPT_BYTES_V1 = 8 * 1024;
20
+ /** Payload chunks stay well below both the page and SQLite value ceilings. */
21
+ export const SESSION_EVENT_PAYLOAD_CHUNK_BYTES_V1 = 128 * 1024;
22
+
23
+ const encoder = new TextEncoder();
24
+
25
+ export interface SessionEventLogStorage {
26
+ get<T>(key: string): Promise<T | undefined>;
27
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void>;
28
+ delete(key: string): Promise<boolean>;
29
+ list<T>(options: { prefix: string }): Promise<Map<string, T>>;
30
+ }
31
+
32
+ interface SessionEventLogIndexV1 {
33
+ schemaVersion: 1;
34
+ sessionId: string;
35
+ eventCount: number;
36
+ pageCount: number;
37
+ }
38
+
39
+ interface StoredSessionEventPageV1 {
40
+ schemaVersion: 1;
41
+ sessionId: string;
42
+ page: number;
43
+ startSeq: number;
44
+ entries: StoredSessionEventV1[];
45
+ }
46
+
47
+ interface StoredSessionEventInlineV1 {
48
+ storage: "inline";
49
+ event: SessionEvent;
50
+ }
51
+
52
+ /**
53
+ * A bounded durable projection plus a reference to the exact event payload.
54
+ * `projection.cut` is intentionally explicit: debug readers never mistake an
55
+ * excerpt for the event the model actually ran on.
56
+ */
57
+ export interface StoredSessionEventCutV1 {
58
+ storage: "cut";
59
+ projection: Record<string, unknown> & {
60
+ type: string;
61
+ seq: number;
62
+ timestamp: string;
63
+ cut: {
64
+ marker: "content-cut";
65
+ originalBytes: number;
66
+ sha256: string;
67
+ };
68
+ };
69
+ payload: {
70
+ chunks: number;
71
+ bytes: number;
72
+ sha256: string;
73
+ };
74
+ }
75
+
76
+ type StoredSessionEventV1 =
77
+ StoredSessionEventInlineV1 | StoredSessionEventCutV1;
78
+
79
+ function utf8Bytes(value: unknown): number {
80
+ return encoder.encode(JSON.stringify(value)).byteLength;
81
+ }
82
+
83
+ function sessionKeyPart(sessionId: string): string {
84
+ return encodeURIComponent(sessionId);
85
+ }
86
+
87
+ export function sessionEventLogIndexKeyV1(sessionId: string): string {
88
+ return `${SESSION_EVENT_LOG_INDEX_PREFIX}${sessionKeyPart(sessionId)}`;
89
+ }
90
+
91
+ export function sessionEventLogPagePrefixV1(sessionId: string): string {
92
+ return `${SESSION_EVENT_LOG_PAGE_PREFIX}${sessionKeyPart(sessionId)}:`;
93
+ }
94
+
95
+ function sessionEventLogPageKey(sessionId: string, page: number): string {
96
+ return `${sessionEventLogPagePrefixV1(sessionId)}${String(page).padStart(10, "0")}`;
97
+ }
98
+
99
+ export function sessionEventPayloadPrefixV1(sessionId: string): string {
100
+ return `${SESSION_EVENT_PAYLOAD_PREFIX}${sessionKeyPart(sessionId)}:`;
101
+ }
102
+
103
+ function sessionEventPayloadKey(
104
+ sessionId: string,
105
+ seq: number,
106
+ chunk: number,
107
+ ): string {
108
+ return `${sessionEventPayloadPrefixV1(sessionId)}${String(seq).padStart(12, "0")}:${String(chunk).padStart(6, "0")}`;
109
+ }
110
+
111
+ async function sha256(value: string): Promise<string> {
112
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
113
+ return [...new Uint8Array(digest)]
114
+ .map((byte) => byte.toString(16).padStart(2, "0"))
115
+ .join("");
116
+ }
117
+
118
+ function cutUtf8(value: string, maximum: number): string {
119
+ if (encoder.encode(value).byteLength <= maximum) return value;
120
+ const marker = "\n[… content cut …]";
121
+ const budget = maximum - encoder.encode(marker).byteLength;
122
+ let low = 0;
123
+ let high = value.length;
124
+ while (low < high) {
125
+ const middle = Math.ceil((low + high) / 2);
126
+ if (encoder.encode(value.slice(0, middle)).byteLength <= budget)
127
+ low = middle;
128
+ else high = middle - 1;
129
+ }
130
+ return `${value.slice(0, low)}${marker}`;
131
+ }
132
+
133
+ function messageExcerpt(message: LlmMessage | undefined): string {
134
+ if (!message) return "";
135
+ if (message.role === "assistant")
136
+ return `${message.role}: ${message.content}`;
137
+ if (message.role === "tool") {
138
+ return `${message.role}(${message.name}): ${message.content}`;
139
+ }
140
+ return `${message.role}: ${message.content}`;
141
+ }
142
+
143
+ function eventCoordinates(event: SessionEvent): {
144
+ type: string;
145
+ seq: number;
146
+ timestamp: string;
147
+ turn?: number;
148
+ step?: number;
149
+ } {
150
+ const record = event as unknown as Record<string, unknown>;
151
+ return {
152
+ type: event.type,
153
+ seq: event.seq,
154
+ timestamp: event.timestamp,
155
+ ...(typeof record.turn === "number" ? { turn: record.turn } : {}),
156
+ ...(typeof record.step === "number" ? { step: record.step } : {}),
157
+ };
158
+ }
159
+
160
+ async function cutProjection(
161
+ event: SessionEvent,
162
+ serialized: string,
163
+ digest: string,
164
+ ): Promise<StoredSessionEventCutV1["projection"]> {
165
+ const base = eventCoordinates(event);
166
+ const cut = {
167
+ marker: "content-cut" as const,
168
+ originalBytes: encoder.encode(serialized).byteLength,
169
+ sha256: digest,
170
+ };
171
+ if (event.type === "model/request") {
172
+ const requestSerialized = JSON.stringify(event.request);
173
+ const requestBytes = encoder.encode(requestSerialized).byteLength;
174
+ const requestDigest = await sha256(requestSerialized);
175
+ const lastMessage = event.request.messages.at(-1);
176
+ const excerptPartBytes = Math.floor(SESSION_EVENT_EXCERPT_BYTES_V1 / 2);
177
+ return {
178
+ ...base,
179
+ cut,
180
+ request: {
181
+ requestId: event.request.requestId,
182
+ provider: event.request.provider,
183
+ model: event.request.model,
184
+ ...(event.request.modelBinding
185
+ ? { modelBinding: event.request.modelBinding }
186
+ : {}),
187
+ messageCount: event.request.messages.length,
188
+ toolCount: event.request.tools.length,
189
+ bytes: requestBytes,
190
+ sha256: requestDigest,
191
+ excerpt: {
192
+ system: cutUtf8(event.request.system, excerptPartBytes),
193
+ lastMessage: cutUtf8(messageExcerpt(lastMessage), excerptPartBytes),
194
+ },
195
+ truncated: true,
196
+ },
197
+ };
198
+ }
199
+ const record = event as unknown as Record<string, unknown>;
200
+ const named = [
201
+ "requestId",
202
+ "messageId",
203
+ "occurrenceId",
204
+ "effectId",
205
+ "callId",
206
+ "name",
207
+ "packageId",
208
+ "status",
209
+ "outcome",
210
+ "isError",
211
+ ].reduce<Record<string, unknown>>((kept, key) => {
212
+ const value = record[key];
213
+ if (
214
+ typeof value === "string" ||
215
+ typeof value === "number" ||
216
+ typeof value === "boolean"
217
+ ) {
218
+ kept[key] = value;
219
+ }
220
+ return kept;
221
+ }, {});
222
+ return {
223
+ ...base,
224
+ ...named,
225
+ cut,
226
+ excerpt: cutUtf8(serialized, SESSION_EVENT_EXCERPT_BYTES_V1),
227
+ truncated: true,
228
+ };
229
+ }
230
+
231
+ function payloadChunks(serialized: string): string[] {
232
+ const chunks: string[] = [];
233
+ let offset = 0;
234
+ while (offset < serialized.length) {
235
+ let end = Math.min(serialized.length, offset + 32_000);
236
+ while (
237
+ end > offset &&
238
+ encoder.encode(serialized.slice(offset, end)).byteLength >
239
+ SESSION_EVENT_PAYLOAD_CHUNK_BYTES_V1
240
+ ) {
241
+ end -= 1;
242
+ }
243
+ if (end === offset)
244
+ throw new Error("Session event payload cannot be chunked");
245
+ chunks.push(serialized.slice(offset, end));
246
+ offset = end;
247
+ }
248
+ return chunks;
249
+ }
250
+
251
+ function requireIndex(
252
+ input: SessionEventLogIndexV1 | undefined,
253
+ sessionId: string,
254
+ ): SessionEventLogIndexV1 | undefined {
255
+ if (input === undefined) return undefined;
256
+ if (
257
+ input.schemaVersion !== 1 ||
258
+ input.sessionId !== sessionId ||
259
+ !Number.isSafeInteger(input.eventCount) ||
260
+ input.eventCount < 0 ||
261
+ !Number.isSafeInteger(input.pageCount) ||
262
+ input.pageCount < 0
263
+ ) {
264
+ throw new Error(`Session event index for "${sessionId}" is invalid`);
265
+ }
266
+ return input;
267
+ }
268
+
269
+ function requirePage(
270
+ input: StoredSessionEventPageV1 | undefined,
271
+ sessionId: string,
272
+ page: number,
273
+ ): StoredSessionEventPageV1 {
274
+ if (
275
+ !input ||
276
+ input.schemaVersion !== 1 ||
277
+ input.sessionId !== sessionId ||
278
+ input.page !== page ||
279
+ !Number.isSafeInteger(input.startSeq) ||
280
+ input.startSeq < 0 ||
281
+ !Array.isArray(input.entries) ||
282
+ utf8Bytes(input) > SESSION_EVENT_PAGE_BYTES_V1
283
+ ) {
284
+ throw new Error(`Session event page ${page} for "${sessionId}" is invalid`);
285
+ }
286
+ return input;
287
+ }
288
+
289
+ /**
290
+ * Durable Session log module. Its interface hides paging, large-event
291
+ * projection, exact-payload chunks, integrity checks, and legacy migration.
292
+ */
293
+ export class SessionEventLog {
294
+ constructor(private readonly storage: SessionEventLogStorage) {}
295
+
296
+ async read(sessionId: string): Promise<SessionEvent[]> {
297
+ const index = requireIndex(
298
+ await this.storage.get<SessionEventLogIndexV1>(
299
+ sessionEventLogIndexKeyV1(sessionId),
300
+ ),
301
+ sessionId,
302
+ );
303
+ if (!index) {
304
+ const legacy =
305
+ (await this.storage.get<unknown[]>(LATEST_EVENTS_KEY)) ?? [];
306
+ return legacy.map(decodeSessionEvent);
307
+ }
308
+ const events: SessionEvent[] = [];
309
+ for (let page = 0; page < index.pageCount; page += 1) {
310
+ const stored = requirePage(
311
+ await this.storage.get<StoredSessionEventPageV1>(
312
+ sessionEventLogPageKey(sessionId, page),
313
+ ),
314
+ sessionId,
315
+ page,
316
+ );
317
+ if (stored.startSeq !== events.length) {
318
+ throw new Error(
319
+ `Session event pages for "${sessionId}" are not contiguous`,
320
+ );
321
+ }
322
+ for (const entry of stored.entries) {
323
+ events.push(await this.exactEvent(sessionId, entry));
324
+ }
325
+ }
326
+ if (events.length !== index.eventCount) {
327
+ throw new Error(
328
+ `Session event index for "${sessionId}" has the wrong count`,
329
+ );
330
+ }
331
+ for (const [seq, event] of events.entries()) {
332
+ if (event.seq !== seq) {
333
+ throw new Error(
334
+ `Session event log for "${sessionId}" is not contiguous`,
335
+ );
336
+ }
337
+ }
338
+ return events;
339
+ }
340
+
341
+ async readRange(
342
+ sessionId: string,
343
+ startSeq: number,
344
+ endSeq: number,
345
+ ): Promise<SessionEvent[]> {
346
+ if (
347
+ !Number.isSafeInteger(startSeq) ||
348
+ !Number.isSafeInteger(endSeq) ||
349
+ startSeq < 0 ||
350
+ endSeq < startSeq
351
+ ) {
352
+ throw new Error("Session event range is invalid");
353
+ }
354
+ return (await this.read(sessionId)).slice(startSeq, endSeq);
355
+ }
356
+
357
+ async readProjections(
358
+ sessionId: string,
359
+ startSeq = 0,
360
+ endSeq = Number.MAX_SAFE_INTEGER,
361
+ ): Promise<unknown[]> {
362
+ const index = requireIndex(
363
+ await this.storage.get<SessionEventLogIndexV1>(
364
+ sessionEventLogIndexKeyV1(sessionId),
365
+ ),
366
+ sessionId,
367
+ );
368
+ if (!index) {
369
+ const legacy = await this.read(sessionId);
370
+ return Promise.all(
371
+ legacy.slice(startSeq, endSeq).map(async (event) => {
372
+ const serialized = JSON.stringify(event);
373
+ if (
374
+ event.type !== "model/request" &&
375
+ encoder.encode(serialized).byteLength <=
376
+ SESSION_EVENT_INLINE_BYTES_V1
377
+ ) {
378
+ return event;
379
+ }
380
+ const digest = await sha256(serialized);
381
+ return cutProjection(event, serialized, digest);
382
+ }),
383
+ );
384
+ }
385
+ const projected: unknown[] = [];
386
+ for (let page = 0; page < index.pageCount; page += 1) {
387
+ const stored = requirePage(
388
+ await this.storage.get<StoredSessionEventPageV1>(
389
+ sessionEventLogPageKey(sessionId, page),
390
+ ),
391
+ sessionId,
392
+ page,
393
+ );
394
+ for (const entry of stored.entries) {
395
+ const seq =
396
+ entry.storage === "inline" ? entry.event.seq : entry.projection.seq;
397
+ if (seq >= startSeq && seq < endSeq) {
398
+ projected.push(
399
+ entry.storage === "inline" ? entry.event : entry.projection,
400
+ );
401
+ }
402
+ }
403
+ }
404
+ return projected;
405
+ }
406
+
407
+ /** Reads the legacy value and rewrites it as pages when necessary. */
408
+ async migrate(sessionId: string): Promise<SessionEvent[]> {
409
+ const index = requireIndex(
410
+ await this.storage.get<SessionEventLogIndexV1>(
411
+ sessionEventLogIndexKeyV1(sessionId),
412
+ ),
413
+ sessionId,
414
+ );
415
+ if (index) return this.read(sessionId);
416
+ const legacy = (await this.storage.get<unknown[]>(LATEST_EVENTS_KEY)) ?? [];
417
+ const events = legacy.map(decodeSessionEvent);
418
+ await this.rewrite(sessionId, events);
419
+ return events;
420
+ }
421
+
422
+ async append(
423
+ sessionId: string,
424
+ events: readonly SessionEvent[],
425
+ ): Promise<void> {
426
+ if (events.length === 0) return;
427
+ let index = requireIndex(
428
+ await this.storage.get<SessionEventLogIndexV1>(
429
+ sessionEventLogIndexKeyV1(sessionId),
430
+ ),
431
+ sessionId,
432
+ );
433
+ if (!index) {
434
+ await this.migrate(sessionId);
435
+ index = requireIndex(
436
+ await this.storage.get<SessionEventLogIndexV1>(
437
+ sessionEventLogIndexKeyV1(sessionId),
438
+ ),
439
+ sessionId,
440
+ );
441
+ }
442
+ if (!index)
443
+ throw new Error("Session event migration did not write an index");
444
+ const decoded = events.map(decodeSessionEvent);
445
+ for (const [offset, event] of decoded.entries()) {
446
+ if (event.seq !== index.eventCount + offset) {
447
+ throw new Error(
448
+ "Bot session persistence received non-contiguous events",
449
+ );
450
+ }
451
+ }
452
+ const entries = await Promise.all(
453
+ decoded.map((event) => this.storedEvent(sessionId, event)),
454
+ );
455
+ await this.appendStored(sessionId, index, entries);
456
+ }
457
+
458
+ async rewrite(
459
+ sessionId: string,
460
+ events: readonly SessionEvent[],
461
+ ): Promise<void> {
462
+ const previous = await this.read(sessionId);
463
+ const decoded = events.map(decodeSessionEvent);
464
+ for (const [seq, event] of decoded.entries()) {
465
+ if (event.seq !== seq) {
466
+ throw new Error(
467
+ `Session event log for "${sessionId}" is not contiguous`,
468
+ );
469
+ }
470
+ }
471
+ await this.clearPaged(sessionId);
472
+ const empty: SessionEventLogIndexV1 = {
473
+ schemaVersion: 1,
474
+ sessionId,
475
+ eventCount: 0,
476
+ pageCount: 0,
477
+ };
478
+ await this.storage.put(sessionEventLogIndexKeyV1(sessionId), empty);
479
+ if (decoded.length > 0) {
480
+ const entries = await Promise.all(
481
+ decoded.map((event) => this.storedEvent(sessionId, event)),
482
+ );
483
+ await this.appendStored(sessionId, empty, entries);
484
+ }
485
+ await this.rebaseRunRanges(sessionId, previous, decoded);
486
+ await this.storage.delete(LATEST_EVENTS_KEY);
487
+ }
488
+
489
+ async clearCurrent(sessionId: string): Promise<void> {
490
+ await this.rewrite(sessionId, []);
491
+ }
492
+
493
+ private async storedEvent(
494
+ sessionId: string,
495
+ event: SessionEvent,
496
+ ): Promise<StoredSessionEventV1> {
497
+ const serialized = JSON.stringify(event);
498
+ const bytes = encoder.encode(serialized).byteLength;
499
+ if (
500
+ event.type !== "model/request" &&
501
+ bytes <= SESSION_EVENT_INLINE_BYTES_V1
502
+ ) {
503
+ return { storage: "inline", event: structuredClone(event) };
504
+ }
505
+ const digest = await sha256(serialized);
506
+ const chunks = payloadChunks(serialized);
507
+ for (const [chunk, value] of chunks.entries()) {
508
+ if (
509
+ encoder.encode(value).byteLength > SESSION_EVENT_PAYLOAD_CHUNK_BYTES_V1
510
+ ) {
511
+ throw new Error("Session event payload chunk exceeds its byte budget");
512
+ }
513
+ await this.storage.put(
514
+ sessionEventPayloadKey(sessionId, event.seq, chunk),
515
+ value,
516
+ );
517
+ }
518
+ return {
519
+ storage: "cut",
520
+ projection: await cutProjection(event, serialized, digest),
521
+ payload: { chunks: chunks.length, bytes, sha256: digest },
522
+ };
523
+ }
524
+
525
+ private async exactEvent(
526
+ sessionId: string,
527
+ entry: StoredSessionEventV1,
528
+ ): Promise<SessionEvent> {
529
+ if (entry.storage === "inline") return decodeSessionEvent(entry.event);
530
+ const parts: string[] = [];
531
+ for (let chunk = 0; chunk < entry.payload.chunks; chunk += 1) {
532
+ const part = await this.storage.get<string>(
533
+ sessionEventPayloadKey(sessionId, entry.projection.seq, chunk),
534
+ );
535
+ if (typeof part !== "string") {
536
+ throw new Error(
537
+ `Session event payload ${entry.projection.seq}:${chunk} is missing`,
538
+ );
539
+ }
540
+ parts.push(part);
541
+ }
542
+ const serialized = parts.join("");
543
+ if (
544
+ encoder.encode(serialized).byteLength !== entry.payload.bytes ||
545
+ (await sha256(serialized)) !== entry.payload.sha256
546
+ ) {
547
+ throw new Error(
548
+ `Session event payload ${entry.projection.seq} is corrupt`,
549
+ );
550
+ }
551
+ return decodeSessionEvent(JSON.parse(serialized));
552
+ }
553
+
554
+ private async appendStored(
555
+ sessionId: string,
556
+ index: SessionEventLogIndexV1,
557
+ entries: readonly StoredSessionEventV1[],
558
+ ): Promise<void> {
559
+ let pageNumber = Math.max(0, index.pageCount - 1);
560
+ let page =
561
+ index.pageCount === 0
562
+ ? {
563
+ schemaVersion: 1 as const,
564
+ sessionId,
565
+ page: 0,
566
+ startSeq: index.eventCount,
567
+ entries: [] as StoredSessionEventV1[],
568
+ }
569
+ : requirePage(
570
+ await this.storage.get<StoredSessionEventPageV1>(
571
+ sessionEventLogPageKey(sessionId, pageNumber),
572
+ ),
573
+ sessionId,
574
+ pageNumber,
575
+ );
576
+ let dirty = false;
577
+ let pageCount = index.pageCount;
578
+ for (const entry of entries) {
579
+ const candidate: StoredSessionEventPageV1 = {
580
+ ...page,
581
+ entries: [...page.entries, entry],
582
+ };
583
+ if (utf8Bytes(candidate) <= SESSION_EVENT_PAGE_BYTES_V1) {
584
+ page = candidate;
585
+ dirty = true;
586
+ if (pageCount === 0) pageCount = 1;
587
+ continue;
588
+ }
589
+ if (page.entries.length === 0) {
590
+ throw new Error("A Session event projection exceeds the page budget");
591
+ }
592
+ if (dirty) {
593
+ await this.storage.put(
594
+ sessionEventLogPageKey(sessionId, pageNumber),
595
+ page,
596
+ );
597
+ }
598
+ pageNumber += 1;
599
+ pageCount = pageNumber + 1;
600
+ const seq =
601
+ entry.storage === "inline" ? entry.event.seq : entry.projection.seq;
602
+ page = {
603
+ schemaVersion: 1,
604
+ sessionId,
605
+ page: pageNumber,
606
+ startSeq: seq,
607
+ entries: [entry],
608
+ };
609
+ if (utf8Bytes(page) > SESSION_EVENT_PAGE_BYTES_V1) {
610
+ throw new Error("A Session event projection exceeds the page budget");
611
+ }
612
+ dirty = true;
613
+ }
614
+ if (dirty) {
615
+ await this.storage.put(
616
+ sessionEventLogPageKey(sessionId, pageNumber),
617
+ page,
618
+ );
619
+ }
620
+ await this.storage.put(sessionEventLogIndexKeyV1(sessionId), {
621
+ schemaVersion: 1,
622
+ sessionId,
623
+ eventCount: index.eventCount + entries.length,
624
+ pageCount,
625
+ } satisfies SessionEventLogIndexV1);
626
+ }
627
+
628
+ private async clearPaged(sessionId: string): Promise<void> {
629
+ const index = requireIndex(
630
+ await this.storage.get<SessionEventLogIndexV1>(
631
+ sessionEventLogIndexKeyV1(sessionId),
632
+ ),
633
+ sessionId,
634
+ );
635
+ for (let page = 0; page < (index?.pageCount ?? 0); page += 1) {
636
+ const stored = requirePage(
637
+ await this.storage.get<StoredSessionEventPageV1>(
638
+ sessionEventLogPageKey(sessionId, page),
639
+ ),
640
+ sessionId,
641
+ page,
642
+ );
643
+ for (const entry of stored.entries) {
644
+ if (entry.storage !== "cut") continue;
645
+ for (let chunk = 0; chunk < entry.payload.chunks; chunk += 1) {
646
+ await this.storage.delete(
647
+ sessionEventPayloadKey(sessionId, entry.projection.seq, chunk),
648
+ );
649
+ }
650
+ }
651
+ await this.storage.delete(sessionEventLogPageKey(sessionId, page));
652
+ }
653
+ await this.storage.delete(sessionEventLogIndexKeyV1(sessionId));
654
+ }
655
+
656
+ /**
657
+ * Keeps compact run references valid when structural repair inserts events
658
+ * into the middle of a Session and resequences the suffix. Rewrites retain
659
+ * original events in order (or remove only a trailing, restarting Turn), so
660
+ * matching their canonical form without `seq` yields every old boundary's
661
+ * new coordinate. Legacy runs still embedding `events` need no reference
662
+ * update and migrate when they are next written.
663
+ */
664
+ private async rebaseRunRanges(
665
+ sessionId: string,
666
+ previous: readonly SessionEvent[],
667
+ rewritten: readonly SessionEvent[],
668
+ ): Promise<void> {
669
+ if (previous.length === 0) return;
670
+ const signature = (event: SessionEvent): string => {
671
+ const { seq: _seq, ...stable } = event;
672
+ return JSON.stringify(stable);
673
+ };
674
+ const previousSignatures = previous.map(signature);
675
+ const boundaries = new Map<number, number>();
676
+ let previousIndex = 0;
677
+ for (
678
+ let rewrittenIndex = 0;
679
+ rewrittenIndex < rewritten.length && previousIndex < previous.length;
680
+ rewrittenIndex += 1
681
+ ) {
682
+ if (
683
+ signature(rewritten[rewrittenIndex]!) ===
684
+ previousSignatures[previousIndex]
685
+ ) {
686
+ boundaries.set(previousIndex, rewrittenIndex);
687
+ previousIndex += 1;
688
+ }
689
+ }
690
+ // A recovery restart may deliberately drop only the old trailing Turn.
691
+ // Every boundary in that removed suffix collapses onto the new end.
692
+ for (
693
+ let boundary = previousIndex;
694
+ boundary <= previous.length;
695
+ boundary += 1
696
+ ) {
697
+ boundaries.set(boundary, rewritten.length);
698
+ }
699
+
700
+ const runs = await this.storage.list<Record<string, unknown>>({
701
+ prefix: RUN_PREFIX,
702
+ });
703
+ for (const [key, run] of runs) {
704
+ if (run.sessionId !== sessionId || !isEventRange(run.eventRange))
705
+ continue;
706
+ const startSeq = boundaries.get(run.eventRange.startSeq);
707
+ const endSeq = boundaries.get(run.eventRange.endSeq);
708
+ if (startSeq === undefined || endSeq === undefined) {
709
+ throw new Error(`run range in "${key}" cannot follow Session rewrite`);
710
+ }
711
+ await this.storage.put(key, {
712
+ ...run,
713
+ previousEventCount: startSeq,
714
+ eventRange: { startSeq, endSeq },
715
+ });
716
+ }
717
+ }
718
+ }
719
+
720
+ function isEventRange(
721
+ value: unknown,
722
+ ): value is { startSeq: number; endSeq: number } {
723
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
724
+ const candidate = value as Record<string, unknown>;
725
+ return (
726
+ Number.isSafeInteger(candidate.startSeq) &&
727
+ Number.isSafeInteger(candidate.endSeq) &&
728
+ (candidate.startSeq as number) >= 0 &&
729
+ (candidate.endSeq as number) >= (candidate.startSeq as number)
730
+ );
731
+ }