@morlay/session-rdb 0.0.21-alpha.5 → 0.0.21-alpha.7

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 (50) hide show
  1. package/README.md +53 -27
  2. package/dist/artifact.d.mts +27 -2
  3. package/dist/artifact.mjs +2 -2
  4. package/dist/{backend-a0xDrUFG.d.mts → backend-E0bmTXpz.d.mts} +233 -27
  5. package/dist/{branch-Dlmbkfjj.mjs → branch-BjUR6wfe.mjs} +31 -15
  6. package/dist/deletion.d.mts +1 -1
  7. package/dist/deletion.mjs +1 -1
  8. package/dist/import.d.mts +1 -1
  9. package/dist/import.mjs +2 -3
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.mjs +4 -4
  12. package/dist/{log-C-rbdFwK.mjs → log-qCNkDjh5.mjs} +93 -22
  13. package/dist/{sqlite-C3LHLzYW.mjs → sqlite-zcXVyMxr.mjs} +85 -10
  14. package/dist/{src-Bn0038Sp.mjs → src-B9X8BKyh.mjs} +210 -38
  15. package/dist/storage.d.mts +2 -1
  16. package/dist/storage.mjs +1 -1
  17. package/dist/testing.d.mts +2 -11
  18. package/dist/testing.mjs +12 -29
  19. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +1 -0
  20. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +2 -0
  21. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +10 -0
  22. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +7 -0
  23. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +1041 -0
  24. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +4 -0
  25. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +13 -0
  26. package/locale/en.json +6 -0
  27. package/locale/zh.json +6 -0
  28. package/package.json +24 -22
  29. package/src/backend.ts +46 -1
  30. package/src/branch.ts +28 -11
  31. package/src/drizzle/postgres-v3.ts +1 -0
  32. package/src/drizzle/sqlite-v3.ts +1 -0
  33. package/src/entities/v3/event-usage.ts +5 -5
  34. package/src/entities/v3/sessions.ts +5 -0
  35. package/src/import.ts +3 -3
  36. package/src/index.ts +56 -22
  37. package/src/legacy.ts +14 -3
  38. package/src/log.ts +107 -22
  39. package/src/postgres.ts +65 -5
  40. package/src/rows.ts +125 -0
  41. package/src/schema.ts +3 -2
  42. package/src/session-query.ts +27 -1
  43. package/src/sqlite.ts +71 -3
  44. package/src/storage-takeover/projection-cache.ts +40 -15
  45. package/src/storage-takeover/repository.ts +34 -0
  46. package/src/storage-takeover/storage-backend.ts +3 -1
  47. package/src/testing/coordinator-contract.ts +2 -1
  48. package/src/testing.ts +0 -1
  49. package/src/write-guard.ts +5 -0
  50. package/src/testing/helpers.ts +0 -20
@@ -0,0 +1,4 @@
1
+ -- 只加一列:上游 0.1.7 的 workspace registry 状态多了 `pinnedSessionIds`(钉住的会话 id 集合,
2
+ -- 最近钉住的在前;归档与钉住互斥)。归档在本库里用会话行的 `f_archived_at` 表达,钉住同理用
3
+ -- 一个序号列:值是它在集合里的位置,读回即按它排序。
4
+ ALTER TABLE `t_sessions` ADD `f_pinned_seq` integer;
@@ -0,0 +1,13 @@
1
+ -- 「最后活动时间」物化到会话行:管理面的会话列表要按它排序 + 分页,原先每行一次「该会话最后事件时间」的
2
+ -- 相关子查询(真实库 243 行 = 4.2s)在分页下也救不了——排序要求全表先把值算出来。加列后排序与分页都只
3
+ -- 碰 t_sessions(毫秒级),写路径负责维护;这里一次性回填,没有事件的会话留 NULL(读时回落 createdAt)。
4
+ -- 类型写 `integer`(不是 `bigint`):STRICT 表只认固定类型名,`bigint` 会被拒。
5
+ ALTER TABLE `t_sessions` ADD `f_last_event_at` integer;
6
+ --> statement-breakpoint
7
+ UPDATE `t_sessions`
8
+ SET `f_last_event_at` = (
9
+ SELECT MAX(e.f_created_at)
10
+ FROM t_session_events se
11
+ JOIN t_events e ON e.f_event_id = se.f_event_id
12
+ WHERE se.f_session_id = t_sessions.f_session_id
13
+ );
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "RDB Session Backend",
4
+ "description": "Durable session backend on a relational database: implements both the persistence and the branch providers."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "RDB 会话持久化",
4
+ "description": "关系库上的会话持久化:同时实现持久化与分支两个 provider。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.21-alpha.5",
3
+ "version": "0.0.21-alpha.7",
4
4
  "description": "RDB durable session backend for DeepSeek Harness: implements both session-persistence and session-branch (rewind / retry / fork) providers.",
5
5
  "keywords": [
6
6
  "branch",
@@ -17,6 +17,7 @@
17
17
  "url": "https://github.com/morlay/dsh-plugin.git"
18
18
  },
19
19
  "files": [
20
+ "locale/*.json",
20
21
  "dist",
21
22
  "src",
22
23
  "drizzle",
@@ -36,38 +37,39 @@
36
37
  "./testing": "./dist/testing.mjs",
37
38
  "./usage": "./dist/usage.mjs",
38
39
  "./package.json": "./package.json",
39
- "./cordis.patch.yml": "./cordis.patch.yml"
40
+ "./cordis.patch.yml": "./cordis.patch.yml",
41
+ "./locale/*.json": "./locale/*.json"
40
42
  },
41
43
  "dependencies": {
42
- "@morlay/session-branch": "0.0.10-alpha.5",
44
+ "@morlay/session-branch": "0.0.10-alpha.7",
43
45
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
44
46
  "fflate": "^0.8.2",
45
47
  "pg": "^8.23.0"
46
48
  },
47
49
  "devDependencies": {
48
- "@deepseek-ai/dsh-session-projection": "0.1.6-alpha.2",
49
- "@deepseek-ai/dsh-session-projection-cache": "0.1.6-alpha.2",
50
- "@deepseek-ai/dsh-session-turn-outline": "0.1.6-alpha.2",
51
- "@deepseek-ai/dsh-storage": "0.1.6-alpha.2",
52
- "@deepseek-ai/dsh-storage-domain": "0.1.6-alpha.2",
53
- "@deepseek-ai/dsh-token-meter": "0.1.6-alpha.2",
54
- "@deepseek-ai/dsh-workspace": "0.1.6-alpha.2",
50
+ "@deepseek-ai/dsh-compaction": "0.1.7-alpha.2",
51
+ "@deepseek-ai/dsh-session-projection": "0.1.7-alpha.2",
52
+ "@deepseek-ai/dsh-session-projection-cache": "0.1.7-alpha.2",
53
+ "@deepseek-ai/dsh-session-turn-outline": "0.1.7-alpha.2",
54
+ "@deepseek-ai/dsh-storage": "0.1.7-alpha.2",
55
+ "@deepseek-ai/dsh-storage-domain": "0.1.7-alpha.2",
56
+ "@deepseek-ai/dsh-token-meter": "0.1.7-alpha.2",
57
+ "@deepseek-ai/dsh-workspace": "0.1.7-alpha.2",
55
58
  "@types/pg": "^8.23.1",
56
59
  "drizzle-kit": "1.0.0-rc.5-ab785fc"
57
60
  },
58
61
  "peerDependencies": {
59
- "@deepseek-ai/cordis": "4.0.2",
60
- "@deepseek-ai/dsh-invariants": "0.1.6-alpha.2",
61
- "@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
62
- "@deepseek-ai/dsh-scope": "0.1.6-alpha.2",
63
- "@deepseek-ai/dsh-session": "0.1.6-alpha.2",
64
- "@deepseek-ai/dsh-session-format": "0.1.6-alpha.2",
65
- "@deepseek-ai/dsh-session-format-catalog": "0.1.6-alpha.2",
66
- "@deepseek-ai/dsh-session-persistence": "0.1.6-alpha.2",
67
- "@deepseek-ai/dsh-session-query": "0.1.6-alpha.2",
68
- "@deepseek-ai/dsh-settings": "0.1.6-alpha.2",
69
- "@deepseek-ai/dsh-storage": "0.1.6-alpha.2",
70
- "@deepseek-ai/schemastery": "3.18.2"
62
+ "@deepseek-ai/cordis": "4.0.4",
63
+ "@deepseek-ai/dsh-invariants": "0.1.7-alpha.2",
64
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.2",
65
+ "@deepseek-ai/dsh-scope": "0.1.7-alpha.2",
66
+ "@deepseek-ai/dsh-session": "0.1.7-alpha.2",
67
+ "@deepseek-ai/dsh-session-format": "0.1.7-alpha.2",
68
+ "@deepseek-ai/dsh-session-format-catalog": "0.1.7-alpha.2",
69
+ "@deepseek-ai/dsh-session-persistence": "0.1.7-alpha.2",
70
+ "@deepseek-ai/dsh-session-query": "0.1.7-alpha.2",
71
+ "@deepseek-ai/dsh-storage": "0.1.7-alpha.2",
72
+ "@deepseek-ai/schemastery": "3.18.4"
71
73
  },
72
74
  "dsh": {
73
75
  "bundle": {
package/src/backend.ts CHANGED
@@ -15,12 +15,17 @@ export interface SessionRow {
15
15
  fSeedLength: number | null;
16
16
  fOrigin: string | null;
17
17
  fDelegationDepth: number | null;
18
+ fAgentPreset: string | null;
18
19
 
19
20
  fIncarnation: string;
20
21
 
21
22
  fRevision: number;
22
23
 
23
24
  fArchivedAt: number | null;
25
+
26
+ fPinnedSeq: number | null;
27
+
28
+ fLastEventAt: number | null;
24
29
  }
25
30
 
26
31
  export interface EventInsert {
@@ -82,7 +87,8 @@ export interface BackendTx {
82
87
 
83
88
  updateHead(id: SessionId, headEventId: string, headSequence: number): Promise<void>;
84
89
 
85
- bumpRevision(id: SessionId): Promise<void>;
90
+ /** 写批次提交时递增 revision;给了 `lastEventAt` 就一并把「最后活动时间」往前推(只增不减)。 */
91
+ bumpRevision(id: SessionId, lastEventAt?: number): Promise<void>;
86
92
 
87
93
  deleteBridgeTail(id: SessionId, fromSequence: number): Promise<void>;
88
94
 
@@ -97,6 +103,42 @@ export interface BackendTx {
97
103
  deleteSessions(ids: SessionId[]): Promise<number>;
98
104
  }
99
105
 
106
+ /**
107
+ * 「会话行」列表项:管理面自己的列表用(完整语料,含归档)+ 标题 + 最后活动时间。
108
+ *
109
+ * 与官方列表(`session/list` → `ctx.sessionQuery.listSessions`)的区别:那条按部署策略**默认排除归档**,
110
+ * 这里给的是完整集合,标题也直接带出来(官方那条的标题走投影缓存)。
111
+ */
112
+ export interface SessionListRowRecord {
113
+ sessionId: string;
114
+ title: string | null;
115
+ origin: string | null;
116
+ cwd: string | null;
117
+ createdAt: number;
118
+ /** 该会话最后一个事件的时间(没有事件时回落 `createdAt`)。 */
119
+ updatedAt: number;
120
+ archived: boolean;
121
+ subagent: boolean;
122
+ /** 所属工作区标题;不属于任何工作区时为 null。 */
123
+ workspace: string | null;
124
+ }
125
+
126
+ /** 会话行列表的查询条件:搜索、子代理过滤与分页都在后端做(前端分页等于每次拉全量)。 */
127
+ export interface SessionListRowsQuery {
128
+ /** 匹配标题或所属工作区标题(大小写不敏感);空串即不过滤。 */
129
+ query?: string;
130
+ /** 是否连子代理派生会话一起返回(默认不含)。 */
131
+ includeSubagents?: boolean;
132
+ limit?: number;
133
+ offset?: number;
134
+ }
135
+
136
+ export interface SessionListRowsPage {
137
+ items: SessionListRowRecord[];
138
+ /** 过滤后的总数(分页前的),前端据此算页数。 */
139
+ total: number;
140
+ }
141
+
100
142
  /** 活动计数的一个桶:本地日 + 事件类型 + 计数。 */
101
143
  export interface EventCountBucket {
102
144
  day: string;
@@ -142,6 +184,9 @@ export interface Backend {
142
184
  */
143
185
  usageReport(sinceMs?: number): Promise<UsageAggregate>;
144
186
 
187
+ /** 管理面的会话行列表:完整语料(含归档)+ 标题 + 最后活动时间,按活动倒序分页。 */
188
+ listSessionRows(query?: SessionListRowsQuery): Promise<SessionListRowsPage>;
189
+
145
190
  /**
146
191
  * 活动计数**旁路累加**(派生表 `t_event_counts`,不参与写事务、失败可丢——表可销毁重建):
147
192
  * 每个 (会话, 本地日, 事件类型) 记一行计数。
package/src/branch.ts CHANGED
@@ -278,21 +278,38 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
278
278
  const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
279
279
 
280
280
  const internals = this.persistence.internals();
281
- const sourceRows = await internals.backend.getEventRows(sourceId);
282
- const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
281
+ // 复用父会话的事件行有个前提:**读取视图的 seq 与存储行的 f_sequence 一一对应**。legacy 会话的读视图
282
+ // 由迁移链重建(可能重新编号、增删事件),按 seq 复用会把桥接行挂到语义不符的行上——读取时按 `f_data`
283
+ // 解析,子会话前缀读到的内容就会和它自己的 seed 事件对不上。那种情况下复制事件行(多几行存储,正确优先)。
284
+ const reusable = !source.migrated && source.events.length === source.storedCount;
283
285
  const reuse = new Map<number, string>();
284
- for (const event of prefix) {
285
- const eventId = sourceEventIds.get(event.seq);
286
- if (eventId !== undefined) reuse.set(event.seq, eventId);
286
+ if (reusable) {
287
+ const sourceRows = await internals.backend.getEventRows(sourceId);
288
+ const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
289
+ for (const event of prefix) {
290
+ const eventId = sourceEventIds.get(event.seq);
291
+ if (eventId !== undefined) reuse.set(event.seq, eventId);
292
+ }
293
+ } else if (prefix.length > 0) {
294
+ this.live.warn?.(
295
+ `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
+ );
287
297
  }
288
298
  internals.registerReuseEventIds(childId, reuse);
289
- const handle = await this.persistence.create(childMeta, {
290
- inheritedEventCount: SessionLogOffset(prefix.length),
291
- });
292
299
  try {
293
- if (seed.length > 0) await handle.append(seed);
294
- } finally {
295
- await handle.close();
300
+ const handle = await this.persistence.create(childMeta, {
301
+ inheritedEventCount: SessionLogOffset(prefix.length),
302
+ });
303
+ try {
304
+ if (seed.length > 0) await handle.append(seed);
305
+ } finally {
306
+ await handle.close();
307
+ }
308
+ } catch (error) {
309
+ // fork 没成(id 已存在、append 失败等):别把复用映射留在进程里——同一个 childId 之后的落写会按它
310
+ // 去复用父会话的事件行,而那条会话可能完全无关。
311
+ internals.dropReuseEventIds(childId);
312
+ throw error;
296
313
  }
297
314
  // 子会话继承了前缀事件:活动计数按子会话重算(派生表,best-effort)。
298
315
  await this.persistence.rebuildEventCounts(childId);
@@ -13,3 +13,4 @@ export const tWorkspaces = tables["t_workspaces"]!;
13
13
  export const tWorkspaceSessions = tables["t_workspace_sessions"]!;
14
14
  export const tWorkspaceState = tables["t_workspace_state"]!;
15
15
  export const tSessionProjcacheRows = tables["t_session_projcache_row"]!;
16
+ export const tEventUsage = tables["t_event_usage"]!;
@@ -13,3 +13,4 @@ export const tWorkspaces = tables["t_workspaces"]!;
13
13
  export const tWorkspaceSessions = tables["t_workspace_sessions"]!;
14
14
  export const tWorkspaceState = tables["t_workspace_state"]!;
15
15
  export const tSessionProjcacheRows = tables["t_session_projcache_row"]!;
16
+ export const tEventUsage = tables["t_event_usage"]!;
@@ -16,11 +16,11 @@ export const eventUsage: TableDef = {
16
16
  f_created_at: { type: "bigint", notNull: true },
17
17
  f_provider: { type: "text" },
18
18
  f_model: { type: "text" },
19
- f_input_tokens: { type: "integer", notNull: true },
20
- f_output_tokens: { type: "integer", notNull: true },
21
- f_cache_read_tokens: { type: "integer", notNull: true },
22
- f_reasoning_tokens: { type: "integer", notNull: true },
23
- f_total_tokens: { type: "integer", notNull: true },
19
+ f_input_tokens: { type: "integer", notNull: true, default: 0 },
20
+ f_output_tokens: { type: "integer", notNull: true, default: 0 },
21
+ f_cache_read_tokens: { type: "integer", notNull: true, default: 0 },
22
+ f_reasoning_tokens: { type: "integer", notNull: true, default: 0 },
23
+ f_total_tokens: { type: "integer", notNull: true, default: 0 },
24
24
  },
25
25
  indexes: { idx_event_usage_created_at: { columns: ["f_created_at"] } },
26
26
  };
@@ -14,11 +14,16 @@ export const sessions: TableDef = {
14
14
  f_seed_length: { type: "integer" },
15
15
  f_origin: { type: "text" },
16
16
  f_delegation_depth: { type: "integer" },
17
+ f_agent_preset: { type: "text" },
17
18
  f_incarnation: { type: "text", notNull: true },
18
19
  f_revision: { type: "integer", notNull: true },
19
20
 
20
21
  f_archived_at: { type: "bigint" },
21
22
 
23
+ f_pinned_seq: { type: "integer" },
24
+
25
+ f_last_event_at: { type: "bigint" },
26
+
22
27
  f_title: { type: "text" },
23
28
 
24
29
  f_title_seq: { type: "integer" },
package/src/import.ts CHANGED
@@ -3,11 +3,11 @@ import type { Context } from "@deepseek-ai/cordis";
3
3
  import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-session";
4
4
  import type { Session, SessionEvent, SessionId, SessionHeader } from "@deepseek-ai/dsh-session";
5
5
  import { parseSessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
6
- import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
7
6
  import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
8
7
  import { balanceRewindPrefix } from "@morlay/session-branch";
9
8
  import { unzip } from "fflate";
10
9
  import { replaceLiveSessionLog } from "./branch.ts";
10
+ import { restoreCatalog } from "./legacy.ts";
11
11
  import type { SessionPersistenceRdb } from "./index.ts";
12
12
 
13
13
  export const SESSION_LOG_ARTIFACT_FILENAME = "session.jsonl";
@@ -30,9 +30,9 @@ export function parseJsonlArtifact(content: string): SessionStorageMetadata & {
30
30
  throw new Error("imported session log has an unparsable header line");
31
31
  }
32
32
 
33
- let restore: ReturnType<typeof sessionFormatCatalog.createRestore>;
33
+ let restore: ReturnType<typeof restoreCatalog.createRestore>;
34
34
  try {
35
- restore = sessionFormatCatalog.createRestore(header, {
35
+ restore = restoreCatalog.createRestore(header, {
36
36
  recovery: "strict",
37
37
  validation: "transformed",
38
38
  });
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Context } from "@deepseek-ai/cordis";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import type { SettingsProvider } from "@deepseek-ai/dsh-settings";
4
3
  import { randomUUID } from "node:crypto";
5
4
  import { Pool } from "pg";
6
5
  import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
@@ -61,16 +60,31 @@ import { registerSessionImport } from "./import.ts";
61
60
  import { registerSessionDeletion } from "./deletion.ts";
62
61
  import { registerSessionExport } from "./export.ts";
63
62
  import { registerSessionGc } from "./gc.ts";
63
+ import { registerSessionRows } from "./rows.ts";
64
64
  import { COUNTED_EVENT_TYPES, localDayKey, registerSessionUsage } from "./usage.ts";
65
65
  import type { UsageAggregate } from "./usage.ts";
66
66
  import { SessionQueryRdb } from "./session-query.ts";
67
67
  import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
68
+ import { hasLegacyShape } from "./log.ts";
68
69
  import { installStorageTakeover } from "./storage-takeover/index.ts";
69
70
 
71
+ /** 一批事件里的最大时间:写路径据此推进会话行的「最后活动时间」。 */
72
+ function maxEventTime(events: readonly { readonly time?: number }[]): number | undefined {
73
+ let max: number | undefined;
74
+ for (const event of events) {
75
+ const time = event.time;
76
+ if (typeof time !== "number") continue;
77
+ if (max === undefined || time > max) max = time;
78
+ }
79
+ return max;
80
+ }
81
+
70
82
  const DEFAULT_PROJECTION_WRITE_EVERY_EVENTS = 200;
71
83
  const DEFAULT_PROJECTION_WRITE_INTERVAL_MS = 5000;
72
84
 
73
85
  export { SCHEMA_VERSION } from "./schema.ts";
86
+ export { SESSION_ROWS_PATH } from "./rows.ts";
87
+ export type { SessionRowsItem, SessionRowsValue } from "./rows.ts";
74
88
  export { SessionBranchRdb, SessionBranchRdbProvider, locateTurnEnd } from "./branch.ts";
75
89
 
76
90
  export interface SessionPersistenceRdbInternals {
@@ -95,6 +109,8 @@ export interface SessionPersistenceRdbInternals {
95
109
  ): Promise<import("@deepseek-ai/dsh-session-persistence").SessionPersistenceRevision | undefined>;
96
110
 
97
111
  registerReuseEventIds(childId: SessionId, map: ReadonlyMap<number, string>): void;
112
+ /** fork 失败时丢弃还没被消费的映射。 */
113
+ dropReuseEventIds(childId: SessionId): void;
98
114
  }
99
115
 
100
116
  export interface ProjectionCacheOptions {
@@ -469,7 +485,7 @@ class RdbSessionHandle implements SessionHandle {
469
485
  }
470
486
 
471
487
  export class SessionPersistenceRdb extends SessionPersistence {
472
- static inject = ["sessions", "settings"];
488
+ static inject = ["sessions"];
473
489
 
474
490
  static Config: z<Config> = z.union([
475
491
  z.object({
@@ -503,8 +519,6 @@ export class SessionPersistenceRdb extends SessionPersistence {
503
519
  }),
504
520
  ]);
505
521
 
506
- static readonly settingsNs = "session-rdb";
507
-
508
522
  override readonly name = "session-rdb";
509
523
 
510
524
  readonly tracker = new RdbBackendTracker(this.name);
@@ -526,23 +540,11 @@ export class SessionPersistenceRdb extends SessionPersistence {
526
540
 
527
541
  injectedBackend?: Backend,
528
542
  ) {
529
- let resolved: Config = config;
530
- const settings = ctx.reflect.get("settings") as unknown as SettingsProvider | undefined;
531
- if (settings !== undefined) {
532
- const scope = settings.register(
533
- SessionPersistenceRdb.settingsNs,
534
- SessionPersistenceRdb.Config,
535
- { base: config },
536
- );
537
- resolved = scope.get();
538
- scope.watch(() => {
539
- ctx.logger.warn("session-rdb: settings changed; restart to apply the new configuration");
540
- });
541
- }
542
543
  super(ctx);
543
544
 
544
- this.config = resolved;
545
- this.backend = injectedBackend ?? createBackend(resolved);
545
+ // 配置就是这一行的 config(cordis.patch.yml / profile patch,或设置页改它);上游 0.1.7 的 settings
546
+ // 不再提供 namespace section 覆盖,旧 `settings.yaml` 的 `session-rdb` 段由上游一次性导进同 id 的行。
547
+ this.backend = injectedBackend ?? createBackend(config);
546
548
  this.ready = this.init();
547
549
  this.installLiveRouting(ctx);
548
550
 
@@ -560,6 +562,8 @@ export class SessionPersistenceRdb extends SessionPersistence {
560
562
 
561
563
  registerSessionUsage(this.ctx, this);
562
564
 
565
+ registerSessionRows(this.ctx, this.backend);
566
+
563
567
  installStorageTakeover(this.ctx, {
564
568
  repository: this.backend.storage,
565
569
  ready: this.ready,
@@ -853,7 +857,6 @@ export class SessionPersistenceRdb extends SessionPersistence {
853
857
  validateStoredEvents(meta, [...events]);
854
858
 
855
859
  const reuse = this.reuseEventIds.get(meta.id);
856
- if (reuse !== undefined) this.reuseEventIds.delete(meta.id);
857
860
  let confirmedHead = -1;
858
861
  await this.backend.transaction(async (tx) => {
859
862
  if (tornTruncateTo !== undefined) {
@@ -877,9 +880,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
877
880
  reuse,
878
881
  );
879
882
  await tx.updateHead(meta.id, headEventId, headSequence);
880
- await tx.bumpRevision(meta.id);
883
+ await tx.bumpRevision(meta.id, maxEventTime(events));
881
884
  confirmedHead = headSequence;
882
885
  });
886
+ // 事务**成功之后**才丢掉复用映射:失败(并发写者校验、唯一键冲突等)时这份映射还没被消费,
887
+ // 重试同一个 append 还要靠它复用父会话的事件行——提前删掉会让前缀事件行被重新插入一遍。
888
+ if (reuse !== undefined) this.reuseEventIds.delete(meta.id);
883
889
 
884
890
  this.writeGuard.confirmHead(meta.id, confirmedHead);
885
891
  this.tracker.materialized(meta.id);
@@ -951,6 +957,21 @@ export class SessionPersistenceRdb extends SessionPersistence {
951
957
  }
952
958
  }
953
959
  const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
960
+ // 版本号不完全可信:写路径曾把回退视图的结果以当前版本号落库,于是库里存在「v4 标记 + 旧代形状」
961
+ // 的行——在扫干净的边界之内再按内容判一次(撕裂尾部与坏行已经被 `scanRows` 丢掉了)。
962
+ if (hasLegacyShape(preserved)) {
963
+ const adopted = adoptLegacyRows(row, eventRows);
964
+ return {
965
+ meta: adopted.meta,
966
+ inheritedEventCount: adopted.inheritedEventCount,
967
+ events: adopted.events,
968
+ incarnation: row.fIncarnation,
969
+ revision: row.fRevision,
970
+ storedCount: eventRows.length,
971
+ migrated: false,
972
+ ...(adopted.tornFrom !== undefined ? { tornFrom: adopted.tornFrom } : {}),
973
+ };
974
+ }
954
975
  return {
955
976
  meta,
956
977
  inheritedEventCount: row.fSeedLength ?? 0,
@@ -1006,6 +1027,13 @@ export class SessionPersistenceRdb extends SessionPersistence {
1006
1027
  log: { meta: SessionHeader; inheritedEventCount: number; events: SessionEvent[] },
1007
1028
  ): Promise<void> {
1008
1029
  await this.backend.transaction(async (tx) => {
1030
+ // 这段重写会删光该会话的桥接行、再按迁移视图重建——与 `appendBatch` 同一个理由,先做并发写者校验:
1031
+ // 否则另一实例在 open 之前 / 期间提交的事件会被这段重写静默丢掉。调用方刚读过这份日志(重写只发生在
1032
+ // write open 的读路径之后),所以 guard 里没有该会话时就把当前磁盘 head 记成本实例已确认的值;
1033
+ // 有记录时严格比较——那能抓住"读完之后另一个实例又写了"。
1034
+ const head = await tx.getHead(id);
1035
+ if (!this.writeGuard.has(id)) this.writeGuard.confirmHead(id, head.fHeadSequence);
1036
+ this.writeGuard.assertNoConcurrentWriter(id, head.fHeadSequence);
1009
1037
  await tx.deleteBridgeTail(id, 0);
1010
1038
  await tx.upsertSession(
1011
1039
  { meta: log.meta, inheritedEventCount: SessionLogOffset(log.inheritedEventCount) },
@@ -1016,7 +1044,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
1016
1044
  nextSeq: 0,
1017
1045
  });
1018
1046
  await tx.updateHead(id, headEventId, headSequence);
1019
- await tx.bumpRevision(id);
1047
+ await tx.bumpRevision(id, maxEventTime(log.events));
1020
1048
  });
1021
1049
  }
1022
1050
 
@@ -1146,6 +1174,11 @@ export class SessionPersistenceRdb extends SessionPersistence {
1146
1174
  this.reuseEventIds.set(childId, new Map(map));
1147
1175
  }
1148
1176
 
1177
+ /** 丢弃一个还没被消费的复用映射(fork 失败时):留着会让同一个 childId 之后的落写复用父会话的事件行。 */
1178
+ dropReuseEventIds(childId: SessionId): void {
1179
+ this.reuseEventIds.delete(childId);
1180
+ }
1181
+
1149
1182
  internals(): SessionPersistenceRdbInternals {
1150
1183
  return {
1151
1184
  backend: this.backend,
@@ -1164,6 +1197,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
1164
1197
  listSnapshots: (signal) => this.listSnapshots(signal),
1165
1198
  readStoredRevision: (id, signal) => this.readStoredRevision(id, signal),
1166
1199
  registerReuseEventIds: (childId, map) => this.registerReuseEventIds(childId, map),
1200
+ dropReuseEventIds: (childId) => this.dropReuseEventIds(childId),
1167
1201
  };
1168
1202
  }
1169
1203
 
package/src/legacy.ts CHANGED
@@ -4,9 +4,9 @@ import {
4
4
  type SessionHeader,
5
5
  type SessionId,
6
6
  } from "@deepseek-ai/dsh-session";
7
- import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
7
+ import { createSessionFormatCatalogWithChildren } from "@deepseek-ai/dsh-session-format-catalog";
8
8
  import type { EventRow, SessionRow } from "./backend.ts";
9
- import { repairRequestHeaders, rowToMeta, scanRows } from "./log.ts";
9
+ import { normalizeToCurrentShape, repairRequestHeaders, rowToMeta, scanRows } from "./log.ts";
10
10
 
11
11
  function physicalHeader(row: SessionRow): Record<string, unknown> {
12
12
  const common = {
@@ -18,6 +18,7 @@ function physicalHeader(row: SessionRow): Record<string, unknown> {
18
18
  ...(row.fParentSession !== null ? { parentSession: row.fParentSession } : {}),
19
19
  ...(row.fOrigin !== null ? { origin: row.fOrigin } : {}),
20
20
  delegationDepth: row.fDelegationDepth ?? 0,
21
+ ...(row.fAgentPreset !== null ? { agentPreset: row.fAgentPreset } : {}),
21
22
  };
22
23
 
23
24
  return row.fVersion < 2
@@ -43,11 +44,18 @@ function physicalEvent(row: EventRow): Record<string, unknown> {
43
44
  };
44
45
  }
45
46
 
47
+ /**
48
+ * 读旧格式用的 catalog:v3→v4 这条边要求显式子会话证据(上游 0.1.7 起),本仓库声明
49
+ * **无子会话证据**——v3 的目录事实由父会话自己在成功路径上写成 `subagent/catalog` 事件,
50
+ * 迁移的 child evidence 只是补偿入口;我们没有更权威的子集合来源可给(见 ADR-跟随上游session-format-v4)。
51
+ */
52
+ export const restoreCatalog = createSessionFormatCatalogWithChildren([]);
53
+
46
54
  export function convertLegacyRows(
47
55
  row: SessionRow,
48
56
  eventRows: readonly EventRow[],
49
57
  ): { meta: SessionHeader; inheritedEventCount: number; events: SessionEvent[] } {
50
- const restore = sessionFormatCatalog.createRestore(physicalHeader(row), {
58
+ const restore = restoreCatalog.createRestore(physicalHeader(row), {
51
59
  recovery: "strict",
52
60
  validation: "transformed",
53
61
  });
@@ -77,7 +85,10 @@ export function adoptLegacyRows(
77
85
  } {
78
86
  const { preserved, tornFrom } = scanRows(eventRows, 0);
79
87
 
88
+ // 回退视图要独立扛住 v4 的校验:迁移链(严格)拒绝的那些旧形状在这里按字段归一
89
+ // (system 消息的 source、tool/result 的消息形状、自造事件类型的 ignorable 信封)。
80
90
  repairRequestHeaders(preserved);
91
+ normalizeToCurrentShape(preserved);
81
92
  return {
82
93
  meta: { ...rowToMeta(row), version: SESSION_FORMAT_VERSION },
83
94