@morlay/session-rdb 0.1.9 → 0.1.10-alpha.2
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 +24 -3
- package/dist/artifact.d.mts +118 -3
- package/dist/artifact.mjs +5 -3
- package/dist/{log-C0q5svm7.d.mts → backend-BKtJ5sO5.d.mts} +189 -291
- 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 -3
- package/dist/deletion.mjs +2 -2
- package/dist/export-CfJQjQfr.mjs +123 -0
- package/dist/export.d.mts +1 -3
- package/dist/export.mjs +32 -57
- package/dist/import.d.mts +1 -3
- package/dist/import.mjs +32 -78
- package/dist/index-CUD2ZpTA.d.mts +71 -0
- package/dist/index.d.mts +20 -2
- package/dist/index.mjs +1562 -5
- 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 -50
- package/dist/storage.mjs +3 -1
- package/dist/testing.d.mts +9 -5
- package/dist/testing.mjs +27 -8
- package/dist/usage-row-BQQM-_t6.mjs +36 -0
- package/dist/usage.d.mts +1 -1
- package/dist/usage.mjs +8 -42
- 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} +125 -112
- 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 -1630
- 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 -272
- 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-query.ts +17 -4
- 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 +139 -77
- package/src/storage-takeover/read-model.ts +189 -0
- package/src/storage-takeover/repository.ts +238 -425
- package/src/storage-takeover/storage-backend.ts +47 -149
- package/src/storage-takeover/types.ts +45 -14
- 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-DbFW3rJX.mjs +0 -391
- package/dist/deletion-ODZFcbZm.mjs +0 -2218
- package/dist/gc.d.mts +0 -6
- package/dist/gc.mjs +0 -49
- package/dist/log-DYxdjJ-l.mjs +0 -562
- package/dist/routes-DGBlBE_I.mjs +0 -10
- package/dist/sqlite-DjOnMRh2.mjs +0 -1777
- 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 -1086
|
@@ -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;
|
|
20
|
+
import { isLegacyVersion } from "../legacy.ts";
|
|
21
|
+
import type { BranchPort } from "./port.ts";
|
|
22
|
+
import { rowToEvent, rowToMeta } from "../log/rows.ts";
|
|
74
23
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return last;
|
|
82
|
-
}
|
|
24
|
+
import {
|
|
25
|
+
assertRewindBoundary,
|
|
26
|
+
locateTurnEnd,
|
|
27
|
+
mintSessionId,
|
|
28
|
+
renumber,
|
|
29
|
+
} from "./semantics.ts";
|
|
83
30
|
|
|
84
|
-
|
|
85
|
-
return events.map((event, index) => ({ ...event, seq: offset + index }) as SessionEvent);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function mintSessionId(): SessionId {
|
|
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;
|
|
@@ -153,7 +94,19 @@ function resetSurfaceManager(surfaceManager: SurfaceManagerLike): void {
|
|
|
153
94
|
surfaceManager._pendingPlan = undefined;
|
|
154
95
|
}
|
|
155
96
|
|
|
156
|
-
|
|
97
|
+
/**
|
|
98
|
+
* 会话**当前内存 log** 的快照(复制一份)。用于"需要完整序列"的操作(首次落库的 seed、rewind / fork 的历史),
|
|
99
|
+
* 不走已弃用的同步历史读 `snapshotEvents()`——那三个读口上游已声明「新调用禁止」,而这里要的是当前内存里
|
|
100
|
+
* 那份完整日志,不是按需分页的历史。
|
|
101
|
+
* @param session - 会话。
|
|
102
|
+
* @returns 事件副本(调用方可以自由遍历)。
|
|
103
|
+
*/
|
|
104
|
+
export function liveEventsOf(session: Session): readonly SessionEvent[] {
|
|
105
|
+
return [...(session as unknown as { log: readonly SessionEvent[] }).log];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @returns 截断后日志里最后一个 `turn/start` 的 turn 号(没有则 0)。 */
|
|
109
|
+
export function truncateLiveSession(session: Session, newLength: number): number {
|
|
157
110
|
const s = session as unknown as {
|
|
158
111
|
log: SessionEvent[];
|
|
159
112
|
eventsSnapshot?: unknown;
|
|
@@ -166,6 +119,10 @@ export function truncateLiveSession(session: Session, newLength: number): void {
|
|
|
166
119
|
derivedGeneration: number;
|
|
167
120
|
surfaceManager: SurfaceManagerLike;
|
|
168
121
|
};
|
|
122
|
+
// 截断后 agent 的 turn 游标要跟着落在保留前缀的最后一个 turn 上:这里直接读**要保留的那段**(同一个数组,
|
|
123
|
+
// 不经过已弃用的同步历史读 `snapshotEvents`,也不依赖 agent 侧的投影)。
|
|
124
|
+
const lastTurn =
|
|
125
|
+
s.log.slice(0, newLength).findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
169
126
|
s.log.length = newLength;
|
|
170
127
|
s.eventsSnapshot = undefined;
|
|
171
128
|
s.headerFold = undefined;
|
|
@@ -176,6 +133,7 @@ export function truncateLiveSession(session: Session, newLength: number): void {
|
|
|
176
133
|
s.derivedNodes = 0;
|
|
177
134
|
s.derivedGeneration = 0;
|
|
178
135
|
resetSurfaceManager(s.surfaceManager);
|
|
136
|
+
return lastTurn;
|
|
179
137
|
}
|
|
180
138
|
|
|
181
139
|
export function replaceLiveSessionLog(session: Session, events: readonly SessionEvent[]): void {
|
|
@@ -208,7 +166,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
208
166
|
readonly name = "session-rdb";
|
|
209
167
|
|
|
210
168
|
constructor(
|
|
211
|
-
private readonly
|
|
169
|
+
private readonly port: BranchPort,
|
|
212
170
|
|
|
213
171
|
private readonly live: LiveSessionHooks = {
|
|
214
172
|
getSession: () => undefined,
|
|
@@ -232,7 +190,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
232
190
|
id: SessionId,
|
|
233
191
|
signal?: AbortSignal,
|
|
234
192
|
): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
|
|
235
|
-
const stored = await this.
|
|
193
|
+
const stored = await this.port.readLog(id, {}, signal);
|
|
236
194
|
if (stored === undefined)
|
|
237
195
|
throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
238
196
|
return { meta: stored.meta, events: stored.events };
|
|
@@ -245,7 +203,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
245
203
|
): Promise<SessionId> {
|
|
246
204
|
signal?.throwIfAborted();
|
|
247
205
|
const { atSeq, anchorMode = "after", seedSuffix = [], childSessionId, meta = {} } = options;
|
|
248
|
-
const source = await this.
|
|
206
|
+
const source = await this.port.readLog(sourceId, {}, signal);
|
|
249
207
|
if (source === undefined)
|
|
250
208
|
throw new SessionBranchError(`session "${sourceId}" not found`, "SESSION_NOT_FOUND");
|
|
251
209
|
const boundary = locateTurnEnd(source.events, atSeq, anchorMode);
|
|
@@ -277,14 +235,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
277
235
|
};
|
|
278
236
|
const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
|
|
279
237
|
|
|
280
|
-
|
|
238
|
+
await this.port.ready;
|
|
281
239
|
// 复用父会话的事件行有个前提:**读取视图的 seq 与存储行的 f_sequence 一一对应**。legacy 会话的读视图
|
|
282
240
|
// 由迁移链重建(可能重新编号、增删事件),按 seq 复用会把桥接行挂到语义不符的行上——读取时按 `f_data`
|
|
283
241
|
// 解析,子会话前缀读到的内容就会和它自己的 seed 事件对不上。那种情况下复制事件行(多几行存储,正确优先)。
|
|
284
242
|
const reusable = !source.migrated && source.events.length === source.storedCount;
|
|
285
243
|
const reuse = new Map<number, string>();
|
|
286
244
|
if (reusable) {
|
|
287
|
-
const sourceRows = await
|
|
245
|
+
const sourceRows = await this.port.eventRows(sourceId);
|
|
288
246
|
const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
|
|
289
247
|
for (const event of prefix) {
|
|
290
248
|
const eventId = sourceEventIds.get(event.seq);
|
|
@@ -295,9 +253,9 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
295
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`,
|
|
296
254
|
);
|
|
297
255
|
}
|
|
298
|
-
|
|
256
|
+
this.port.registerReuseEventIds(childId, reuse);
|
|
299
257
|
try {
|
|
300
|
-
const handle = await this.
|
|
258
|
+
const handle = await this.port.create(childMeta, {
|
|
301
259
|
inheritedEventCount: SessionLogOffset(prefix.length),
|
|
302
260
|
});
|
|
303
261
|
try {
|
|
@@ -308,7 +266,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
308
266
|
} catch (error) {
|
|
309
267
|
// fork 没成(id 已存在、append 失败等):别把复用映射留在进程里——同一个 childId 之后的落写会按它
|
|
310
268
|
// 去复用父会话的事件行,而那条会话可能完全无关。
|
|
311
|
-
|
|
269
|
+
this.port.dropReuseEventIds(childId);
|
|
312
270
|
throw error;
|
|
313
271
|
}
|
|
314
272
|
// fork 不动统计:继承前缀的消耗是父会话真实发生的那一次,子会话只记自己新产生的
|
|
@@ -332,11 +290,11 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
332
290
|
|
|
333
291
|
if (live !== undefined) await this.live.flush(live);
|
|
334
292
|
|
|
335
|
-
|
|
293
|
+
await this.port.ready;
|
|
336
294
|
|
|
337
295
|
// SessionBranch 是「先停止、再操作」的排他面(ADR-rewind绕过handle模型直接截断):rewind 直连 DB 做最小工作,
|
|
338
296
|
// 不走持久化抽象的全量读路径(readLog 会拉全部事件 + legacy 转换 + 读视图修复)。
|
|
339
|
-
const row = await
|
|
297
|
+
const row = await this.port.sessionRow(id);
|
|
340
298
|
if (row === undefined) {
|
|
341
299
|
throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
342
300
|
}
|
|
@@ -346,7 +304,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
346
304
|
let kept: readonly SessionEvent[] | undefined;
|
|
347
305
|
if (isLegacyVersion(row.fVersion)) {
|
|
348
306
|
// 旧格式的持久化坐标与当前视图 seq 不一致:只有这条路径需要读日志(含格式转换与读视图修复)
|
|
349
|
-
const log = await this.
|
|
307
|
+
const log = await this.port.readLog(id, {}, signal);
|
|
350
308
|
if (log === undefined) {
|
|
351
309
|
throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
352
310
|
}
|
|
@@ -357,14 +315,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
357
315
|
kept = balanceRewindPrefix(log.events.slice(0, rawKeepLength));
|
|
358
316
|
} else {
|
|
359
317
|
const boundaryType =
|
|
360
|
-
toBoundary === -1 ? undefined : await
|
|
318
|
+
toBoundary === -1 ? undefined : await this.port.eventTypeAt(id, toBoundary);
|
|
361
319
|
assertRewindBoundary(id, toBoundary, boundaryType, row.fHeadSequence);
|
|
362
320
|
rawKeepLength =
|
|
363
321
|
toBoundary === -1 ? 0 : boundaryType === "turn/end" ? toBoundary + 1 : toBoundary;
|
|
364
322
|
}
|
|
365
323
|
const keepLength =
|
|
366
324
|
kept === undefined
|
|
367
|
-
? await this.rewindKeepLength(id, rawKeepLength
|
|
325
|
+
? await this.rewindKeepLength(id, rawKeepLength)
|
|
368
326
|
: kept.length;
|
|
369
327
|
if (keepLength < rawKeepLength) {
|
|
370
328
|
this.live.warn?.(
|
|
@@ -373,7 +331,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
373
331
|
}
|
|
374
332
|
|
|
375
333
|
const denseBoundary = keepLength - 1;
|
|
376
|
-
const newSeedLength = await
|
|
334
|
+
const newSeedLength = await this.port.transaction(async (tx) => {
|
|
377
335
|
signal?.throwIfAborted();
|
|
378
336
|
const head = await tx.getHead(id);
|
|
379
337
|
if (denseBoundary > head.fHeadSequence) {
|
|
@@ -382,14 +340,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
382
340
|
"INVALID_BOUNDARY",
|
|
383
341
|
);
|
|
384
342
|
}
|
|
343
|
+
let nextHead: { fEventId: string; fSequence: number } | undefined;
|
|
385
344
|
if (denseBoundary < head.fHeadSequence) {
|
|
386
345
|
await tx.deleteBridgeTail(id, denseBoundary + 1);
|
|
387
346
|
const prev = denseBoundary === -1 ? undefined : await tx.getPrevBridge(id, denseBoundary);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
347
|
+
nextHead =
|
|
348
|
+
prev === undefined
|
|
349
|
+
? { fEventId: "", fSequence: -1 }
|
|
350
|
+
: { fEventId: prev.fEventId, fSequence: prev.fSequence };
|
|
393
351
|
}
|
|
394
352
|
|
|
395
353
|
const storedSeedLength = await tx.getSeedLength(id);
|
|
@@ -400,14 +358,36 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
400
358
|
}
|
|
401
359
|
|
|
402
360
|
await tx.refreshTitle(id);
|
|
403
|
-
|
|
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
|
+
}
|
|
404
367
|
return shrunk;
|
|
405
368
|
});
|
|
406
369
|
|
|
407
|
-
|
|
370
|
+
this.port.confirmHead(id, denseBoundary);
|
|
408
371
|
|
|
409
372
|
if (live !== undefined) {
|
|
410
|
-
|
|
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
|
+
}
|
|
411
391
|
|
|
412
392
|
this.live.resetProjections?.(live);
|
|
413
393
|
|
|
@@ -416,22 +396,36 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
416
396
|
if (agent !== undefined) {
|
|
417
397
|
agent.requestHeaderLogged = false;
|
|
418
398
|
|
|
419
|
-
const lastTurn =
|
|
420
|
-
live.snapshotEvents().findLast((e) => e.type === "turn/start")?.data.turn ?? 0;
|
|
421
399
|
const phase = (agent as unknown as { phase?: { lastTurn?: number } }).phase;
|
|
422
400
|
if (phase !== undefined) phase.lastTurn = lastTurn;
|
|
423
401
|
}
|
|
424
402
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
403
|
+
this.port.resetWriterAfterRewind(
|
|
404
|
+
id,
|
|
405
|
+
keepLength,
|
|
406
|
+
newSeedLength === null ? undefined : newSeedLength,
|
|
407
|
+
);
|
|
429
408
|
|
|
430
409
|
agent?.inbox?.clear();
|
|
431
410
|
|
|
432
|
-
|
|
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
|
+
}
|
|
433
420
|
|
|
434
|
-
|
|
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
|
+
});
|
|
435
429
|
} else {
|
|
436
430
|
// rewind 不读日志:缓存行按新水位截断(保留下来的投影单元仍在截断前缀内)
|
|
437
431
|
await this.refreshProjectionCache({
|
|
@@ -444,20 +438,16 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
|
|
|
444
438
|
}
|
|
445
439
|
|
|
446
440
|
// 撤回不改统计:请求已发起就已经消耗过(见 ADR-撤回不回退统计)。
|
|
447
|
-
return { header: rowToMeta(row), revision: (await
|
|
441
|
+
return { header: rowToMeta(row), revision: (await this.port.readStoredRevision(id))! };
|
|
448
442
|
}
|
|
449
443
|
|
|
450
444
|
// 保留长度只依赖尾部窗口:从 rawKeepLength 往回读类型(有界),窗口没覆盖到最近一个 turn/end
|
|
451
445
|
// 就翻倍重读,直到覆盖或到达前缀开头。
|
|
452
|
-
private async rewindKeepLength(
|
|
453
|
-
id: SessionId,
|
|
454
|
-
rawKeepLength: number,
|
|
455
|
-
backend: Backend,
|
|
456
|
-
): Promise<number> {
|
|
446
|
+
private async rewindKeepLength(id: SessionId, rawKeepLength: number): Promise<number> {
|
|
457
447
|
if (rawKeepLength === 0) return 0;
|
|
458
448
|
let limit = 64;
|
|
459
449
|
for (;;) {
|
|
460
|
-
const rows = await
|
|
450
|
+
const rows = await this.port.eventTypesBefore(id, rawKeepLength, limit);
|
|
461
451
|
const types = [...rows].reverse().map((row) => row.fType);
|
|
462
452
|
const windowStart = rawKeepLength - types.length;
|
|
463
453
|
if (types.includes("turn/end") || windowStart === 0 || types.length >= rawKeepLength) {
|
|
@@ -542,7 +532,7 @@ export class SessionBranchRdb extends SessionBranch {
|
|
|
542
532
|
}
|
|
543
533
|
|
|
544
534
|
private readonly provider = new SessionBranchRdbProvider(
|
|
545
|
-
this.ctx.sessionPersistence as
|
|
535
|
+
(this.ctx.sessionPersistence as unknown as { branchPort(): BranchPort }).branchPort(),
|
|
546
536
|
{
|
|
547
537
|
getSession: (id) => this.ctx.sessions.get(id),
|
|
548
538
|
getAgent: (id) => {
|
|
@@ -569,15 +559,34 @@ export class SessionBranchRdb extends SessionBranch {
|
|
|
569
559
|
}
|
|
570
560
|
| undefined;
|
|
571
561
|
if (cache === undefined) return;
|
|
572
|
-
|
|
573
|
-
|
|
562
|
+
const cold = session.snapshotEvents().length === 0;
|
|
563
|
+
if (cold && session.headSeq !== undefined && cache.truncateTo !== undefined) {
|
|
564
|
+
// 没有 live 会话:不 fold 日志,按新水位截断即可。
|
|
565
|
+
try {
|
|
574
566
|
await cache.truncateTo(session.header, session.inheritedEventCount, session.headSeq);
|
|
575
|
-
|
|
567
|
+
} catch (error: unknown) {
|
|
568
|
+
this.ctx.logger.warn(
|
|
569
|
+
`session-rdb: projection cache truncate after rewind for "${session.id}" failed: ${describeError(error)}`,
|
|
570
|
+
);
|
|
576
571
|
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
577
575
|
await cache.write(session);
|
|
578
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
|
+
}
|
|
579
588
|
this.ctx.logger.warn(
|
|
580
|
-
`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 ?? "") : ""}`,
|
|
581
590
|
);
|
|
582
591
|
}
|
|
583
592
|
},
|
|
@@ -618,3 +627,7 @@ export class SessionBranchRdb extends SessionBranch {
|
|
|
618
627
|
}
|
|
619
628
|
|
|
620
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
|
+
}
|