@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
@@ -0,0 +1,25 @@
1
+ import type { TableDef } from "../../adapters/types.ts";
2
+
3
+ /**
4
+ * 用量日志:一条 `assistant/message` 事件一行(按事件行唯一,fork 共享行不重复)。
5
+ * 统计只读这张表,不再逐行解析事件的 JSON;事件行本身仍留在 `t_events`。
6
+ */
7
+ export const eventUsage: TableDef = {
8
+ name: "t_event_usage",
9
+ columns: {
10
+ f_event_id: {
11
+ type: "text",
12
+ primaryKey: true,
13
+ references: { table: "t_events", column: "f_event_id", onDelete: "cascade" },
14
+ },
15
+ f_created_at: { type: "integer", notNull: true },
16
+ f_provider: { type: "text" },
17
+ f_model: { type: "text" },
18
+ f_input_tokens: { type: "integer", notNull: true },
19
+ f_output_tokens: { type: "integer", notNull: true },
20
+ f_cache_read_tokens: { type: "integer", notNull: true },
21
+ f_reasoning_tokens: { type: "integer", notNull: true },
22
+ f_total_tokens: { type: "integer", notNull: true },
23
+ },
24
+ indexes: { idx_event_usage_created_at: { columns: ["f_created_at"] } },
25
+ };
@@ -15,9 +15,7 @@ export const events: TableDef = {
15
15
  f_data: { type: "text", notNull: true },
16
16
  f_created_at: { type: "bigint", notNull: true, default: 0 },
17
17
  },
18
- // 查询经 f_event_id(列级 UNIQUE 唯一索引)与 t_session_events 复合索引
19
- // (按 session 过滤后回表);f_parent_id 仅写路径构造;维度列索引为
20
- // 审计/UI 过滤预留。
18
+
21
19
  indexes: {
22
20
  idx_events_kind: { columns: ["f_kind"] },
23
21
  idx_events_role: { columns: ["f_role"] },
@@ -8,6 +8,7 @@ import { workspaces } from "./workspaces.ts";
8
8
  import { workspaceSessions } from "./workspace-sessions.ts";
9
9
  import { workspaceState } from "./workspace-state.ts";
10
10
  import { sessionProjcacheRows } from "./session-projcache-rows.ts";
11
+ import { eventUsage } from "./event-usage.ts";
11
12
 
12
13
  export { persistenceState };
13
14
  export { schemaMeta };
@@ -19,6 +20,7 @@ export { workspaces };
19
20
  export { workspaceSessions };
20
21
  export { workspaceState };
21
22
  export { sessionProjcacheRows };
23
+ export { eventUsage };
22
24
 
23
25
  export const sqliteTableDefs = [
24
26
  persistenceState,
@@ -31,6 +33,7 @@ export const sqliteTableDefs = [
31
33
  workspaceSessions,
32
34
  workspaceState,
33
35
  sessionProjcacheRows,
36
+ eventUsage,
34
37
  ] as const;
35
38
 
36
39
  export const postgresTableDefs = [
@@ -44,4 +47,5 @@ export const postgresTableDefs = [
44
47
  workspaceSessions,
45
48
  workspaceState,
46
49
  sessionProjcacheRows,
50
+ eventUsage,
47
51
  ] as const;
@@ -20,7 +20,6 @@ export const sessionEvents: TableDef = {
20
20
  uniques: {
21
21
  uq_session_events_session_sequence: { columns: ["f_session_id", "f_sequence"] },
22
22
  },
23
- // UNIQUE(f_session_id, f_sequence) 自动建唯一索引(按 session 过滤 + seq
24
- // 范围/排序/取尾);f_event_id 索引覆盖反向查找(孤儿事件行清理)。
23
+
25
24
  indexes: { idx_session_events_event_id: { columns: ["f_event_id"] } },
26
25
  };
@@ -1,13 +1,5 @@
1
1
  import type { TableDef } from "../../adapters/types.ts";
2
2
 
3
- /**
4
- * 投影 checkpoint 行(上游 checkpoint 的 `(sessionId, key, ver, seq, val)`
5
- * 逐行落库):`f_seq` 是该行的日志水位、`f_ver` 是投影单元的 stateVersion。
6
- * 行是折出捷径、非权威,版本不匹配时整行丢弃。
7
- *
8
- * checkpoint 的日志 identity 不另存一份:它与 `t_sessions` 的行是 1:1,直接
9
- * 复用该行的 `f_version` / `f_created_at` / `f_cwd` / `f_seed_length` 做校验。
10
- */
11
3
  export const sessionProjcacheRows: TableDef = {
12
4
  name: "t_session_projcache_row",
13
5
  columns: {
@@ -16,11 +16,11 @@ export const sessions: TableDef = {
16
16
  f_delegation_depth: { type: "integer" },
17
17
  f_incarnation: { type: "text", notNull: true },
18
18
  f_revision: { type: "integer", notNull: true },
19
- /** 归档标记:非空即该会话已归档(毫秒时间戳),NULL 表示未归档。 */
19
+
20
20
  f_archived_at: { type: "bigint" },
21
- /** 最新会话标题(最后一条 `session/title` 事件的 title),NULL 表示尚无标题。 */
21
+
22
22
  f_title: { type: "text" },
23
- /** `f_title` 来源事件的稠密 seq(列表 hint 的水位)。 */
23
+
24
24
  f_title_seq: { type: "integer" },
25
25
  },
26
26
  };
@@ -1,6 +1,5 @@
1
1
  import type { TableDef } from "../../adapters/types.ts";
2
2
 
3
- /** storage 域版本账本:域首次打开时写入 descriptor version,其后按 accepted 集合校验。 */
4
3
  export const storageUnits: TableDef = {
5
4
  name: "t_storage_units",
6
5
  columns: {
@@ -1,10 +1,5 @@
1
1
  import type { TableDef } from "../../adapters/types.ts";
2
2
 
3
- /**
4
- * workspace 的会话归属(上游 `workspaceRecord.sessionIds` 的数组顺序):
5
- * 一个会话一行,`f_position` 是归属显示顺序。会话 id 有独立索引,可按
6
- * session 反查归属 workspace。
7
- */
8
3
  export const workspaceSessions: TableDef = {
9
4
  name: "t_workspace_sessions",
10
5
  columns: {
@@ -1,11 +1,5 @@
1
1
  import type { TableDef } from "../../adapters/types.ts";
2
2
 
3
- /**
4
- * workspace 域 global 单例(上游 `workspaceDomainState`):`initialized` 是
5
- * 引导标记,`pendingMutation` 拆成操作与目标两列(create / delete 的可恢复
6
- * 两写标记)。显示顺序(`workspaceIds`)在 `t_workspaces.f_position`,
7
- * 归档集由 `t_sessions.f_archived_at` 承载(归档不触碰归属槽位)。
8
- */
9
3
  export const workspaceState: TableDef = {
10
4
  name: "t_workspace_state",
11
5
  columns: {
@@ -1,10 +1,5 @@
1
1
  import type { TableDef } from "../../adapters/types.ts";
2
2
 
3
- /**
4
- * workspace 域记录表(上游 `workspace` 域 v2 的 `workspaces` 表):
5
- * 记录本身拆成语义列;会话归属在 {@link workspaceSessions}(带顺序),
6
- * 显示顺序由 `f_position` 承载(未进入 `workspaceIds` 的记录为 -1)。
7
- */
8
3
  export const workspaces: TableDef = {
9
4
  name: "t_workspaces",
10
5
  columns: {
package/src/export.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { strToU8, zip } from "fflate";
2
+ import type { Context } from "@deepseek-ai/cordis";
3
+ import type { SessionId } from "@deepseek-ai/dsh-session";
4
+ import type { SessionPersistenceRdb } from "./index.ts";
5
+
6
+ export const SESSION_EXPORT_PATH = "/api/session.export";
7
+
8
+ /** 文件名只保留安全字符:会话 id 来自请求体,会写进 Content-Disposition。 */
9
+ function safeFilename(sessionId: string): string {
10
+ return sessionId.replace(/[^A-Za-z0-9._-]/gu, "_");
11
+ }
12
+
13
+ /** fflate 只给回调式异步 API(同步变体被 node/no-sync 禁止)。 */
14
+ function zipBytes(files: Record<string, Uint8Array>): Promise<Uint8Array> {
15
+ return new Promise((resolve, reject) => {
16
+ zip(files, (error, data) => {
17
+ if (error === null) resolve(data);
18
+ else reject(error);
19
+ });
20
+ });
21
+ }
22
+
23
+ /** 导出通道:直接把会话日志打成 zip 响应体,与导入通道读同一份 artifact。 */
24
+ export function registerSessionExport(ctx: Context, persistence: SessionPersistenceRdb): void {
25
+ ctx.inject(["webServer", "connection"] as const, (webCtx) => {
26
+ const webServer = webCtx.webServer as unknown as {
27
+ register(route: {
28
+ kind: "exact";
29
+ path: string;
30
+ handler: (
31
+ req: import("node:http").IncomingMessage,
32
+ res: import("node:http").ServerResponse,
33
+ ) => void | Promise<void>;
34
+ }): () => void;
35
+ };
36
+ const connection = webCtx.get("connection") as unknown as {
37
+ requestRejection(request: {
38
+ headers: import("node:http").IncomingHttpHeaders;
39
+ }): number | undefined;
40
+ };
41
+ return webCtx.effect(
42
+ () =>
43
+ webServer.register({
44
+ kind: "exact",
45
+ path: SESSION_EXPORT_PATH,
46
+ handler: async (req, res) => {
47
+ const rejection = connection.requestRejection(req);
48
+ if (rejection !== undefined) {
49
+ res.writeHead(rejection);
50
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
51
+ return;
52
+ }
53
+ if (req.method !== "POST") {
54
+ res.writeHead(405, { "content-type": "application/json" });
55
+ res.end(JSON.stringify({ error: "method not allowed" }));
56
+ return;
57
+ }
58
+ const chunks: Buffer[] = [];
59
+ for await (const chunk of req) chunks.push(chunk as Buffer);
60
+ let envelope: { sessionId?: unknown };
61
+ try {
62
+ envelope = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
63
+ sessionId?: unknown;
64
+ };
65
+ } catch {
66
+ res.writeHead(400, { "content-type": "application/json" });
67
+ res.end(JSON.stringify({ error: "request body is not JSON" }));
68
+ return;
69
+ }
70
+ if (typeof envelope.sessionId !== "string" || envelope.sessionId === "") {
71
+ res.writeHead(400, { "content-type": "application/json" });
72
+ res.end(JSON.stringify({ error: "missing sessionId field" }));
73
+ return;
74
+ }
75
+ const sessionId = envelope.sessionId;
76
+ try {
77
+ const raw = await persistence.readRaw(sessionId as SessionId);
78
+ if (raw === undefined) {
79
+ res.writeHead(404, { "content-type": "application/json" });
80
+ res.end(JSON.stringify({ error: `session "${sessionId}" not found` }));
81
+ return;
82
+ }
83
+ const zipFile = await zipBytes({ [raw.filename]: strToU8(raw.content) });
84
+ res.writeHead(200, {
85
+ "content-type": "application/zip",
86
+ "content-length": String(zipFile.byteLength),
87
+ "content-disposition": `attachment; filename="${safeFilename(sessionId)}.zip"`,
88
+ });
89
+ res.end(Buffer.from(zipFile));
90
+ } catch (error: unknown) {
91
+ res.writeHead(500, { "content-type": "application/json" });
92
+ res.end(
93
+ JSON.stringify({
94
+ error: error instanceof Error ? error.message : "session export failed",
95
+ }),
96
+ );
97
+ }
98
+ },
99
+ }),
100
+ `session-rdb: ${SESSION_EXPORT_PATH} route`,
101
+ );
102
+ });
103
+ }
package/src/gc.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { SessionPersistenceRdb } from "./index.ts";
3
+
4
+ export const SESSION_GC_PATH = "/api/session.gc";
5
+
6
+ interface GcAgentLike {
7
+ cancel?(cause: { kind: "user" }, options?: { keepInbox?: boolean }): void;
8
+ whenIdle(): Promise<void>;
9
+ }
10
+
11
+ /** 让所有运行中的 agent 先退场:GC 要重写事件表,运行中的写路径必须停下来。 */
12
+ async function stopRunningAgents(ctx: Context): Promise<number> {
13
+ const agents = ctx.get("agents") as { list(): GcAgentLike[] } | undefined;
14
+ const running = agents?.list() ?? [];
15
+ for (const agent of running) agent.cancel?.({ kind: "user" }, { keepInbox: true });
16
+ await Promise.all(running.map((agent) => agent.whenIdle()));
17
+ return running.length;
18
+ }
19
+
20
+ /** GC 通道:停 agent → 回收孤儿事件行 → VACUUM,一次请求完成。 */
21
+ export function registerSessionGc(ctx: Context, persistence: SessionPersistenceRdb): void {
22
+ ctx.inject(["webServer", "connection"] as const, (webCtx) => {
23
+ const webServer = webCtx.webServer as unknown as {
24
+ register(route: {
25
+ kind: "exact";
26
+ path: string;
27
+ handler: (
28
+ req: import("node:http").IncomingMessage,
29
+ res: import("node:http").ServerResponse,
30
+ ) => void | Promise<void>;
31
+ }): () => void;
32
+ };
33
+ const connection = webCtx.get("connection") as unknown as {
34
+ requestRejection(request: {
35
+ headers: import("node:http").IncomingHttpHeaders;
36
+ }): number | undefined;
37
+ };
38
+ return webCtx.effect(
39
+ () =>
40
+ webServer.register({
41
+ kind: "exact",
42
+ path: SESSION_GC_PATH,
43
+ handler: async (req, res) => {
44
+ const rejection = connection.requestRejection(req);
45
+ if (rejection !== undefined) {
46
+ res.writeHead(rejection);
47
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
48
+ return;
49
+ }
50
+ if (req.method !== "POST") {
51
+ res.writeHead(405, { "content-type": "application/json" });
52
+ res.end(JSON.stringify({ error: "method not allowed" }));
53
+ return;
54
+ }
55
+ try {
56
+ const stoppedAgents = await stopRunningAgents(webCtx);
57
+ // 先回收孤儿会话,再回收事件行:被删会话独占的事件行才刚成为孤儿。
58
+ const orphanSessions = await persistence.collectOrphanSessions();
59
+ const orphanEvents = await persistence.collectOrphans();
60
+ await persistence.vacuum();
61
+ res.writeHead(200, { "content-type": "application/json" });
62
+ res.end(
63
+ JSON.stringify({ orphanSessions, orphanEvents, stoppedAgents, vacuumed: true }),
64
+ );
65
+ } catch (error: unknown) {
66
+ res.writeHead(500, { "content-type": "application/json" });
67
+ res.end(
68
+ JSON.stringify({ error: error instanceof Error ? error.message : "gc failed" }),
69
+ );
70
+ }
71
+ },
72
+ }),
73
+ `session-rdb: ${SESSION_GC_PATH} route`,
74
+ );
75
+ });
76
+ }
@@ -1,14 +1,3 @@
1
- /**
2
- * 一次性导入:把旧 `$DSH_HOME/storages` 的 JSON 数据搬进 session-rdb 的
3
- * 语义专用表(显式命令触发,不做启动隐式导入)。旧文件保留不删,便于对账与
4
- * 回退。
5
- *
6
- * 支持两种来源布局:
7
- * - `workspace.json`:single 文档(`{unit, global, tables}`);
8
- * - `session_projcache/sessions/*.json`:v7 per-record 文档;目录缺失时回退
9
- * 读旧 single 文档 `session_projcache.json`。
10
- */
11
-
12
1
  import { readFile, readdir } from "node:fs/promises";
13
2
  import { join } from "node:path";
14
3
  import type {
@@ -19,41 +8,30 @@ import type {
19
8
  } from "./storage-takeover/types.ts";
20
9
  import { SessionId } from "@deepseek-ai/dsh-session";
21
10
 
22
- /** 一条待导入的投影 checkpoint:identity 不落库(复用会话行),只导行。 */
23
11
  interface ImportedProjcache {
24
12
  sessionId: SessionId;
25
13
  rows: ProjectionCheckpoint;
26
14
  }
27
15
 
28
- /** 导入统计(按记录数)。 */
29
16
  export interface StoragesImportResult {
30
- /** 导入的 workspace 记录数。 */
31
17
  workspaces: number;
32
- /** 是否导入了 workspace global 单例。 */
18
+
33
19
  workspaceState: boolean;
34
- /** 导入的投影 checkpoint 记录数。 */
20
+
35
21
  projcache: number;
36
22
  }
37
23
 
38
- /** 导入参数。 */
39
24
  export interface StoragesImportOptions {
40
- /** Harness home(含 `storages/` 的目录)。 */
41
25
  dshHome: string;
42
26
  }
43
27
 
44
28
  const WORKSPACE_UNIT = "workspace";
45
- /** 旧 workspace 文档可接受的域版本集(对齐上游 `workspaceDomainSpec`,见对齐测试)。 */
29
+
46
30
  export const WORKSPACE_UNIT_VERSIONS: ReadonlySet<number> = new Set([2]);
47
31
  const PROJCACHE_UNIT = "session_projcache";
48
- /** 旧投影缓存文档可接受的域版本集(对齐上游 spec 的 version ∪ compatibleVersions)。 */
32
+
49
33
  export const PROJCACHE_UNIT_VERSIONS: ReadonlySet<number> = new Set([7, 3, 4, 5, 6]);
50
34
 
51
- /**
52
- * Import the legacy storages documents into the rdb tables.
53
- * @param repository - storages takeover access layer (medium already open).
54
- * @param options - harness home holding the legacy `storages/` tree.
55
- * @returns per-record import counts.
56
- */
57
35
  export async function importStorages(
58
36
  repository: StorageRepository,
59
37
  options: StoragesImportOptions,
@@ -85,8 +63,6 @@ export async function importStorages(
85
63
  PROJCACHE_UNIT_VERSIONS.values().next().value as number,
86
64
  );
87
65
  for (const entry of projcacheEntries) {
88
- // 只导行:checkpoint 的 identity 由 t_sessions 的行承载(会话不存在时
89
- // 外键拒绝写入,导入 fail loud)。
90
66
  await repository.putProjcache(entry.sessionId, entry.rows);
91
67
  result.projcache += 1;
92
68
  }
@@ -95,7 +71,6 @@ export async function importStorages(
95
71
  return result;
96
72
  }
97
73
 
98
- /** Read one JSON document; a missing file is `undefined`, malformed JSON fails loud. */
99
74
  async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
100
75
  let text: string;
101
76
  try {
@@ -107,7 +82,6 @@ async function readJson(path: string): Promise<Record<string, unknown> | undefin
107
82
  return JSON.parse(text) as Record<string, unknown>;
108
83
  }
109
84
 
110
- /** Validate one legacy single document's unit identity and return its tables map. */
111
85
  function unitTables(
112
86
  document: Record<string, unknown>,
113
87
  name: string,
@@ -129,10 +103,6 @@ function unitTables(
129
103
  return tables as Record<string, Record<string, unknown>>;
130
104
  }
131
105
 
132
- /**
133
- * Read projection checkpoints from the per-record directory, falling back to
134
- * the legacy whole-unit document when the directory is absent.
135
- */
136
106
  async function readProjcacheEntries(storagesRoot: string): Promise<ImportedProjcache[]> {
137
107
  const entries: ImportedProjcache[] = [];
138
108
  const sessionsDir = join(storagesRoot, PROJCACHE_UNIT, "sessions");
@@ -149,8 +119,6 @@ async function readProjcacheEntries(storagesRoot: string): Promise<ImportedProjc
149
119
  | undefined;
150
120
  if (document === undefined) continue;
151
121
  if (typeof document.version !== "number" || !PROJCACHE_UNIT_VERSIONS.has(document.version)) {
152
- // Stale per-record document: the cache reads it as absent, so the import
153
- // discards it too instead of stamping it current.
154
122
  continue;
155
123
  }
156
124
  const record = document.record as { identity?: unknown; rows?: unknown } | undefined;
@@ -163,7 +131,6 @@ async function readProjcacheEntries(storagesRoot: string): Promise<ImportedProjc
163
131
  return entries;
164
132
  }
165
133
 
166
- /** Read the legacy whole-unit projection cache document (`{unit, tables:{sessions}}`). */
167
134
  async function readLegacyProjcache(path: string): Promise<ImportedProjcache[]> {
168
135
  const document = await readJson(path);
169
136
  if (document === undefined) return [];
package/src/import.ts CHANGED
@@ -5,11 +5,11 @@ import type { Session, SessionEvent, SessionId, SessionHeader } from "@deepseek-
5
5
  import { parseSessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
6
6
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
7
7
  import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
8
- import { unzipSync } from "fflate";
8
+ import { balanceRewindPrefix } from "@morlay/session-branch";
9
+ import { unzip } from "fflate";
9
10
  import { replaceLiveSessionLog } from "./branch.ts";
10
11
  import type { SessionPersistenceRdb } from "./index.ts";
11
12
 
12
- /** 当前世代产物的文件名(读入侧接受任意 canonical 世代名,见 parseImportZip)。 */
13
13
  export const SESSION_LOG_ARTIFACT_FILENAME = "session.jsonl";
14
14
 
15
15
  export const SESSION_IMPORT_PATH = "/api/session.import";
@@ -29,7 +29,7 @@ export function parseJsonlArtifact(content: string): SessionStorageMetadata & {
29
29
  } catch {
30
30
  throw new Error("imported session log has an unparsable header line");
31
31
  }
32
- // 经 format catalog 恢复:v2 直接解码,历史版本走迁移链。
32
+
33
33
  let restore: ReturnType<typeof sessionFormatCatalog.createRestore>;
34
34
  try {
35
35
  restore = sessionFormatCatalog.createRestore(header, {
@@ -72,7 +72,7 @@ export function parseJsonlArtifact(content: string): SessionStorageMetadata & {
72
72
  }
73
73
  const meta = artifact.header as SessionHeader;
74
74
  const events = artifact.events as SessionEvent[];
75
- // 连续性校验:导入的 log 必须是从 0 开始的稠密 seq(落库前的最后一道闸)。
75
+
76
76
  for (let i = 0; i < events.length; i++) {
77
77
  if (events[i]!.seq !== i) {
78
78
  throw new Error(
@@ -80,8 +80,7 @@ export function parseJsonlArtifact(content: string): SessionStorageMetadata & {
80
80
  );
81
81
  }
82
82
  }
83
- // 继承前缀长度不得超过事件总数(上游 load 判损坏);导出已收缩,此处
84
- // 防御性收缩非自洽的 artifact。
83
+
85
84
  const inheritedEventCount = Math.min(artifact.inheritedEventCount, events.length);
86
85
  return {
87
86
  meta: {
@@ -100,17 +99,30 @@ export function parseJsonlArtifact(content: string): SessionStorageMetadata & {
100
99
  };
101
100
  }
102
101
 
103
- export function parseImportZip(zip: Uint8Array): SessionStorageMetadata & {
104
- events: SessionEvent[];
105
- } {
102
+ /**
103
+ * fflate 的 `unzip` 是回调式异步 API(node 侧内部走 worker_threads,浏览器侧走 Web Worker),
104
+ * 这里桥成 Promise。它与 `unzipSync` 的差异只在同步/异步:非法 zip 走的是同一段校验代码,
105
+ * 拒绝的错误对象与同步版一致,因此上层文案无需区分。
106
+ */
107
+ function unzipAsync(data: Uint8Array): Promise<Record<string, Uint8Array>> {
108
+ return new Promise((resolve, reject) => {
109
+ unzip(data, (error, entries) => {
110
+ if (error === null) resolve(entries);
111
+ else reject(error);
112
+ });
113
+ });
114
+ }
115
+
116
+ export async function parseImportZip(
117
+ zip: Uint8Array,
118
+ ): Promise<SessionStorageMetadata & { events: SessionEvent[] }> {
106
119
  let entries: Record<string, Uint8Array>;
107
120
  try {
108
- entries = unzipSync(zip);
121
+ entries = await unzipAsync(zip);
109
122
  } catch {
110
123
  throw new Error("imported zip is not a valid ZIP archive");
111
124
  }
112
- // 上游导出按世代命名(`session.v3.jsonl`),早期 rdb 导出沿用 `session.jsonl`:
113
- // 两者都接受,按 canonical 名字规则识别(临时/大写/压缩后缀名不视为产物)。
125
+
114
126
  const artifact = Object.entries(entries).find(
115
127
  ([name]) => parseSessionFormatLogFilename(name) !== undefined,
116
128
  );
@@ -123,14 +135,11 @@ export function parseImportZip(zip: Uint8Array): SessionStorageMetadata & {
123
135
  return parseJsonlArtifact(new TextDecoder().decode(artifact[1]));
124
136
  }
125
137
 
126
- // 上游 Agent 的取消面(duck-type):停止运行中的 loop。
127
138
  interface ImportAgentLike {
128
139
  cancel?(cause: { kind: "user" }, options?: { keepInbox?: boolean }): void;
129
140
  whenIdle(): Promise<void>;
130
141
  }
131
142
 
132
- // persistImport 会先 rewind(-1) 截断 live 会话的内存 log,不能与 agent 写日志
133
- // 并发;keepInbox 保留排队输入(截断后由 rewind 的 live 钩子 durable 取消)。
134
143
  async function stopAgentLoop(ctx: Context, sessionId: SessionId): Promise<void> {
135
144
  const agents = ctx.get("agents") as
136
145
  | { get(id: SessionId): ImportAgentLike | undefined }
@@ -143,29 +152,37 @@ async function stopAgentLoop(ctx: Context, sessionId: SessionId): Promise<void>
143
152
 
144
153
  export async function persistImport(
145
154
  persistence: SessionPersistenceRdb,
146
- branch: { rewind(id: SessionId, toBoundary: number): Promise<unknown> } | undefined,
155
+ branch:
156
+ | {
157
+ rewind(id: SessionId, toBoundary: number): Promise<unknown>;
158
+
159
+ resetLiveDerivedState?(session: Session): void;
160
+ }
161
+ | undefined,
147
162
  imported: SessionStorageMetadata & { events: SessionEvent[] },
148
163
  targetId?: SessionId,
149
164
  sessions?: { get(id: SessionId): Session | undefined },
150
165
  stopLoop?: (id: SessionId) => Promise<void>,
151
166
  ): Promise<SessionId> {
152
167
  const id = targetId ?? (`session-${randomUUID()}` as SessionId);
168
+ // 整段导入的日志先配平:已不平衡的尾部会让接收会话的 token-meter 折叠在续写时报 step/end 无配对。
169
+ // 尾部未闭合的 step 是中断运行的正常形状,由上游 resume 补 closers,保持原样。
170
+ const events = balanceRewindPrefix(imported.events, { keepOpenTail: true });
153
171
  if (targetId !== undefined) {
154
172
  if (branch === undefined) {
155
173
  throw new Error("sessionBranch service is unavailable");
156
174
  }
157
- // 覆盖语义先 rewind(-1) 截断:停止运行中的 loop(未注入端口时空操作)。
175
+
158
176
  if (stopLoop !== undefined) await stopLoop(targetId);
159
177
  await branch.rewind(targetId, -1);
160
- // rewind 截断后追加导入事件(覆盖语义)。live 会话的 write handle 由
161
- // live 路由持有——复用而非 open(open 会撞 SessionAlreadyOwnedError)。
178
+
162
179
  const liveHandle = persistence.tracker.writerOf(targetId);
163
180
  if (liveHandle !== undefined) {
164
- if (imported.events.length > 0) await liveHandle.append(imported.events);
181
+ if (events.length > 0) await liveHandle.append(events);
165
182
  } else {
166
183
  const handle = await persistence.open(targetId, "write");
167
184
  try {
168
- if (imported.events.length > 0) await handle.append(imported.events);
185
+ if (events.length > 0) await handle.append(events);
169
186
  } finally {
170
187
  await handle.close();
171
188
  }
@@ -173,24 +190,27 @@ export async function persistImport(
173
190
  } else {
174
191
  const handle = await persistence.create(
175
192
  { ...imported.meta, id },
176
- { inheritedEventCount: imported.inheritedEventCount },
193
+ {
194
+ inheritedEventCount: SessionLogOffset(
195
+ Math.min(imported.inheritedEventCount, events.length),
196
+ ),
197
+ },
177
198
  );
178
- if (imported.events.length > 0) await handle.append(imported.events);
199
+ if (events.length > 0) await handle.append(events);
179
200
  await handle.close();
180
201
  }
181
- // 覆盖语义的 live 同步:rewind 截断的 live log 由同一批导入事件补回
182
- // (不发布、不落库),使 observeSession 的 live 快照与 DB 一致。
202
+
183
203
  if (targetId !== undefined) {
184
204
  const live = sessions?.get(targetId);
185
- if (live !== undefined) replaceLiveSessionLog(live, imported.events);
205
+ if (live !== undefined) {
206
+ replaceLiveSessionLog(live, events);
207
+ branch?.resetLiveDerivedState?.(live);
208
+ }
186
209
  }
187
210
  return id;
188
211
  }
189
212
 
190
213
  export function registerSessionImport(ctx: Context, persistence: SessionPersistenceRdb): void {
191
- // webServer / connection 由其他插件注册,本后端构造早于它们——用
192
- // ctx.inject 延迟到两个服务就绪后再注册 exact route(disposer 随 fiber
193
- // 卸载自动回滚);服务缺失(headless 装配、纯后端测试)时注入永不触发。
194
214
  ctx.inject(["webServer", "connection"] as const, (webCtx) => {
195
215
  const webServer = webCtx.webServer as unknown as {
196
216
  register(route: {
@@ -261,7 +281,7 @@ export function registerSessionImport(ctx: Context, persistence: SessionPersiste
261
281
  }
262
282
  let imported: SessionStorageMetadata & { events: SessionEvent[] };
263
283
  try {
264
- imported = parseImportZip(zip);
284
+ imported = await parseImportZip(zip);
265
285
  } catch (error: unknown) {
266
286
  res.writeHead(400, { "content-type": "application/json" });
267
287
  res.end(
@@ -276,7 +296,10 @@ export function registerSessionImport(ctx: Context, persistence: SessionPersiste
276
296
  ? (envelope.sessionId as SessionId)
277
297
  : undefined;
278
298
  const branch = webCtx.get("sessionBranch") as unknown as
279
- | { rewind(id: SessionId, toBoundary: number): Promise<unknown> }
299
+ | {
300
+ rewind(id: SessionId, toBoundary: number): Promise<unknown>;
301
+ resetLiveDerivedState?(session: Session): void;
302
+ }
280
303
  | undefined;
281
304
  try {
282
305
  const sessions = webCtx.get("sessions") as