@morlay/session-rdb 0.1.10-alpha.0 → 0.1.10-alpha.3
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/README.md +18 -3
- package/dist/artifact.d.mts +118 -2
- package/dist/artifact.mjs +5 -3
- package/dist/{log-TYgd8Xkr.d.mts → backend-BKtJ5sO5.d.mts} +183 -284
- package/dist/backend-rdb-BDCntVDy.mjs +1341 -0
- package/dist/branch-CG1PUlO6.mjs +833 -0
- package/dist/deletion-SSX7IqU8.mjs +35 -0
- package/dist/deletion.d.mts +1 -1
- package/dist/deletion.mjs +2 -2
- package/dist/export-CfJQjQfr.mjs +123 -0
- package/dist/export.d.mts +1 -1
- package/dist/export.mjs +31 -55
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +31 -76
- package/dist/index-CUD2ZpTA.d.mts +71 -0
- package/dist/index.d.mts +20 -2
- package/dist/index.mjs +1561 -4
- package/dist/open-YoyD_kah.mjs +561 -0
- package/dist/organize.d.mts +9 -0
- package/dist/organize.mjs +30 -0
- package/dist/route-DpfY0zq-.mjs +114 -0
- package/dist/storage.d.mts +41 -48
- package/dist/storage.mjs +3 -1
- package/dist/testing.d.mts +9 -1
- package/dist/testing.mjs +25 -4
- package/dist/usage-row-BQQM-_t6.mjs +36 -0
- package/dist/usage.d.mts +1 -1
- package/dist/usage.mjs +7 -40
- package/dist/write-guard-CYrY58GM.mjs +174 -0
- package/package.json +32 -36
- package/src/artifact.ts +4 -1
- package/src/backend-rdb.ts +331 -0
- package/src/backend.ts +32 -15
- package/src/{branch.ts → branch/index.ts} +109 -110
- package/src/branch/port.ts +62 -0
- package/src/branch/semantics.ts +69 -0
- package/src/db/open.ts +180 -0
- package/src/db/stats.ts +449 -0
- package/src/deletion-error.ts +17 -0
- package/src/deletion.ts +19 -72
- package/src/entities/index.ts +16 -1
- package/src/entities/v3/event-usage.ts +3 -3
- package/src/entities/v3/events.ts +3 -3
- package/src/entities/v3/index.ts +4 -16
- package/src/entities/v3/persistence-state.ts +3 -3
- package/src/entities/v3/schema-meta.ts +3 -3
- package/src/entities/v3/session-counts.ts +3 -3
- package/src/entities/v3/session-events.ts +3 -3
- package/src/entities/v3/session-projcache-rows.ts +3 -3
- package/src/entities/v3/session-usage.ts +3 -3
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-records.ts +17 -0
- package/src/entities/v3/storage-units.ts +3 -3
- package/src/{schema.ts → event-shape.ts} +0 -46
- package/src/export.ts +32 -78
- package/src/handles.ts +349 -0
- package/src/http/route.ts +178 -0
- package/src/import-storages.ts +5 -2
- package/src/import.ts +63 -125
- package/src/index.ts +15 -1666
- package/src/legacy.ts +3 -1
- package/src/live-routing.ts +205 -0
- package/src/log/export.ts +84 -0
- package/src/{log.ts → log/repair.ts} +1 -268
- package/src/log/rows.ts +107 -0
- package/src/log/usage-row.ts +64 -0
- package/src/migrations.ts +196 -0
- package/src/organize.ts +38 -0
- package/src/read-path.ts +190 -0
- package/src/routes.ts +2 -1
- package/src/rows.ts +25 -77
- package/src/session-ops.ts +444 -0
- package/src/session-store.ts +675 -0
- package/src/stats-fold.ts +44 -0
- package/src/storage-takeover/index.ts +2 -5
- package/src/storage-takeover/legacy-domain-migration.ts +118 -0
- package/src/storage-takeover/projection-cache.ts +122 -71
- package/src/storage-takeover/read-model.ts +189 -0
- package/src/storage-takeover/repository.ts +236 -442
- package/src/storage-takeover/storage-backend.ts +44 -159
- package/src/storage-takeover/types.ts +45 -19
- package/src/storage-takeover/workspace-documents.ts +65 -0
- package/src/storage.ts +4 -2
- package/src/testing.ts +21 -1
- package/src/usage.ts +11 -65
- package/src/version.ts +13 -0
- package/src/write-path.ts +286 -0
- package/dist/branch-CoPCRKer.mjs +0 -401
- package/dist/deletion-CWl89TIR.mjs +0 -2249
- package/dist/gc.d.mts +0 -4
- package/dist/gc.mjs +0 -48
- package/dist/log-CcWy46VE.mjs +0 -558
- package/dist/routes-hZjTcX_R.mjs +0 -9
- package/dist/sqlite-BNRd1aqR.mjs +0 -1778
- package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
- package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
- package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
- package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
- package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
- package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
- package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
- package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
- package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
- package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
- package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
- package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
- package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
- package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
- package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
- package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
- package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
- package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
- package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
- package/src/adapters/index.ts +0 -12
- package/src/adapters/to-postgres.ts +0 -93
- package/src/adapters/to-sqlite.ts +0 -83
- package/src/adapters/types.ts +0 -44
- package/src/drizzle/postgres-v2.ts +0 -10
- package/src/drizzle/postgres-v3.ts +0 -18
- package/src/drizzle/sqlite-v2.ts +0 -9
- package/src/drizzle/sqlite-v3.ts +0 -18
- package/src/entities/v2/index.ts +0 -20
- package/src/entities/v2/session-events.ts +0 -10
- package/src/entities/v3/schedule-tasks.ts +0 -16
- package/src/entities/v3/workspace-sessions.ts +0 -19
- package/src/entities/v3/workspace-state.ts +0 -12
- package/src/entities/v3/workspaces.ts +0 -14
- package/src/gc.ts +0 -77
- package/src/postgres.ts +0 -1015
- package/src/sqlite.ts +0 -1096
|
@@ -17,77 +17,18 @@ import {
|
|
|
17
17
|
type ForkFromOptions,
|
|
18
18
|
type SessionBranchProvider,
|
|
19
19
|
} from "@morlay/session-branch";
|
|
20
|
-
import {
|
|
21
|
-
import type {
|
|
22
|
-
import
|
|
23
|
-
import { isLegacyVersion } from "./legacy.ts";
|
|
24
|
-
import { rowToMeta } from "./log.ts";
|
|
25
|
-
|
|
26
|
-
function assertRewindBoundary(
|
|
27
|
-
id: SessionId,
|
|
28
|
-
toBoundary: number,
|
|
29
|
-
boundaryType: string | undefined,
|
|
30
|
-
head: number,
|
|
31
|
-
): void {
|
|
32
|
-
if (toBoundary === -1) return;
|
|
33
|
-
if (toBoundary > head) {
|
|
34
|
-
throw new SessionBranchError(
|
|
35
|
-
`rewind boundary ${toBoundary} is beyond the stored head ${head}`,
|
|
36
|
-
"INVALID_BOUNDARY",
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
if (boundaryType === undefined) {
|
|
40
|
-
throw new SessionBranchError(
|
|
41
|
-
`rewind boundary ${toBoundary} does not exist in session "${id}"`,
|
|
42
|
-
"INVALID_BOUNDARY",
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
if (boundaryType !== "turn/end" && boundaryType !== "user/message") {
|
|
46
|
-
throw new SessionBranchError(
|
|
47
|
-
`rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryType})`,
|
|
48
|
-
"INVALID_BOUNDARY",
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function locateTurnEnd(
|
|
54
|
-
events: readonly SessionEvent[],
|
|
55
|
-
atSeq?: number,
|
|
56
|
-
mode: BranchAnchorMode = "after",
|
|
57
|
-
): number {
|
|
58
|
-
const ends = events.filter((event) => event.type === "turn/end").map((event) => event.seq);
|
|
59
|
-
if (atSeq === undefined) {
|
|
60
|
-
const last = ends.at(-1);
|
|
61
|
-
if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
62
|
-
return last;
|
|
63
|
-
}
|
|
64
|
-
if (mode === "before") {
|
|
65
|
-
let boundary = -1;
|
|
66
|
-
for (const seq of ends) {
|
|
67
|
-
if (seq < atSeq) boundary = seq;
|
|
68
|
-
else break;
|
|
69
|
-
}
|
|
70
|
-
return boundary;
|
|
71
|
-
}
|
|
72
|
-
const firstAfter = ends.find((seq) => seq >= atSeq);
|
|
73
|
-
if (firstAfter !== undefined) return firstAfter;
|
|
74
|
-
|
|
75
|
-
const lastStart = [...events].reverse().find((event) => event.type === "turn/start");
|
|
76
|
-
if (lastStart !== undefined && lastStart.seq <= atSeq) {
|
|
77
|
-
throw new SessionBranchError(`anchor ${atSeq} lies inside an open turn`, "OPEN_TURN");
|
|
78
|
-
}
|
|
79
|
-
const last = ends.at(-1);
|
|
80
|
-
if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
81
|
-
return last;
|
|
82
|
-
}
|
|
20
|
+
import { isLegacyVersion } from "../legacy.ts";
|
|
21
|
+
import type { BranchPort } from "./port.ts";
|
|
22
|
+
import { rowToEvent, rowToMeta } from "../log/rows.ts";
|
|
83
23
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
24
|
+
import {
|
|
25
|
+
assertRewindBoundary,
|
|
26
|
+
locateTurnEnd,
|
|
27
|
+
mintSessionId,
|
|
28
|
+
renumber,
|
|
29
|
+
} from "./semantics.ts";
|
|
87
30
|
|
|
88
|
-
|
|
89
|
-
return `session-${randomUUID()}` as SessionId;
|
|
90
|
-
}
|
|
31
|
+
export { locateTurnEnd } from "./semantics.ts";
|
|
91
32
|
|
|
92
33
|
export interface LiveSessionHooks {
|
|
93
34
|
getSession(id: SessionId): Session | undefined;
|
|
@@ -180,7 +121,8 @@ export function truncateLiveSession(session: Session, newLength: number): number
|
|
|
180
121
|
};
|
|
181
122
|
// 截断后 agent 的 turn 游标要跟着落在保留前缀的最后一个 turn 上:这里直接读**要保留的那段**(同一个数组,
|
|
182
123
|
// 不经过已弃用的同步历史读 `snapshotEvents`,也不依赖 agent 侧的投影)。
|
|
183
|
-
const lastTurn =
|
|
124
|
+
const lastTurn =
|
|
125
|
+
s.log.slice(0, newLength).findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
184
126
|
s.log.length = newLength;
|
|
185
127
|
s.eventsSnapshot = undefined;
|
|
186
128
|
s.headerFold = undefined;
|
|
@@ -224,7 +166,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
224
166
|
readonly name = "session-rdb";
|
|
225
167
|
|
|
226
168
|
constructor(
|
|
227
|
-
private readonly
|
|
169
|
+
private readonly port: BranchPort,
|
|
228
170
|
|
|
229
171
|
private readonly live: LiveSessionHooks = {
|
|
230
172
|
getSession: () => undefined,
|
|
@@ -248,7 +190,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
248
190
|
id: SessionId,
|
|
249
191
|
signal?: AbortSignal,
|
|
250
192
|
): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
|
|
251
|
-
const stored = await this.
|
|
193
|
+
const stored = await this.port.readLog(id, {}, signal);
|
|
252
194
|
if (stored === undefined)
|
|
253
195
|
throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
254
196
|
return { meta: stored.meta, events: stored.events };
|
|
@@ -261,7 +203,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
261
203
|
): Promise<SessionId> {
|
|
262
204
|
signal?.throwIfAborted();
|
|
263
205
|
const { atSeq, anchorMode = "after", seedSuffix = [], childSessionId, meta = {} } = options;
|
|
264
|
-
const source = await this.
|
|
206
|
+
const source = await this.port.readLog(sourceId, {}, signal);
|
|
265
207
|
if (source === undefined)
|
|
266
208
|
throw new SessionBranchError(`session "${sourceId}" not found`, "SESSION_NOT_FOUND");
|
|
267
209
|
const boundary = locateTurnEnd(source.events, atSeq, anchorMode);
|
|
@@ -293,14 +235,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
293
235
|
};
|
|
294
236
|
const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
|
|
295
237
|
|
|
296
|
-
|
|
238
|
+
await this.port.ready;
|
|
297
239
|
// 复用父会话的事件行有个前提:**读取视图的 seq 与存储行的 f_sequence 一一对应**。legacy 会话的读视图
|
|
298
240
|
// 由迁移链重建(可能重新编号、增删事件),按 seq 复用会把桥接行挂到语义不符的行上——读取时按 `f_data`
|
|
299
241
|
// 解析,子会话前缀读到的内容就会和它自己的 seed 事件对不上。那种情况下复制事件行(多几行存储,正确优先)。
|
|
300
242
|
const reusable = !source.migrated && source.events.length === source.storedCount;
|
|
301
243
|
const reuse = new Map<number, string>();
|
|
302
244
|
if (reusable) {
|
|
303
|
-
const sourceRows = await
|
|
245
|
+
const sourceRows = await this.port.eventRows(sourceId);
|
|
304
246
|
const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
|
|
305
247
|
for (const event of prefix) {
|
|
306
248
|
const eventId = sourceEventIds.get(event.seq);
|
|
@@ -311,9 +253,9 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
311
253
|
`session-rdb: fork "${sourceId}" copies event rows instead of reusing them — its read view comes from a migration, so stored seqs need not line up`,
|
|
312
254
|
);
|
|
313
255
|
}
|
|
314
|
-
|
|
256
|
+
this.port.registerReuseEventIds(childId, reuse);
|
|
315
257
|
try {
|
|
316
|
-
const handle = await this.
|
|
258
|
+
const handle = await this.port.create(childMeta, {
|
|
317
259
|
inheritedEventCount: SessionLogOffset(prefix.length),
|
|
318
260
|
});
|
|
319
261
|
try {
|
|
@@ -324,7 +266,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
324
266
|
} catch (error) {
|
|
325
267
|
// fork 没成(id 已存在、append 失败等):别把复用映射留在进程里——同一个 childId 之后的落写会按它
|
|
326
268
|
// 去复用父会话的事件行,而那条会话可能完全无关。
|
|
327
|
-
|
|
269
|
+
this.port.dropReuseEventIds(childId);
|
|
328
270
|
throw error;
|
|
329
271
|
}
|
|
330
272
|
// fork 不动统计:继承前缀的消耗是父会话真实发生的那一次,子会话只记自己新产生的
|
|
@@ -348,11 +290,11 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
348
290
|
|
|
349
291
|
if (live !== undefined) await this.live.flush(live);
|
|
350
292
|
|
|
351
|
-
|
|
293
|
+
await this.port.ready;
|
|
352
294
|
|
|
353
295
|
// SessionBranch 是「先停止、再操作」的排他面(ADR-rewind绕过handle模型直接截断):rewind 直连 DB 做最小工作,
|
|
354
296
|
// 不走持久化抽象的全量读路径(readLog 会拉全部事件 + legacy 转换 + 读视图修复)。
|
|
355
|
-
const row = await
|
|
297
|
+
const row = await this.port.sessionRow(id);
|
|
356
298
|
if (row === undefined) {
|
|
357
299
|
throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
358
300
|
}
|
|
@@ -362,7 +304,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
362
304
|
let kept: readonly SessionEvent[] | undefined;
|
|
363
305
|
if (isLegacyVersion(row.fVersion)) {
|
|
364
306
|
// 旧格式的持久化坐标与当前视图 seq 不一致:只有这条路径需要读日志(含格式转换与读视图修复)
|
|
365
|
-
const log = await this.
|
|
307
|
+
const log = await this.port.readLog(id, {}, signal);
|
|
366
308
|
if (log === undefined) {
|
|
367
309
|
throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
368
310
|
}
|
|
@@ -373,14 +315,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
373
315
|
kept = balanceRewindPrefix(log.events.slice(0, rawKeepLength));
|
|
374
316
|
} else {
|
|
375
317
|
const boundaryType =
|
|
376
|
-
toBoundary === -1 ? undefined : await
|
|
318
|
+
toBoundary === -1 ? undefined : await this.port.eventTypeAt(id, toBoundary);
|
|
377
319
|
assertRewindBoundary(id, toBoundary, boundaryType, row.fHeadSequence);
|
|
378
320
|
rawKeepLength =
|
|
379
321
|
toBoundary === -1 ? 0 : boundaryType === "turn/end" ? toBoundary + 1 : toBoundary;
|
|
380
322
|
}
|
|
381
323
|
const keepLength =
|
|
382
324
|
kept === undefined
|
|
383
|
-
? await this.rewindKeepLength(id, rawKeepLength
|
|
325
|
+
? await this.rewindKeepLength(id, rawKeepLength)
|
|
384
326
|
: kept.length;
|
|
385
327
|
if (keepLength < rawKeepLength) {
|
|
386
328
|
this.live.warn?.(
|
|
@@ -389,7 +331,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
389
331
|
}
|
|
390
332
|
|
|
391
333
|
const denseBoundary = keepLength - 1;
|
|
392
|
-
const newSeedLength = await
|
|
334
|
+
const newSeedLength = await this.port.transaction(async (tx) => {
|
|
393
335
|
signal?.throwIfAborted();
|
|
394
336
|
const head = await tx.getHead(id);
|
|
395
337
|
if (denseBoundary > head.fHeadSequence) {
|
|
@@ -398,14 +340,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
398
340
|
"INVALID_BOUNDARY",
|
|
399
341
|
);
|
|
400
342
|
}
|
|
343
|
+
let nextHead: { fEventId: string; fSequence: number } | undefined;
|
|
401
344
|
if (denseBoundary < head.fHeadSequence) {
|
|
402
345
|
await tx.deleteBridgeTail(id, denseBoundary + 1);
|
|
403
346
|
const prev = denseBoundary === -1 ? undefined : await tx.getPrevBridge(id, denseBoundary);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
347
|
+
nextHead =
|
|
348
|
+
prev === undefined
|
|
349
|
+
? { fEventId: "", fSequence: -1 }
|
|
350
|
+
: { fEventId: prev.fEventId, fSequence: prev.fSequence };
|
|
409
351
|
}
|
|
410
352
|
|
|
411
353
|
const storedSeedLength = await tx.getSeedLength(id);
|
|
@@ -416,14 +358,36 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
416
358
|
}
|
|
417
359
|
|
|
418
360
|
await tx.refreshTitle(id);
|
|
419
|
-
|
|
361
|
+
if (nextHead === undefined) {
|
|
362
|
+
await tx.bumpRevision(id);
|
|
363
|
+
} else {
|
|
364
|
+
// head 回退与 revision bump 是同一次行写:分成两条 UPDATE 就是两次行版本。
|
|
365
|
+
await tx.updateHeadAndRevision(id, nextHead.fEventId, nextHead.fSequence);
|
|
366
|
+
}
|
|
420
367
|
return shrunk;
|
|
421
368
|
});
|
|
422
369
|
|
|
423
|
-
|
|
370
|
+
this.port.confirmHead(id, denseBoundary);
|
|
424
371
|
|
|
425
372
|
if (live !== undefined) {
|
|
426
|
-
|
|
373
|
+
// 内存 log 与存储同源的判据:上游 `Session.append` 用 `log.length` 当 seq,所以「内存条目数 - 1」
|
|
374
|
+
// 就是存储 head。内存**落后**于存储时(导入 / 取消与截断的竞态),不能拿内存做长度运算——
|
|
375
|
+
// `log.length = n` 会在尾部补出空洞,之后每次 append 的 seq 都与存储错位,撤回边界就会点到
|
|
376
|
+
// 存储里不存在的位置。落后就按存储前缀**整体重建**;`replaceLiveSessionLog` 顺手把 folds /
|
|
377
|
+
// derived / surface 这些派生缓存一并清掉。
|
|
378
|
+
const liveLength = (live as unknown as { log: readonly SessionEvent[] }).log.length;
|
|
379
|
+
let lastTurn: number;
|
|
380
|
+
if (liveLength < keepLength) {
|
|
381
|
+
const prefix =
|
|
382
|
+
kept ??
|
|
383
|
+
(await this.port.eventRows(id))
|
|
384
|
+
.map(rowToEvent)
|
|
385
|
+
.filter((event) => event.seq <= denseBoundary);
|
|
386
|
+
replaceLiveSessionLog(live, prefix);
|
|
387
|
+
lastTurn = prefix.findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
388
|
+
} else {
|
|
389
|
+
lastTurn = truncateLiveSession(live, keepLength);
|
|
390
|
+
}
|
|
427
391
|
|
|
428
392
|
this.live.resetProjections?.(live);
|
|
429
393
|
|
|
@@ -436,16 +400,32 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
436
400
|
if (phase !== undefined) phase.lastTurn = lastTurn;
|
|
437
401
|
}
|
|
438
402
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
403
|
+
this.port.resetWriterAfterRewind(
|
|
404
|
+
id,
|
|
405
|
+
keepLength,
|
|
406
|
+
newSeedLength === null ? undefined : newSeedLength,
|
|
407
|
+
);
|
|
443
408
|
|
|
444
409
|
agent?.inbox?.clear();
|
|
445
410
|
|
|
446
|
-
|
|
411
|
+
// flush 是「把 live 缓冲尽力落库」:失败不该让撤回失败,也不该阻断下面的缓存刷新——
|
|
412
|
+
// 撤回的正确性由库里的截断保证,缓存必须跟着新水位走,否则页面会一直显示撤回前的数据。
|
|
413
|
+
try {
|
|
414
|
+
await this.live.flush(live);
|
|
415
|
+
} catch (error: unknown) {
|
|
416
|
+
this.live.warn?.(
|
|
417
|
+
`session-rdb: flush after rewind for "${id}" failed (the stored log is already truncated; live buffers may lag): ${describeError(error)}`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
447
420
|
|
|
448
|
-
|
|
421
|
+
// 带上新水位:`write` 失败时缓存按这个水位截断兜底,而不是把撤回前的旧快照留在读路径上。
|
|
422
|
+
await this.refreshProjectionCache({
|
|
423
|
+
id: live.id,
|
|
424
|
+
header: live.header,
|
|
425
|
+
inheritedEventCount: live.inheritedEventCount,
|
|
426
|
+
headSeq: keepLength - 1,
|
|
427
|
+
snapshotEvents: () => live.snapshotEvents(),
|
|
428
|
+
});
|
|
449
429
|
} else {
|
|
450
430
|
// rewind 不读日志:缓存行按新水位截断(保留下来的投影单元仍在截断前缀内)
|
|
451
431
|
await this.refreshProjectionCache({
|
|
@@ -458,20 +438,16 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
458
438
|
}
|
|
459
439
|
|
|
460
440
|
// 撤回不改统计:请求已发起就已经消耗过(见 ADR-撤回不回退统计)。
|
|
461
|
-
return { header: rowToMeta(row), revision: (await
|
|
441
|
+
return { header: rowToMeta(row), revision: (await this.port.readStoredRevision(id))! };
|
|
462
442
|
}
|
|
463
443
|
|
|
464
444
|
// 保留长度只依赖尾部窗口:从 rawKeepLength 往回读类型(有界),窗口没覆盖到最近一个 turn/end
|
|
465
445
|
// 就翻倍重读,直到覆盖或到达前缀开头。
|
|
466
|
-
private async rewindKeepLength(
|
|
467
|
-
id: SessionId,
|
|
468
|
-
rawKeepLength: number,
|
|
469
|
-
backend: Backend,
|
|
470
|
-
): Promise<number> {
|
|
446
|
+
private async rewindKeepLength(id: SessionId, rawKeepLength: number): Promise<number> {
|
|
471
447
|
if (rawKeepLength === 0) return 0;
|
|
472
448
|
let limit = 64;
|
|
473
449
|
for (;;) {
|
|
474
|
-
const rows = await
|
|
450
|
+
const rows = await this.port.eventTypesBefore(id, rawKeepLength, limit);
|
|
475
451
|
const types = [...rows].reverse().map((row) => row.fType);
|
|
476
452
|
const windowStart = rawKeepLength - types.length;
|
|
477
453
|
if (types.includes("turn/end") || windowStart === 0 || types.length >= rawKeepLength) {
|
|
@@ -556,7 +532,7 @@ export class SessionBranchRdb extends SessionBranch {
|
|
|
556
532
|
}
|
|
557
533
|
|
|
558
534
|
private readonly provider = new SessionBranchRdbProvider(
|
|
559
|
-
this.ctx.sessionPersistence as
|
|
535
|
+
(this.ctx.sessionPersistence as unknown as { branchPort(): BranchPort }).branchPort(),
|
|
560
536
|
{
|
|
561
537
|
getSession: (id) => this.ctx.sessions.get(id),
|
|
562
538
|
getAgent: (id) => {
|
|
@@ -583,15 +559,34 @@ export class SessionBranchRdb extends SessionBranch {
|
|
|
583
559
|
}
|
|
584
560
|
| undefined;
|
|
585
561
|
if (cache === undefined) return;
|
|
586
|
-
|
|
587
|
-
|
|
562
|
+
const cold = session.snapshotEvents().length === 0;
|
|
563
|
+
if (cold && session.headSeq !== undefined && cache.truncateTo !== undefined) {
|
|
564
|
+
// 没有 live 会话:不 fold 日志,按新水位截断即可。
|
|
565
|
+
try {
|
|
588
566
|
await cache.truncateTo(session.header, session.inheritedEventCount, session.headSeq);
|
|
589
|
-
|
|
567
|
+
} catch (error: unknown) {
|
|
568
|
+
this.ctx.logger.warn(
|
|
569
|
+
`session-rdb: projection cache truncate after rewind for "${session.id}" failed: ${describeError(error)}`,
|
|
570
|
+
);
|
|
590
571
|
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
591
575
|
await cache.write(session);
|
|
592
576
|
} catch (error: unknown) {
|
|
577
|
+
// 写回失败(例如带报错信息的日志让上游 fold 抛错、或 flush 失败):退到「按新水位截断镜像」,
|
|
578
|
+
// 至少读路径与服务端的截断结果一致;否则要等实例重启才会从库里重新加载。
|
|
579
|
+
if (session.headSeq !== undefined && cache.truncateTo !== undefined) {
|
|
580
|
+
try {
|
|
581
|
+
await cache.truncateTo(session.header, session.inheritedEventCount, session.headSeq);
|
|
582
|
+
} catch (fallbackError: unknown) {
|
|
583
|
+
this.ctx.logger.warn(
|
|
584
|
+
`session-rdb: projection cache truncate fallback after rewind for "${session.id}" failed: ${describeError(fallbackError)}`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
593
588
|
this.ctx.logger.warn(
|
|
594
|
-
`session-rdb: projection cache
|
|
589
|
+
`session-rdb: projection cache rebuild after rewind for "${session.id}" failed (fell back to truncating the cache at the new head): ${describeError(error)}\n${error instanceof Error ? (error.stack ?? "") : ""}`,
|
|
595
590
|
);
|
|
596
591
|
}
|
|
597
592
|
},
|
|
@@ -632,3 +627,7 @@ export class SessionBranchRdb extends SessionBranch {
|
|
|
632
627
|
}
|
|
633
628
|
|
|
634
629
|
export default SessionBranchRdb;
|
|
630
|
+
|
|
631
|
+
function describeError(error: unknown): string {
|
|
632
|
+
return error instanceof Error ? error.message : "unknown error";
|
|
633
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session";
|
|
2
|
+
import type { SessionHandle, SessionPersistenceRevision } from "@deepseek-ai/dsh-session-persistence";
|
|
3
|
+
import type { SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
4
|
+
import type { BackendTx, EventRow, SessionRow } from "../backend.ts";
|
|
5
|
+
|
|
6
|
+
/** 一份读出来的日志(分支只看这些字段)。 */
|
|
7
|
+
export interface BranchLog {
|
|
8
|
+
meta: SessionHeader;
|
|
9
|
+
inheritedEventCount: number;
|
|
10
|
+
events: SessionEvent[];
|
|
11
|
+
tornFrom?: number;
|
|
12
|
+
incarnation: string;
|
|
13
|
+
revision: number;
|
|
14
|
+
storedCount: number;
|
|
15
|
+
migrated: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 分支能力需要的持久化面(端口)。
|
|
20
|
+
*
|
|
21
|
+
* 为什么不是整个 `SessionPersistenceRdb`:分支只需要「读日志前缀、写截断、认领复用映射」这几件事,
|
|
22
|
+
* 端口里的每个动词都有真实调用方(不是把服务的方法表转抄一遍)。rewind 走直连快路径,
|
|
23
|
+
* 所以这里保留了行级的 `eventRows` / `sessionRow` / `transaction`,而不是只给一个 `readLog`。
|
|
24
|
+
*/
|
|
25
|
+
export interface BranchPort {
|
|
26
|
+
readonly ready: Promise<void>;
|
|
27
|
+
|
|
28
|
+
readLog(
|
|
29
|
+
id: SessionId,
|
|
30
|
+
options?: { fromSeq?: number },
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
): Promise<BranchLog | undefined>;
|
|
33
|
+
|
|
34
|
+
create(meta: SessionHeader, options?: { inheritedEventCount?: SessionLogOffset }): Promise<SessionHandle>;
|
|
35
|
+
|
|
36
|
+
eventRows(id: SessionId, fromSequence?: number): Promise<EventRow[]>;
|
|
37
|
+
|
|
38
|
+
eventTypesBefore(
|
|
39
|
+
id: SessionId,
|
|
40
|
+
beforeSequence: number,
|
|
41
|
+
limit: number,
|
|
42
|
+
): Promise<Array<Pick<EventRow, "fSequence" | "fType">>>;
|
|
43
|
+
|
|
44
|
+
sessionRow(id: SessionId): Promise<SessionRow | undefined>;
|
|
45
|
+
|
|
46
|
+
eventTypeAt(id: SessionId, sequence: number): Promise<string | undefined>;
|
|
47
|
+
|
|
48
|
+
transaction<T>(fn: (tx: BackendTx) => Promise<T>): Promise<T>;
|
|
49
|
+
|
|
50
|
+
/** head 回退与 revision bump 是同一次行写,确认位点后写路径才允许继续 append。 */
|
|
51
|
+
confirmHead(id: SessionId, sequence: number): void;
|
|
52
|
+
|
|
53
|
+
readStoredRevision(id: SessionId): Promise<SessionPersistenceRevision | undefined>;
|
|
54
|
+
|
|
55
|
+
/** fork 复用父会话的事件行:子会话落写时按 seq 取回父行 id。 */
|
|
56
|
+
registerReuseEventIds(childId: SessionId, map: ReadonlyMap<number, string>): void;
|
|
57
|
+
|
|
58
|
+
dropReuseEventIds(childId: SessionId): void;
|
|
59
|
+
|
|
60
|
+
/** rewind 截断后把活跃写句柄的游标与 seed 长度挪到新水位。 */
|
|
61
|
+
resetWriterAfterRewind(id: SessionId, cursor: number, inheritedEventCount?: number): void;
|
|
62
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SessionBranchError, type BranchAnchorMode } from "@morlay/session-branch";
|
|
3
|
+
import type { SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
|
|
4
|
+
|
|
5
|
+
export function assertRewindBoundary(
|
|
6
|
+
id: SessionId,
|
|
7
|
+
toBoundary: number,
|
|
8
|
+
boundaryType: string | undefined,
|
|
9
|
+
head: number,
|
|
10
|
+
): void {
|
|
11
|
+
if (toBoundary === -1) return;
|
|
12
|
+
if (toBoundary > head) {
|
|
13
|
+
throw new SessionBranchError(
|
|
14
|
+
`rewind boundary ${toBoundary} is beyond the stored head ${head}`,
|
|
15
|
+
"INVALID_BOUNDARY",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (boundaryType === undefined) {
|
|
19
|
+
throw new SessionBranchError(
|
|
20
|
+
`rewind boundary ${toBoundary} does not exist in session "${id}"`,
|
|
21
|
+
"INVALID_BOUNDARY",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (boundaryType !== "turn/end" && boundaryType !== "user/message") {
|
|
25
|
+
throw new SessionBranchError(
|
|
26
|
+
`rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryType})`,
|
|
27
|
+
"INVALID_BOUNDARY",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function locateTurnEnd(
|
|
33
|
+
events: readonly SessionEvent[],
|
|
34
|
+
atSeq?: number,
|
|
35
|
+
mode: BranchAnchorMode = "after",
|
|
36
|
+
): number {
|
|
37
|
+
const ends = events.filter((event) => event.type === "turn/end").map((event) => event.seq);
|
|
38
|
+
if (atSeq === undefined) {
|
|
39
|
+
const last = ends.at(-1);
|
|
40
|
+
if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
41
|
+
return last;
|
|
42
|
+
}
|
|
43
|
+
if (mode === "before") {
|
|
44
|
+
let boundary = -1;
|
|
45
|
+
for (const seq of ends) {
|
|
46
|
+
if (seq < atSeq) boundary = seq;
|
|
47
|
+
else break;
|
|
48
|
+
}
|
|
49
|
+
return boundary;
|
|
50
|
+
}
|
|
51
|
+
const firstAfter = ends.find((seq) => seq >= atSeq);
|
|
52
|
+
if (firstAfter !== undefined) return firstAfter;
|
|
53
|
+
|
|
54
|
+
const lastStart = [...events].reverse().find((event) => event.type === "turn/start");
|
|
55
|
+
if (lastStart !== undefined && lastStart.seq <= atSeq) {
|
|
56
|
+
throw new SessionBranchError(`anchor ${atSeq} lies inside an open turn`, "OPEN_TURN");
|
|
57
|
+
}
|
|
58
|
+
const last = ends.at(-1);
|
|
59
|
+
if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
60
|
+
return last;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renumber(events: readonly SessionEvent[], offset: number): SessionEvent[] {
|
|
64
|
+
return events.map((event, index) => ({ ...event, seq: offset + index }) as SessionEvent);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function mintSessionId(): SessionId {
|
|
68
|
+
return `session-${randomUUID()}` as SessionId;
|
|
69
|
+
}
|