@morlay/session-rdb 0.0.10 → 0.0.12

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/src/import.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Context } from "@deepseek-ai/cordis";
3
+ import { SessionLogOffset, decodeSeqRanges, decodeStorageRecord } from "@deepseek-ai/dsh-session";
4
+ import type { Session, SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
5
+ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
6
+ import { unzipSync } from "fflate";
7
+ import { replaceLiveSessionLog } from "./branch.ts";
8
+ import type { SessionPersistenceRdb } from "./index.ts";
9
+
10
+ export const SESSION_LOG_ARTIFACT_FILENAME = "session.jsonl";
11
+
12
+ export const SESSION_IMPORT_PATH = "/api/session.import";
13
+
14
+ const MAX_IMPORT_ZIP_BYTES = 64 * 1024 * 1024;
15
+
16
+ export function expandProvenanceFromStorage(parsed: unknown): unknown {
17
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
18
+ throw new TypeError("imported session records must be objects");
19
+ }
20
+ const record = parsed as { seq?: unknown; sourceEventSeqs?: unknown };
21
+ if (record.sourceEventSeqs === undefined) return parsed;
22
+ if (!Number.isSafeInteger(record.seq) || (record.seq as number) < 0) {
23
+ throw new TypeError("imported session event seq must be a non-negative safe integer");
24
+ }
25
+ return {
26
+ ...record,
27
+ sourceEventSeqs: decodeSeqRanges(record.sourceEventSeqs, (record.seq as number) + 1),
28
+ };
29
+ }
30
+
31
+ export function parseJsonlArtifact(content: string): SessionStorageMetadata & {
32
+ events: SessionEvent[];
33
+ } {
34
+ const lines = content.split("\n");
35
+ if (lines.length === 0 || lines[0] === "") {
36
+ throw new Error("imported session log is empty");
37
+ }
38
+ let header: Record<string, unknown> | undefined;
39
+ try {
40
+ header = JSON.parse(lines[0] as string) as Record<string, unknown>;
41
+ } catch {
42
+ throw new Error("imported session log has an unparsable header line");
43
+ }
44
+ if (
45
+ typeof header !== "object" ||
46
+ header === null ||
47
+ header["type"] !== "session" ||
48
+ typeof header["id"] !== "string" ||
49
+ typeof header["version"] !== "number" ||
50
+ !Number.isSafeInteger(header["createdAt"] as number) ||
51
+ (header["createdAt"] as number) < 0
52
+ ) {
53
+ throw new Error("imported session log has an invalid header line");
54
+ }
55
+ const seedLength = header["seedLength"];
56
+ if (
57
+ seedLength !== undefined &&
58
+ (!Number.isSafeInteger(seedLength) || (seedLength as number) < 0)
59
+ ) {
60
+ throw new Error("imported session log has an invalid seedLength");
61
+ }
62
+ const events: SessionEvent[] = [];
63
+ for (let i = 1; i < lines.length; i++) {
64
+ const line = lines[i];
65
+ if (line === undefined || line === "") continue;
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = JSON.parse(line) as unknown;
69
+ } catch {
70
+ throw new Error(`imported session log has an unparsable event line at ${i}`);
71
+ }
72
+ for (const event of decodeStorageRecord(expandProvenanceFromStorage(parsed))) {
73
+ events.push(event);
74
+ }
75
+ }
76
+ // 连续性校验:导入的 log 必须是从 0 开始的稠密 seq(落库前的最后一道闸)。
77
+ for (let i = 0; i < events.length; i++) {
78
+ if (events[i]!.seq !== i) {
79
+ throw new Error(
80
+ `imported session log seq gap at ${i} (got ${events[i]!.seq}); import requires a dense log`,
81
+ );
82
+ }
83
+ }
84
+ const origin = header["origin"];
85
+ const delegationDepth = header["delegationDepth"];
86
+ // 继承前缀长度不得超过事件总数(上游 load 判损坏);导出已收缩,此处
87
+ // 防御性收缩非自洽的 artifact。
88
+ const inheritedEventCount = Math.min((seedLength as number | undefined) ?? 0, events.length);
89
+ return {
90
+ meta: {
91
+ version: header["version"] as number,
92
+ id: header["id"] as SessionId,
93
+ createdAt: header["createdAt"] as number,
94
+ ...(typeof header["cwd"] === "string" ? { cwd: header["cwd"] } : {}),
95
+ ...(typeof header["parentSession"] === "string"
96
+ ? { parentSession: header["parentSession"] as SessionId }
97
+ : {}),
98
+ isSeeded: seedLength !== undefined,
99
+ ...(origin === "subagent" ? { origin: origin as "subagent" } : {}),
100
+ ...(Number.isSafeInteger(delegationDepth as number) && (delegationDepth as number) > 0
101
+ ? { delegationDepth: delegationDepth as number }
102
+ : {}),
103
+ ...(typeof header["agentPreset"] === "string" ? { agentPreset: header["agentPreset"] } : {}),
104
+ },
105
+ inheritedEventCount: SessionLogOffset(inheritedEventCount),
106
+ events,
107
+ };
108
+ }
109
+
110
+ export function parseImportZip(zip: Uint8Array): SessionStorageMetadata & {
111
+ events: SessionEvent[];
112
+ } {
113
+ let entries: Record<string, Uint8Array>;
114
+ try {
115
+ entries = unzipSync(zip);
116
+ } catch {
117
+ throw new Error("imported zip is not a valid ZIP archive");
118
+ }
119
+ const artifact = entries[SESSION_LOG_ARTIFACT_FILENAME];
120
+ if (artifact === undefined) {
121
+ throw new Error(`imported zip is missing ${SESSION_LOG_ARTIFACT_FILENAME}`);
122
+ }
123
+ return parseJsonlArtifact(new TextDecoder().decode(artifact));
124
+ }
125
+
126
+ export async function persistImport(
127
+ persistence: SessionPersistenceRdb,
128
+ branch: { rewind(id: SessionId, toBoundary: number): Promise<unknown> } | undefined,
129
+ imported: SessionStorageMetadata & { events: SessionEvent[] },
130
+ targetId?: SessionId,
131
+ sessions?: { get(id: SessionId): Session | undefined },
132
+ ): Promise<SessionId> {
133
+ const id = targetId ?? (`session-${randomUUID()}` as SessionId);
134
+ if (targetId !== undefined) {
135
+ if (branch === undefined) {
136
+ throw new Error("sessionBranch service is unavailable");
137
+ }
138
+ await branch.rewind(targetId, -1);
139
+ } else {
140
+ await persistence.create({ ...imported.meta, id }, imported.inheritedEventCount);
141
+ }
142
+ if (imported.events.length > 0) await persistence.append(id, imported.events);
143
+ // 覆盖语义的 live 同步:rewind 截断的 live log 由同一批导入事件补回
144
+ // (不发布、不落库),使 observeSession 的 live 快照与 DB 一致。
145
+ if (targetId !== undefined) {
146
+ const live = sessions?.get(targetId);
147
+ if (live !== undefined) replaceLiveSessionLog(live, imported.events);
148
+ }
149
+ return id;
150
+ }
151
+
152
+ export function registerSessionImport(ctx: Context, persistence: SessionPersistenceRdb): void {
153
+ // webServer / connection 由其他插件注册,本后端构造早于它们——用
154
+ // ctx.inject 延迟到两个服务就绪后再注册 exact route(disposer 随 fiber
155
+ // 卸载自动回滚);服务缺失(headless 装配、纯后端测试)时注入永不触发。
156
+ ctx.inject(["webServer", "connection"] as const, (webCtx) => {
157
+ const webServer = webCtx.webServer as unknown as {
158
+ register(route: {
159
+ kind: "exact";
160
+ path: string;
161
+ handler: (
162
+ req: import("node:http").IncomingMessage,
163
+ res: import("node:http").ServerResponse,
164
+ ) => void | Promise<void>;
165
+ }): () => void;
166
+ };
167
+ const connection = webCtx.get("connection") as unknown as {
168
+ requestRejection(request: {
169
+ headers: import("node:http").IncomingHttpHeaders;
170
+ }): number | undefined;
171
+ };
172
+ return webCtx.effect(
173
+ () =>
174
+ webServer.register({
175
+ kind: "exact",
176
+ path: SESSION_IMPORT_PATH,
177
+ handler: async (req, res) => {
178
+ const rejection = connection.requestRejection(req);
179
+ if (rejection !== undefined) {
180
+ res.writeHead(rejection);
181
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
182
+ return;
183
+ }
184
+ const chunks: Buffer[] = [];
185
+ for await (const chunk of req) chunks.push(chunk as Buffer);
186
+ const body = Buffer.concat(chunks);
187
+ let envelope: { zip?: unknown; sessionId?: unknown };
188
+ try {
189
+ envelope = JSON.parse(body.toString("utf8")) as {
190
+ zip?: unknown;
191
+ sessionId?: unknown;
192
+ };
193
+ } catch {
194
+ res.writeHead(400, { "content-type": "application/json" });
195
+ res.end(JSON.stringify({ error: "request body is not JSON" }));
196
+ return;
197
+ }
198
+ if (typeof envelope.zip !== "string" || envelope.zip === "") {
199
+ res.writeHead(400, { "content-type": "application/json" });
200
+ res.end(JSON.stringify({ error: "missing zip field" }));
201
+ return;
202
+ }
203
+ if (
204
+ envelope.sessionId !== undefined &&
205
+ (typeof envelope.sessionId !== "string" || envelope.sessionId === "")
206
+ ) {
207
+ res.writeHead(400, { "content-type": "application/json" });
208
+ res.end(JSON.stringify({ error: "sessionId must be a non-empty string" }));
209
+ return;
210
+ }
211
+ let zip: Uint8Array;
212
+ try {
213
+ zip = Buffer.from(envelope.zip, "base64");
214
+ } catch {
215
+ res.writeHead(400, { "content-type": "application/json" });
216
+ res.end(JSON.stringify({ error: "zip field is not valid base64" }));
217
+ return;
218
+ }
219
+ if (zip.byteLength > MAX_IMPORT_ZIP_BYTES) {
220
+ res.writeHead(413, { "content-type": "application/json" });
221
+ res.end(JSON.stringify({ error: "imported zip exceeds the size limit" }));
222
+ return;
223
+ }
224
+ let imported: SessionStorageMetadata & { events: SessionEvent[] };
225
+ try {
226
+ imported = parseImportZip(zip);
227
+ } catch (error: unknown) {
228
+ res.writeHead(400, { "content-type": "application/json" });
229
+ res.end(
230
+ JSON.stringify({
231
+ error: error instanceof Error ? error.message : "imported zip is invalid",
232
+ }),
233
+ );
234
+ return;
235
+ }
236
+ const targetId =
237
+ typeof envelope.sessionId === "string"
238
+ ? (envelope.sessionId as SessionId)
239
+ : undefined;
240
+ const branch = webCtx.get("sessionBranch") as unknown as
241
+ | { rewind(id: SessionId, toBoundary: number): Promise<unknown> }
242
+ | undefined;
243
+ try {
244
+ const sessions = webCtx.get("sessions") as
245
+ | { get(id: SessionId): Session | undefined }
246
+ | undefined;
247
+ const id = await persistImport(persistence, branch, imported, targetId, sessions);
248
+ res.writeHead(200, { "content-type": "application/json" });
249
+ res.end(JSON.stringify({ sessionId: id }));
250
+ } catch (error: unknown) {
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ if (targetId !== undefined && /not found/i.test(message)) {
253
+ res.writeHead(404, { "content-type": "application/json" });
254
+ res.end(JSON.stringify({ error: `session "${targetId}" not found` }));
255
+ return;
256
+ }
257
+ res.writeHead(500, { "content-type": "application/json" });
258
+ res.end(
259
+ JSON.stringify({
260
+ error: error instanceof Error ? error.message : "import failed",
261
+ }),
262
+ );
263
+ return;
264
+ }
265
+ },
266
+ }),
267
+ `session-rdb: ${SESSION_IMPORT_PATH} route`,
268
+ );
269
+ });
270
+ }