@morlay/session-rdb 0.0.20 → 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.
- package/README.md +59 -31
- package/dist/artifact.d.mts +25 -65
- package/dist/artifact.mjs +2 -2
- package/dist/{schema-BkQN5GyT.d.mts → backend-DpdtxYpz.d.mts} +346 -93
- package/dist/{branch-Co6xlbi0.mjs → branch-5JzX9rUq.mjs} +163 -52
- package/dist/deletion.d.mts +7 -0
- package/dist/deletion.mjs +2 -0
- package/dist/dist-vIVO6bA-.mjs +1524 -0
- package/dist/import.d.mts +4 -4
- package/dist/import.mjs +28 -11
- package/dist/index.d.mts +2 -3
- package/dist/index.mjs +4 -1631
- package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
- package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
- package/dist/src-CWTWV7vx.mjs +1904 -0
- package/dist/storage.d.mts +14 -5
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -26
- package/dist/testing.mjs +10503 -9614
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
- package/package.json +28 -29
- package/src/adapters/to-postgres.ts +1 -3
- package/src/adapters/to-sqlite.ts +0 -2
- package/src/adapters/types.ts +0 -3
- package/src/artifact.ts +0 -2
- package/src/backend.ts +31 -2
- package/src/branch.ts +223 -135
- package/src/deletion.ts +87 -0
- package/src/drizzle/postgres-v2.ts +0 -1
- package/src/drizzle/postgres-v3.ts +0 -1
- package/src/drizzle/sqlite-v2.ts +0 -1
- package/src/drizzle/sqlite-v3.ts +0 -1
- package/src/entities/v2/session-events.ts +0 -1
- package/src/entities/v3/event-usage.ts +25 -0
- package/src/entities/v3/events.ts +1 -3
- package/src/entities/v3/index.ts +4 -0
- package/src/entities/v3/session-events.ts +1 -2
- package/src/entities/v3/session-projcache-rows.ts +0 -8
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-units.ts +0 -1
- package/src/entities/v3/workspace-sessions.ts +0 -5
- package/src/entities/v3/workspace-state.ts +0 -6
- package/src/entities/v3/workspaces.ts +0 -5
- package/src/export.ts +103 -0
- package/src/gc.ts +76 -0
- package/src/import-storages.ts +4 -37
- package/src/import.ts +54 -31
- package/src/index.ts +150 -114
- package/src/legacy.ts +4 -42
- package/src/log.ts +63 -106
- package/src/postgres.ts +249 -22
- package/src/schema.ts +6 -6
- package/src/session-query.ts +0 -4
- package/src/sqlite.ts +231 -55
- package/src/storage-takeover/index.ts +2 -28
- package/src/storage-takeover/projection-cache.ts +30 -134
- package/src/storage-takeover/repository.ts +20 -59
- package/src/storage-takeover/storage-backend.ts +3 -33
- package/src/storage-takeover/types.ts +14 -56
- package/src/storage.ts +0 -2
- package/src/testing/contract.ts +19 -50
- package/src/testing/coordinator-contract.ts +10 -25
- package/src/testing.ts +1 -4
- package/src/usage.ts +191 -0
- package/dist/index-D55G9n4k.d.mts +0 -271
- 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
|
-
|
|
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"] },
|
package/src/entities/v3/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
19
|
+
|
|
20
20
|
f_archived_at: { type: "bigint" },
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
f_title: { type: "text" },
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
f_title_seq: { type: "integer" },
|
|
25
25
|
},
|
|
26
26
|
};
|
|
@@ -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
|
+
}
|
package/src/import-storages.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
+
|
|
33
19
|
workspaceState: boolean;
|
|
34
|
-
|
|
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
|
-
|
|
29
|
+
|
|
46
30
|
export const WORKSPACE_UNIT_VERSIONS: ReadonlySet<number> = new Set([2]);
|
|
47
31
|
const PROJCACHE_UNIT = "session_projcache";
|
|
48
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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 =
|
|
121
|
+
entries = await unzipAsync(zip);
|
|
109
122
|
} catch {
|
|
110
123
|
throw new Error("imported zip is not a valid ZIP archive");
|
|
111
124
|
}
|
|
112
|
-
|
|
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:
|
|
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
|
-
|
|
175
|
+
|
|
158
176
|
if (stopLoop !== undefined) await stopLoop(targetId);
|
|
159
177
|
await branch.rewind(targetId, -1);
|
|
160
|
-
|
|
161
|
-
// live 路由持有——复用而非 open(open 会撞 SessionAlreadyOwnedError)。
|
|
178
|
+
|
|
162
179
|
const liveHandle = persistence.tracker.writerOf(targetId);
|
|
163
180
|
if (liveHandle !== undefined) {
|
|
164
|
-
if (
|
|
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 (
|
|
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
|
-
{
|
|
193
|
+
{
|
|
194
|
+
inheritedEventCount: SessionLogOffset(
|
|
195
|
+
Math.min(imported.inheritedEventCount, events.length),
|
|
196
|
+
),
|
|
197
|
+
},
|
|
177
198
|
);
|
|
178
|
-
if (
|
|
199
|
+
if (events.length > 0) await handle.append(events);
|
|
179
200
|
await handle.close();
|
|
180
201
|
}
|
|
181
|
-
|
|
182
|
-
// (不发布、不落库),使 observeSession 的 live 快照与 DB 一致。
|
|
202
|
+
|
|
183
203
|
if (targetId !== undefined) {
|
|
184
204
|
const live = sessions?.get(targetId);
|
|
185
|
-
if (live !== undefined)
|
|
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
|
-
| {
|
|
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
|