@morlay/session-rdb 0.0.19 → 0.0.21-alpha.0

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.
Files changed (70) hide show
  1. package/README.md +59 -31
  2. package/dist/artifact.d.mts +25 -65
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{schema-DPcuEh_a.d.mts → backend-DpdtxYpz.d.mts} +360 -107
  5. package/dist/branch-5JzX9rUq.mjs +390 -0
  6. package/dist/deletion.d.mts +7 -0
  7. package/dist/deletion.mjs +2 -0
  8. package/dist/dist-vIVO6bA-.mjs +1524 -0
  9. package/dist/import.d.mts +4 -4
  10. package/dist/import.mjs +214 -1
  11. package/dist/index.d.mts +2 -3
  12. package/dist/index.mjs +4 -1630
  13. package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
  14. package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
  15. package/dist/src-CWTWV7vx.mjs +1904 -0
  16. package/dist/storage.d.mts +14 -5
  17. package/dist/storage.mjs +2 -2
  18. package/dist/testing.d.mts +2 -26
  19. package/dist/testing.mjs +10504 -9614
  20. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
  21. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
  22. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
  23. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
  24. package/package.json +29 -30
  25. package/src/adapters/to-postgres.ts +1 -3
  26. package/src/adapters/to-sqlite.ts +0 -2
  27. package/src/adapters/types.ts +0 -3
  28. package/src/artifact.ts +0 -2
  29. package/src/backend.ts +31 -2
  30. package/src/branch.ts +223 -135
  31. package/src/deletion.ts +87 -0
  32. package/src/drizzle/postgres-v2.ts +0 -1
  33. package/src/drizzle/postgres-v3.ts +0 -1
  34. package/src/drizzle/sqlite-v2.ts +0 -1
  35. package/src/drizzle/sqlite-v3.ts +0 -1
  36. package/src/entities/v2/session-events.ts +0 -1
  37. package/src/entities/v3/event-usage.ts +25 -0
  38. package/src/entities/v3/events.ts +1 -3
  39. package/src/entities/v3/index.ts +4 -0
  40. package/src/entities/v3/session-events.ts +1 -2
  41. package/src/entities/v3/session-projcache-rows.ts +0 -8
  42. package/src/entities/v3/sessions.ts +3 -3
  43. package/src/entities/v3/storage-units.ts +0 -1
  44. package/src/entities/v3/workspace-sessions.ts +0 -5
  45. package/src/entities/v3/workspace-state.ts +0 -6
  46. package/src/entities/v3/workspaces.ts +0 -5
  47. package/src/export.ts +103 -0
  48. package/src/gc.ts +76 -0
  49. package/src/import-storages.ts +4 -37
  50. package/src/import.ts +54 -31
  51. package/src/index.ts +150 -114
  52. package/src/legacy.ts +4 -42
  53. package/src/log.ts +63 -106
  54. package/src/postgres.ts +249 -22
  55. package/src/schema.ts +6 -6
  56. package/src/session-query.ts +0 -4
  57. package/src/sqlite.ts +231 -55
  58. package/src/storage-takeover/index.ts +2 -28
  59. package/src/storage-takeover/projection-cache.ts +30 -134
  60. package/src/storage-takeover/repository.ts +20 -59
  61. package/src/storage-takeover/storage-backend.ts +3 -33
  62. package/src/storage-takeover/types.ts +14 -56
  63. package/src/storage.ts +0 -2
  64. package/src/testing/contract.ts +19 -50
  65. package/src/testing/coordinator-contract.ts +10 -25
  66. package/src/testing.ts +2 -2
  67. package/src/usage.ts +191 -0
  68. package/dist/import-Bc2QQa5G.mjs +0 -473
  69. package/dist/index-CCcK9tia.d.mts +0 -270
  70. package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
package/src/branch.ts CHANGED
@@ -12,15 +12,45 @@ import {
12
12
  SessionBranchError,
13
13
  balanceRewindPrefix,
14
14
  buildTimeline,
15
+ rewindKeepLength,
15
16
  type BranchAnchorMode,
16
17
  type BranchBoundary,
17
18
  type ForkFromOptions,
18
19
  type SessionBranchProvider,
19
20
  } from "@morlay/session-branch";
20
21
  import { randomUUID } from "node:crypto";
22
+ import type { Backend } from "./backend.ts";
21
23
  import type { SessionPersistenceRdb } from "./index.ts";
24
+ import { isLegacyVersion } from "./legacy.ts";
22
25
  import { rowToMeta } from "./log.ts";
23
26
 
27
+ function assertRewindBoundary(
28
+ id: SessionId,
29
+ toBoundary: number,
30
+ boundaryType: string | undefined,
31
+ head: number,
32
+ ): void {
33
+ if (toBoundary === -1) return;
34
+ if (toBoundary > head) {
35
+ throw new SessionBranchError(
36
+ `rewind boundary ${toBoundary} is beyond the stored head ${head}`,
37
+ "INVALID_BOUNDARY",
38
+ );
39
+ }
40
+ if (boundaryType === undefined) {
41
+ throw new SessionBranchError(
42
+ `rewind boundary ${toBoundary} does not exist in session "${id}"`,
43
+ "INVALID_BOUNDARY",
44
+ );
45
+ }
46
+ if (boundaryType !== "turn/end" && boundaryType !== "user/message") {
47
+ throw new SessionBranchError(
48
+ `rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryType})`,
49
+ "INVALID_BOUNDARY",
50
+ );
51
+ }
52
+ }
53
+
24
54
  export function locateTurnEnd(
25
55
  events: readonly SessionEvent[],
26
56
  atSeq?: number,
@@ -42,7 +72,7 @@ export function locateTurnEnd(
42
72
  }
43
73
  const firstAfter = ends.find((seq) => seq >= atSeq);
44
74
  if (firstAfter !== undefined) return firstAfter;
45
- // atSeq 越过末尾:若其落在未闭合轮内则拒绝,否则回退最后一个闭合轮。
75
+
46
76
  const lastStart = [...events].reverse().find((event) => event.type === "turn/start");
47
77
  if (lastStart !== undefined && lastStart.seq <= atSeq) {
48
78
  throw new SessionBranchError(`anchor ${atSeq} lies inside an open turn`, "OPEN_TURN");
@@ -67,46 +97,63 @@ export interface LiveSessionHooks {
67
97
 
68
98
  flush(session: Session): Promise<boolean>;
69
99
 
70
- // 丢弃 live 会话已缓存的投影单元:rewind 直接截断内存 log,投影 registry
71
- // 的 observedSeq 仍停在截断前,后续 append 的事件(seq 更小)会被 drive()
72
- // 跳过,重放输入永远进不了 inbox 投影。可选:纯持久化环境没有投影服务。
100
+ warn?(message: string): void;
101
+
73
102
  resetProjections?(session: Session): void;
74
103
 
75
- // 用截断后的 log 重写该会话的持久化投影检查点:rewind 不会产生事件,
76
- // 缓存行的水位(以及基于它折出的值)仍停在截断前。超前行不会被前端投影
77
- // store 的 higher-seq-wins 规则覆盖(rewind 后的正确值 seq 更小),表现为
78
- // 轮次导航残留被删除的旧轮次。可选:纯持久化环境没有投影缓存服务。
104
+ resetTokenMeter?(session: Session): void;
105
+
79
106
  refreshProjectionCache?(session: ProjectionCacheSession): Promise<void>;
80
107
  }
81
108
 
82
- /**
83
- * 投影检查点刷新所需的最小会话面:cold rewind 没有 live Session 可复用,
84
- * 而截断后的前缀未必是合法的独立会话(surface 引用可能悬空),不能走
85
- * `Session.create` 的校验;缓存服务只读 id / header / inheritedEventCount
86
- * 与事件前缀,用普通对象承载即可。
87
- */
88
109
  export interface ProjectionCacheSession {
89
110
  readonly id: SessionId;
90
111
  readonly header: SessionHeader;
91
112
  readonly inheritedEventCount: SessionLogOffset;
113
+
114
+ // 没有 live 会话时(cold rewind)缓存按这个新水位截断,不 fold 日志
115
+ readonly headSeq?: number;
92
116
  snapshotEvents(): readonly SessionEvent[];
93
117
  }
94
118
 
95
- // 投影 registry 的失效面(上游私有结构,duck-type 读取)。
96
119
  interface ProjectionRegistryLike {
97
120
  registrations?: Map<string, { cells: WeakMap<object, unknown> }>;
98
121
  }
99
122
 
123
+ interface TokenMeterLike {
124
+ states?: WeakMap<object, unknown>;
125
+ }
126
+
100
127
  export interface LiveAgentLike {
101
128
  session: Session;
102
129
 
103
130
  requestHeaderLogged?: boolean;
104
131
 
105
- // 上游 Agent 的 inbox 契约面(ReactLoopInbox.clear):rewind 后残留的排队
106
- // 输入必须 durable 取消,否则 agent 会继续处理它们。可选:测试替身可能没有。
107
132
  inbox?: { clear(): void };
108
133
  }
109
134
 
135
+ interface SurfaceManagerLike {
136
+ _state: {
137
+ nodes: number[];
138
+ replaceGeneration: number;
139
+ contentGeneration: number;
140
+ projectedMessages: Map<unknown, unknown>;
141
+ };
142
+ _lastProcessedSeq: number;
143
+ _pendingPlan?: unknown;
144
+ baseSeq: number;
145
+ }
146
+
147
+ function resetSurfaceManager(surfaceManager: SurfaceManagerLike): void {
148
+ const state = surfaceManager._state;
149
+ state.nodes = [];
150
+ state.replaceGeneration = 0;
151
+ state.contentGeneration = 0;
152
+ state.projectedMessages = new Map();
153
+ surfaceManager._lastProcessedSeq = surfaceManager.baseSeq - 1;
154
+ surfaceManager._pendingPlan = undefined;
155
+ }
156
+
110
157
  export function truncateLiveSession(session: Session, newLength: number): void {
111
158
  const s = session as unknown as {
112
159
  log: SessionEvent[];
@@ -118,12 +165,7 @@ export function truncateLiveSession(session: Session, newLength: number): void {
118
165
  derived: unknown[];
119
166
  derivedNodes: number;
120
167
  derivedGeneration: number;
121
- surfaceManager: {
122
- _state: { nodes: number[]; replaceGeneration: number };
123
- _lastProcessedSeq: number;
124
- _pendingPlan?: unknown;
125
- baseSeq: number;
126
- };
168
+ surfaceManager: SurfaceManagerLike;
127
169
  };
128
170
  s.log.length = newLength;
129
171
  s.eventsSnapshot = undefined;
@@ -134,9 +176,7 @@ export function truncateLiveSession(session: Session, newLength: number): void {
134
176
  s.derived = [];
135
177
  s.derivedNodes = 0;
136
178
  s.derivedGeneration = 0;
137
- s.surfaceManager._state = { nodes: [], replaceGeneration: 0 };
138
- s.surfaceManager._lastProcessedSeq = s.surfaceManager.baseSeq - 1;
139
- s.surfaceManager._pendingPlan = undefined;
179
+ resetSurfaceManager(s.surfaceManager);
140
180
  }
141
181
 
142
182
  export function replaceLiveSessionLog(session: Session, events: readonly SessionEvent[]): void {
@@ -150,12 +190,7 @@ export function replaceLiveSessionLog(session: Session, events: readonly Session
150
190
  derived: unknown[];
151
191
  derivedNodes: number;
152
192
  derivedGeneration: number;
153
- surfaceManager: {
154
- _state: { nodes: number[]; replaceGeneration: number };
155
- _lastProcessedSeq: number;
156
- _pendingPlan?: unknown;
157
- baseSeq: number;
158
- };
193
+ surfaceManager: SurfaceManagerLike;
159
194
  };
160
195
  s.log.length = 0;
161
196
  s.log.push(...events);
@@ -167,9 +202,7 @@ export function replaceLiveSessionLog(session: Session, events: readonly Session
167
202
  s.derived = [];
168
203
  s.derivedNodes = 0;
169
204
  s.derivedGeneration = 0;
170
- s.surfaceManager._state = { nodes: [], replaceGeneration: 0 };
171
- s.surfaceManager._lastProcessedSeq = s.surfaceManager.baseSeq - 1;
172
- s.surfaceManager._pendingPlan = undefined;
205
+ resetSurfaceManager(s.surfaceManager);
173
206
  }
174
207
 
175
208
  export class SessionBranchRdbProvider implements SessionBranchProvider {
@@ -217,7 +250,12 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
217
250
  if (source === undefined)
218
251
  throw new SessionBranchError(`session "${sourceId}" not found`, "SESSION_NOT_FOUND");
219
252
  const boundary = locateTurnEnd(source.events, atSeq, anchorMode);
220
- const prefix = source.events.slice(0, boundary + 1);
253
+ const prefix = balanceRewindPrefix(source.events.slice(0, boundary + 1));
254
+ if (prefix.length <= boundary) {
255
+ this.live.warn?.(
256
+ `session-rdb: fork "${sourceId}" dropped ${boundary + 1 - prefix.length} trailing event(s) from seq ${prefix.length} to keep the seed's step pairs balanced`,
257
+ );
258
+ }
221
259
  const childId = childSessionId ?? mintSessionId();
222
260
  const childMeta: SessionHeader = {
223
261
  version: SESSION_FORMAT_VERSION,
@@ -239,9 +277,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
239
277
  ...(meta.delegationDepth !== undefined ? { delegationDepth: meta.delegationDepth } : {}),
240
278
  };
241
279
  const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
242
- // 事件行复用:前缀事件(稠密 seq → 源会话已存在事件行 id)注册到写路径,
243
- // appendBatch 消费时复用事件行、不复制。seedSuffix 的 manualTurn 事件是
244
- // 新事件(无源行),不注册。
280
+
245
281
  const internals = this.persistence.internals();
246
282
  const sourceRows = await internals.backend.getEventRows(sourceId);
247
283
  const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
@@ -275,44 +311,49 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
275
311
  );
276
312
  }
277
313
  const live = this.live.getSession(id);
278
- // live 会话先落盘 write-behind 缓冲,保证后续读取与后端事务同视图。
314
+
279
315
  if (live !== undefined) await this.live.flush(live);
280
- // 边界校验用原始事件(readLog,不补 closers)——inspect 会给未闭合
281
- // log 补合成 closers,把 user/message 边界掩盖成 turn/end,丢失 exclusive
282
- // 语义。cold 分支同时取回 inheritedEventCount:刷新投影检查点时要用它
283
- // 重建与存储一致的会话身份。
284
- const raw = live === undefined ? await this.persistence.readLog(id, {}, signal) : undefined;
285
- if (live === undefined && raw === undefined) {
316
+
317
+ const internals = this.persistence.internals();
318
+
319
+ // SessionBranch 是「先停止、再操作」的排他面(ADR-rewind绕过handle模型直接截断):rewind 直连 DB 做最小工作,
320
+ // 不走持久化抽象的全量读路径(readLog 会拉全部事件 + legacy 转换 + 读视图修复)。
321
+ const row = await internals.backend.getSession(id);
322
+ if (row === undefined) {
286
323
  throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
287
324
  }
288
- const inspection =
289
- live === undefined ? undefined : await this.persistence.internals().inspect(id, signal);
290
- const events = live === undefined ? raw!.events : inspection!.events;
291
- const meta = live === undefined ? raw!.meta : inspection!.meta;
292
- const boundaryEvent = events[toBoundary];
293
- if (toBoundary === -1) {
294
- // 空前缀:清空整个 log(head -1),与 commitRepair 的初始状态一致。
295
- } else if (boundaryEvent === undefined) {
296
- throw new SessionBranchError(
297
- `rewind boundary ${toBoundary} does not exist in session "${id}"`,
298
- "INVALID_BOUNDARY",
299
- );
300
- } else if (boundaryEvent.type !== "turn/end" && boundaryEvent.type !== "user/message") {
301
- throw new SessionBranchError(
302
- `rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryEvent.type})`,
303
- "INVALID_BOUNDARY",
325
+ const meta = rowToMeta(row);
326
+
327
+ let rawKeepLength: number;
328
+ let kept: readonly SessionEvent[] | undefined;
329
+ if (isLegacyVersion(row.fVersion)) {
330
+ // 旧格式的持久化坐标与当前视图 seq 不一致:只有这条路径需要读日志(含格式转换与读视图修复)
331
+ const log = await this.persistence.readLog(id, {}, signal);
332
+ if (log === undefined) {
333
+ throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
334
+ }
335
+ const boundaryType = log.events[toBoundary]?.type;
336
+ assertRewindBoundary(id, toBoundary, boundaryType, log.events.length - 1);
337
+ rawKeepLength =
338
+ toBoundary === -1 ? 0 : boundaryType === "turn/end" ? toBoundary + 1 : toBoundary;
339
+ kept = balanceRewindPrefix(log.events.slice(0, rawKeepLength));
340
+ } else {
341
+ const boundaryType =
342
+ toBoundary === -1 ? undefined : await internals.backend.getEventTypeAt(id, toBoundary);
343
+ assertRewindBoundary(id, toBoundary, boundaryType, row.fHeadSequence);
344
+ rawKeepLength =
345
+ toBoundary === -1 ? 0 : boundaryType === "turn/end" ? toBoundary + 1 : toBoundary;
346
+ }
347
+ const keepLength =
348
+ kept === undefined
349
+ ? await this.rewindKeepLength(id, rawKeepLength, internals.backend)
350
+ : kept.length;
351
+ if (keepLength < rawKeepLength) {
352
+ this.live.warn?.(
353
+ `session-rdb: rewind "${id}" dropped ${rawKeepLength - keepLength} trailing event(s) from seq ${keepLength} to keep the retained prefix's step pairs balanced`,
304
354
  );
305
355
  }
306
- // 保留前缀长度:turn/end inclusive;user/message exclusive(边界消息由
307
- // 编辑版替换)。exclusive 截断可能残留孤儿 step/start,经平衡化剔除。
308
- const rawKeepLength =
309
- toBoundary === -1 ? 0 : boundaryEvent!.type === "turn/end" ? toBoundary + 1 : toBoundary;
310
- const kept = balanceRewindPrefix(events.slice(0, rawKeepLength));
311
- const keepLength = kept.length;
312
356
 
313
- const internals = this.persistence.internals();
314
- // 原样存储(无过滤):live 视图与 RDB head 同空间(上游 seq),
315
- // 边界即保留前缀长度 - 1。
316
357
  const denseBoundary = keepLength - 1;
317
358
  const newSeedLength = await internals.backend.transaction(async (tx) => {
318
359
  signal?.throwIfAborted();
@@ -332,140 +373,188 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
332
373
  await tx.updateHead(id, prev.fEventId, prev.fSequence);
333
374
  }
334
375
  }
335
- // 截断进入继承前缀后收缩 f_seed_length(只收缩、不扩张),防止存储
336
- // 出现「继承前缀超过存储事件数」的矛盾(上游 load 判损坏)。
376
+
337
377
  const storedSeedLength = await tx.getSeedLength(id);
338
378
  let shrunk = storedSeedLength;
339
379
  if (storedSeedLength !== null && storedSeedLength > denseBoundary + 1) {
340
380
  await tx.updateSeedLength(id, denseBoundary + 1);
341
381
  shrunk = denseBoundary + 1;
342
382
  }
343
- // 截断可能删掉最新的 session/title:标题随保留前缀重算。
383
+
344
384
  await tx.refreshTitle(id);
345
385
  await tx.bumpRevision(id);
346
386
  return shrunk;
347
387
  });
348
388
 
349
- // 更新确认 head(下一次 append 的并发校验基准),与 appendBatch 同语义。
350
389
  internals.writeGuard.confirmHead(id, denseBoundary);
351
390
 
352
391
  if (live !== undefined) {
353
- // live 分支:截断内存 log 并重置派生缓存;同步 handle cursor 与
354
- // agent 轮次游标。不调用 load——live 时 load 会先 flush 把旧内存写回,
355
- // 撤销本次截断。
356
392
  truncateLiveSession(live, keepLength);
357
- // 投影缓存同样停在截断前的水位,必须先失效,否则截断后的重放事件
358
- // (seq 回退)不会进入投影。
393
+
359
394
  this.live.resetProjections?.(live);
395
+
396
+ this.live.resetTokenMeter?.(live);
360
397
  const agent = this.live.getAgent(id);
361
398
  if (agent !== undefined) {
362
399
  agent.requestHeaderLogged = false;
363
- // 重置 agent 的轮次游标,使重放(followup)复用目标轮号而非递增。
400
+
364
401
  const lastTurn =
365
402
  live.snapshotEvents().findLast((e) => e.type === "turn/start")?.data.turn ?? 0;
366
403
  const phase = (agent as unknown as { phase?: { lastTurn?: number } }).phase;
367
404
  if (phase !== undefined) phase.lastTurn = lastTurn;
368
405
  }
369
- // DB 已截断:同步 live write handle 的 cursor 与继承前缀,使下一次
370
- // append 从截断后的位置续接。handle cursor 是**上游空间**(live 内存
371
- // log 截断后的长度,与 drainBuffered 的过滤/contiguity 校验同空间)。
406
+
372
407
  const handle = this.persistence.tracker.writerOf(id);
373
408
  if (handle !== undefined) {
374
409
  handle.resetAfterRewind(keepLength, newSeedLength === null ? undefined : newSeedLength);
375
410
  }
376
- // 截断后强制清空排队输入:保留区里未被截断的 pending(`agent/inbox/spliced`)
377
- // 仍会被 agent 处理,必须 durable 取消。cursor 已对齐,取消事件从截断点续接。
411
+
378
412
  agent?.inbox?.clear();
379
- // 取消事件先经 coordinator 缓冲,落盘后 rewind 才真正 durable(调用方
380
- // 可能在 rewind 返回后直接读后端)。
413
+
381
414
  await this.live.flush(live);
382
- // 持久化投影检查点也要退到截断后的水位:rewind 不产生事件,缓存行
383
- // 会停在截断前;超前行锁死前端(higher-seq-wins),轮次导航残留旧轮次。
415
+
384
416
  await this.refreshProjectionCache(live);
385
417
  } else {
386
- // cold:用截断后的前缀 + 存储身份构造最小会话面,让缓存服务按截断后
387
- // 的 log 重写检查点(没有 live 会话可复用,也不为此 resume 一个 agent)。
418
+ // rewind 不读日志:缓存行按新水位截断(保留下来的投影单元仍在截断前缀内)
388
419
  await this.refreshProjectionCache({
389
420
  id,
390
421
  header: meta,
391
- inheritedEventCount: SessionLogOffset(raw!.inheritedEventCount),
392
- snapshotEvents: () => kept,
422
+ inheritedEventCount: SessionLogOffset(newSeedLength ?? row.fSeedLength ?? 0),
423
+ headSeq: keepLength - 1,
424
+ snapshotEvents: () => [],
393
425
  });
394
426
  }
395
427
 
396
- const row = await internals.backend.getSession(id);
397
- if (row === undefined) {
398
- // 空会话(toBoundary = -1 且从未有行):返回「已确认缺席」快照。
399
- return {
400
- header: {
401
- version: SESSION_FORMAT_VERSION,
402
- id,
403
- createdAt: meta.createdAt,
404
- ...(meta.cwd !== undefined ? { cwd: meta.cwd } : {}),
405
- ...(meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}),
406
- isSeeded: meta.isSeeded,
407
- ...(meta.origin !== undefined ? { origin: meta.origin } : {}),
408
- ...(meta.delegationDepth !== undefined ? { delegationDepth: meta.delegationDepth } : {}),
409
- ...(meta.agentPreset !== undefined ? { agentPreset: meta.agentPreset } : {}),
410
- },
411
- revision:
412
- (await internals.readStoredRevision(id)) ??
413
- (await this.persistence.readStoredRevision(id))!,
414
- };
415
- }
416
428
  return { header: rowToMeta(row), revision: (await internals.readStoredRevision(id))! };
417
429
  }
418
430
 
419
- /**
420
- * 让持久化投影检查点跟随截断后的 log(见 {@link LiveSessionHooks.refreshProjectionCache})。
421
- * 缓存是派生数据:刷新失败只让缓存多滞后一轮,不能让 rewind 失败。
422
- */
431
+ // 保留长度只依赖尾部窗口:从 rawKeepLength 往回读类型(有界),窗口没覆盖到最近一个 turn/end
432
+ // 就翻倍重读,直到覆盖或到达前缀开头。
433
+ private async rewindKeepLength(
434
+ id: SessionId,
435
+ rawKeepLength: number,
436
+ backend: Backend,
437
+ ): Promise<number> {
438
+ if (rawKeepLength === 0) return 0;
439
+ let limit = 64;
440
+ for (;;) {
441
+ const rows = await backend.getEventTypesBefore(id, rawKeepLength, limit);
442
+ const types = [...rows].reverse().map((row) => row.fType);
443
+ const windowStart = rawKeepLength - types.length;
444
+ if (types.includes("turn/end") || windowStart === 0 || types.length >= rawKeepLength) {
445
+ return rewindKeepLength(types, rawKeepLength);
446
+ }
447
+ limit *= 4;
448
+ }
449
+ }
450
+
423
451
  private async refreshProjectionCache(session: ProjectionCacheSession): Promise<void> {
424
452
  if (this.live.refreshProjectionCache === undefined) return;
425
453
  try {
426
454
  await this.live.refreshProjectionCache(session);
427
- } catch {
428
- // 实现方(SessionBranchRdb)已记日志;此处只保证 rewind 不因派生数据失败。
429
- }
455
+ } catch {}
430
456
  }
431
457
  }
432
458
 
433
459
  export class SessionBranchRdb extends SessionBranch {
434
460
  static inject = ["sessionPersistence", "sessions"];
435
461
 
462
+ private readonly warned = new Set<string>();
463
+
436
464
  constructor(ctx: import("@deepseek-ai/cordis").Context) {
437
465
  super(ctx);
438
466
  }
439
467
 
468
+ // 覆盖导入等整段替换内存 log 的路径复用 rewind 的失效钩子(token-meter 水位是位置不是事件身份)
469
+ resetLiveDerivedState(session: Session): void {
470
+ this.resetProjectionCells(session);
471
+ this.resetTokenMeterFold(session);
472
+ }
473
+
474
+ private warnOnce(key: string, message: string): void {
475
+ if (this.warned.has(key)) return;
476
+ this.warned.add(key);
477
+ this.ctx.logger.warn(message);
478
+ }
479
+
480
+ private resetProjectionCells(session: Session): void {
481
+ const registry = (this.ctx as unknown as { get(name: string): unknown }).get(
482
+ "sessionProjections",
483
+ ) as ProjectionRegistryLike | undefined;
484
+ const registrations = registry?.registrations;
485
+ if (!(registrations instanceof Map)) {
486
+ this.warnOnce(
487
+ "sessionProjections.registrations",
488
+ `session-rdb: ctx.sessionProjections.registrations is not a Map (upstream field shape changed); rewind leaves the projection cell caches of "${session.id}" stale, so replayed events can be skipped`,
489
+ );
490
+ return;
491
+ }
492
+ for (const registration of registrations.values()) {
493
+ const cells = registration?.cells;
494
+ if (!(cells instanceof WeakMap)) {
495
+ this.warnOnce(
496
+ "sessionProjections.cells",
497
+ `session-rdb: ctx.sessionProjections registration cells are not a WeakMap (upstream field shape changed); rewind leaves the projection cell caches of "${session.id}" stale, so replayed events can be skipped`,
498
+ );
499
+ continue;
500
+ }
501
+ cells.delete(session);
502
+ }
503
+ }
504
+
505
+ private resetTokenMeterFold(session: Session): void {
506
+ const meter = this.ctx.get("tokenMeter") as TokenMeterLike | undefined;
507
+ if (meter === undefined) {
508
+ this.warnOnce(
509
+ "tokenMeter",
510
+ `session-rdb: ctx.tokenMeter is not mounted; after rewind the token-meter fold watermark of "${session.id}" may stay stale, so compaction can report "step/end ... has no matching step/start event"`,
511
+ );
512
+ return;
513
+ }
514
+ const states = meter.states;
515
+ if (!(states instanceof WeakMap)) {
516
+ this.warnOnce(
517
+ "tokenMeter.states",
518
+ `session-rdb: ctx.tokenMeter.states is not a WeakMap (upstream field shape changed); after rewind the token-meter fold watermark of "${session.id}" may stay stale, so compaction can report "step/end ... has no matching step/start event"`,
519
+ );
520
+ return;
521
+ }
522
+ states.delete(session);
523
+ }
524
+
440
525
  private readonly provider = new SessionBranchRdbProvider(
441
526
  this.ctx.sessionPersistence as SessionPersistenceRdb,
442
527
  {
443
528
  getSession: (id) => this.ctx.sessions.get(id),
444
529
  getAgent: (id) => {
445
- // agents 服务可选(纯持久化环境无 agent-loop):经 ctx.get 动态访问。
446
530
  const agents = this.ctx.get("agents") as
447
531
  | { get(id: SessionId): LiveAgentLike | undefined }
448
532
  | undefined;
449
533
  return agents?.get(id);
450
534
  },
451
535
  flush: (session) => this.ctx.sessions.flush(session),
452
- resetProjections: (session) => {
453
- // sessionProjections 是可选服务(纯持久化环境无 agent-loop/投影)。
454
- const registry = (this.ctx as unknown as { get(name: string): unknown }).get(
455
- "sessionProjections",
456
- ) as ProjectionRegistryLike | undefined;
457
- if (registry?.registrations === undefined) return;
458
- for (const registration of registry.registrations.values()) {
459
- registration.cells.delete(session);
460
- }
536
+ warn: (message) => {
537
+ this.ctx.logger.warn(message);
461
538
  },
539
+ resetProjections: (session) => this.resetProjectionCells(session),
540
+ resetTokenMeter: (session) => this.resetTokenMeterFold(session),
462
541
  refreshProjectionCache: async (session) => {
463
- // sessionProjectionCache 是可选服务(纯持久化环境无投影缓存)。
464
542
  const cache = this.ctx.get("sessionProjectionCache") as
465
- | { write(session: ProjectionCacheSession): Promise<void> }
543
+ | {
544
+ write(session: ProjectionCacheSession): Promise<void>;
545
+ truncateTo?(
546
+ header: SessionHeader,
547
+ inheritedEventCount: SessionLogOffset,
548
+ headSeq: number,
549
+ ): Promise<void>;
550
+ }
466
551
  | undefined;
467
552
  if (cache === undefined) return;
468
553
  try {
554
+ if (session.headSeq !== undefined && cache.truncateTo !== undefined) {
555
+ await cache.truncateTo(session.header, session.inheritedEventCount, session.headSeq);
556
+ return;
557
+ }
469
558
  await cache.write(session);
470
559
  } catch (error: unknown) {
471
560
  this.ctx.logger.warn(
@@ -511,8 +600,7 @@ export class SessionBranchRdb extends SessionBranch {
511
600
  async timeline(sessionId: SessionId, signal?: AbortSignal) {
512
601
  const persistence = this.ctx.sessionPersistence as SessionPersistenceRdb;
513
602
  const snapshots = await persistence.listSnapshots(signal);
514
- // live 会话从内存 log 读自有后缀(含 ignorable 版本效果事件);cold 会话
515
- // 走持久化 readFrom(版本效果不落 canonical log,timeline 为 lineage 骨架)。
603
+
516
604
  const readOwnEvents = async (id: SessionId, fromSeq: number, s?: AbortSignal) => {
517
605
  const live = this.ctx.sessions.get(id);
518
606
  if (live !== undefined) return live.snapshotEvents().slice(fromSeq);
@@ -0,0 +1,87 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { SessionId } from "@deepseek-ai/dsh-session";
3
+ import { SessionDeletionError, type SessionPersistenceRdb } from "./index.ts";
4
+
5
+ export const SESSION_DELETE_PATH = "/api/session.delete";
6
+
7
+ const STATUS_OF_DELETION_ERROR: Record<SessionDeletionError["code"], number> = {
8
+ SESSION_NOT_FOUND: 404,
9
+ SESSION_NOT_ARCHIVED: 409,
10
+ SESSION_LIVE: 409,
11
+ };
12
+
13
+ export function registerSessionDeletion(ctx: Context, persistence: SessionPersistenceRdb): void {
14
+ ctx.inject(["webServer", "connection"] as const, (webCtx) => {
15
+ const webServer = webCtx.webServer as unknown as {
16
+ register(route: {
17
+ kind: "exact";
18
+ path: string;
19
+ handler: (
20
+ req: import("node:http").IncomingMessage,
21
+ res: import("node:http").ServerResponse,
22
+ ) => void | Promise<void>;
23
+ }): () => void;
24
+ };
25
+ const connection = webCtx.get("connection") as unknown as {
26
+ requestRejection(request: {
27
+ headers: import("node:http").IncomingHttpHeaders;
28
+ }): number | undefined;
29
+ };
30
+ return webCtx.effect(() =>
31
+ webServer.register({
32
+ kind: "exact",
33
+ path: SESSION_DELETE_PATH,
34
+ handler: async (req, res) => {
35
+ const rejection = connection.requestRejection(req);
36
+ if (rejection !== undefined) {
37
+ res.writeHead(rejection);
38
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
39
+ return;
40
+ }
41
+ if (req.method !== "POST") {
42
+ res.writeHead(405, { "content-type": "application/json" });
43
+ res.end(JSON.stringify({ error: "method not allowed" }));
44
+ return;
45
+ }
46
+ const chunks: Buffer[] = [];
47
+ for await (const chunk of req) chunks.push(chunk as Buffer);
48
+ let envelope: { sessionId?: unknown };
49
+ try {
50
+ envelope = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
51
+ sessionId?: unknown;
52
+ };
53
+ } catch {
54
+ res.writeHead(400, { "content-type": "application/json" });
55
+ res.end(JSON.stringify({ error: "request body is not JSON" }));
56
+ return;
57
+ }
58
+ if (typeof envelope.sessionId !== "string" || envelope.sessionId === "") {
59
+ res.writeHead(400, { "content-type": "application/json" });
60
+ res.end(JSON.stringify({ error: "missing sessionId field" }));
61
+ return;
62
+ }
63
+ try {
64
+ await persistence.deleteSession(envelope.sessionId as SessionId);
65
+ } catch (error: unknown) {
66
+ if (error instanceof SessionDeletionError) {
67
+ res.writeHead(STATUS_OF_DELETION_ERROR[error.code], {
68
+ "content-type": "application/json",
69
+ });
70
+ res.end(JSON.stringify({ error: error.message, code: error.code }));
71
+ return;
72
+ }
73
+ res.writeHead(500, { "content-type": "application/json" });
74
+ res.end(
75
+ JSON.stringify({
76
+ error: error instanceof Error ? error.message : "session deletion failed",
77
+ }),
78
+ );
79
+ return;
80
+ }
81
+ res.writeHead(200, { "content-type": "application/json" });
82
+ res.end(JSON.stringify({ deleted: envelope.sessionId }));
83
+ },
84
+ }),
85
+ );
86
+ });
87
+ }
@@ -1,4 +1,3 @@
1
- // drizzle-kit 入口:v2 快照(PG,具名导出表对象)。
2
1
  import { toPostgresSchema } from "../adapters/to-postgres.ts";
3
2
  import { postgresTableDefsV2 } from "../entities/v2/index.ts";
4
3
 
@@ -1,4 +1,3 @@
1
- // drizzle-kit 入口:v3 快照(PG,具名导出表对象)。
2
1
  import { toPostgresSchema } from "../adapters/to-postgres.ts";
3
2
  import { postgresTableDefs } from "../entities/v3/index.ts";
4
3
 
@@ -1,4 +1,3 @@
1
- // drizzle-kit 入口:v2 快照(具名导出表对象,供 generate 生成 v2 baseline)。
2
1
  import { toSqliteSchema } from "../adapters/to-sqlite.ts";
3
2
  import { sqliteTableDefsV2 } from "../entities/v2/index.ts";
4
3
 
@@ -1,4 +1,3 @@
1
- // drizzle-kit 入口:v3 快照(具名导出表对象,供 generate 生成 v3 diff)。
2
1
  import { toSqliteSchema } from "../adapters/to-sqlite.ts";
3
2
  import { sqliteTableDefs } from "../entities/v3/index.ts";
4
3
 
@@ -1,7 +1,6 @@
1
1
  import type { TableDef } from "../../adapters/types.ts";
2
2
  import { sessionEvents as v3SessionEvents } from "../v3/session-events.ts";
3
3
 
4
- /** v2 派生:v3 规格 + f_original_seq 列(原样存储前记录上游 seq 的遗留列)。 */
5
4
  export const sessionEvents: TableDef = {
6
5
  ...v3SessionEvents,
7
6
  columns: {