dsh-session-toolbox 0.0.0-stage → 0.1.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.
@@ -0,0 +1,828 @@
1
+ // src/store.mjs
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { writeFileAtomic, withFileLock } from "@deepseek-ai/dsh-atomic-write";
5
+ var DEFAULT_SETTINGS = Object.freeze({
6
+ version: 1,
7
+ draftVault: { enabled: true, maxSizeBytes: 200 * 1024 * 1024, ttlDays: 7 },
8
+ trash: { ttlDays: 30, autoPurge: false },
9
+ move: { enabled: true }
10
+ });
11
+ var OWNED = { mode: 384, dirMode: 448 };
12
+ function mergeSettings(raw) {
13
+ return {
14
+ version: 1,
15
+ draftVault: { ...DEFAULT_SETTINGS.draftVault, ...raw.draftVault ?? {} },
16
+ trash: { ...DEFAULT_SETTINGS.trash, ...raw.trash ?? {} },
17
+ move: { ...DEFAULT_SETTINGS.move, ...raw.move ?? {} }
18
+ };
19
+ }
20
+ var positiveInt = (value) => Number.isSafeInteger(value) && value > 0;
21
+ var JsonDocument = class {
22
+ file;
23
+ #fallback;
24
+ #guard;
25
+ constructor(root, name, fallback, guard) {
26
+ this.file = join(root, name);
27
+ this.#fallback = fallback;
28
+ this.#guard = guard;
29
+ }
30
+ async read() {
31
+ let value;
32
+ try {
33
+ value = JSON.parse(await readFile(this.file, "utf8"));
34
+ } catch (error) {
35
+ if (error.code === "ENOENT") value = structuredClone(this.#fallback);
36
+ else if (error instanceof SyntaxError) throw new Error(`session-toolbox: corrupt JSON in ${this.file}: ${error.message}`, { cause: error });
37
+ else throw error;
38
+ }
39
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`session-toolbox: ${this.file} is not a JSON object`);
40
+ return this.#guard === void 0 ? value : this.#guard(value, this.file);
41
+ }
42
+ async write(value) {
43
+ this.#guard?.(value, this.file);
44
+ await mkdir(dirname(this.file), { recursive: true, mode: 448 });
45
+ await withFileLock(this.file, () => writeFileAtomic(this.file, JSON.stringify(value, null, 2), OWNED));
46
+ return value;
47
+ }
48
+ /** Serialized read-modify-write; `fn` returning undefined aborts without writing. */
49
+ async update(fn) {
50
+ await mkdir(dirname(this.file), { recursive: true, mode: 448 });
51
+ return withFileLock(this.file, async () => {
52
+ const current = await this.read();
53
+ const next = fn(current);
54
+ if (next === void 0) return current;
55
+ this.#guard?.(next, this.file);
56
+ await writeFileAtomic(this.file, JSON.stringify(next, null, 2), OWNED);
57
+ return next;
58
+ });
59
+ }
60
+ };
61
+ function guardSettings(value, file) {
62
+ if (value.version !== void 0 && value.version !== 1) throw new Error(`session-toolbox: unsupported settings version in ${file}: ${String(value.version)}`);
63
+ const draft = value.draftVault ?? {};
64
+ if (draft.enabled !== void 0 && typeof draft.enabled !== "boolean") throw new Error(`session-toolbox: settings.draftVault.enabled must be boolean (${file})`);
65
+ if (draft.maxSizeBytes !== void 0 && !positiveInt(draft.maxSizeBytes)) throw new Error(`session-toolbox: settings.draftVault.maxSizeBytes must be a positive safe integer (${file})`);
66
+ if (draft.ttlDays !== void 0 && !positiveInt(draft.ttlDays)) throw new Error(`session-toolbox: settings.draftVault.ttlDays must be a positive integer (${file})`);
67
+ const trash = value.trash ?? {};
68
+ if (trash.ttlDays !== void 0 && !positiveInt(trash.ttlDays)) throw new Error(`session-toolbox: settings.trash.ttlDays must be a positive integer (${file})`);
69
+ if (trash.autoPurge !== void 0 && typeof trash.autoPurge !== "boolean") throw new Error(`session-toolbox: settings.trash.autoPurge must be boolean (${file})`);
70
+ return mergeSettings(value);
71
+ }
72
+ function guardJournal(value, file) {
73
+ if (value.version !== 1) throw new Error(`session-toolbox: unsupported journal version in ${file}: ${String(value.version)}`);
74
+ const entry = value.active;
75
+ if (entry === null || entry === void 0) return value;
76
+ if (typeof entry.sessionId !== "string" || typeof entry.fromCwd !== "string" && entry.fromCwd !== null || typeof entry.toCwd !== "string") {
77
+ throw new Error(`session-toolbox: malformed move journal (${file})`);
78
+ }
79
+ if (!Array.isArray(entry.done) || typeof entry.startedAt !== "string") throw new Error(`session-toolbox: move journal lacks done/startedAt (${file})`);
80
+ return value;
81
+ }
82
+ function guardLedger(value, file) {
83
+ if (value.version !== 1) throw new Error(`session-toolbox: unsupported ledger version in ${file}: ${String(value.version)}`);
84
+ if (!Array.isArray(value.items)) throw new Error(`session-toolbox: trash ledger has no items array (${file})`);
85
+ for (const item of value.items) {
86
+ if (item.kind !== "session" && item.kind !== "workspace" || typeof item.id !== "string") throw new Error(`session-toolbox: malformed trash item (${file})`);
87
+ if (item.snapshot === null || typeof item.snapshot !== "object") throw new Error(`session-toolbox: trash item lacks snapshot (${file})`);
88
+ }
89
+ return value;
90
+ }
91
+ function guardManifest(value, file) {
92
+ if (value.version !== 1) throw new Error(`session-toolbox: unsupported vault manifest version in ${file}: ${String(value.version)}`);
93
+ if (!Array.isArray(value.records)) throw new Error(`session-toolbox: vault manifest has no records array (${file})`);
94
+ for (const record of value.records) {
95
+ if (typeof record.sessionId !== "string" || typeof record.attachmentId !== "string") throw new Error(`session-toolbox: malformed vault record (${file})`);
96
+ }
97
+ return value;
98
+ }
99
+
100
+ // src/move.mjs
101
+ import { createHash } from "node:crypto";
102
+ import { constants as constants2 } from "node:fs";
103
+ import { mkdir as mkdir2, open, readFile as readFile2, readdir as readdir2, realpath, rename, rm, stat as stat2, writeFile } from "node:fs/promises";
104
+ import { dirname as dirname2, join as join3 } from "node:path";
105
+
106
+ // src/paths.mjs
107
+ import { readdir, stat } from "node:fs/promises";
108
+ import { join as join2 } from "node:path";
109
+ var NO_CWD_DIR = "_no-cwd";
110
+ function encodeSegment(raw) {
111
+ if (raw.length === 0) throw new Error("cannot encode an empty path segment");
112
+ if (raw === ".") return "~002E";
113
+ if (raw === "..") return "~002E~002E";
114
+ let out = "";
115
+ for (let i = 0; i < raw.length; i++) {
116
+ const code = raw.charCodeAt(i);
117
+ const ch = String.fromCharCode(code);
118
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
119
+ else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
120
+ }
121
+ return out;
122
+ }
123
+ function projectKey(cwd) {
124
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
125
+ let readable = "";
126
+ let separatorRun = false;
127
+ for (let i = 0; i < cwd.length; i++) {
128
+ const code = cwd.charCodeAt(i);
129
+ const ch = String.fromCharCode(code);
130
+ if (ch === "/" || ch === "\\" || ch === ":") {
131
+ if (!separatorRun) readable += "-";
132
+ separatorRun = true;
133
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
134
+ readable += ch;
135
+ separatorRun = false;
136
+ } else {
137
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
138
+ separatorRun = false;
139
+ }
140
+ }
141
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
142
+ }
143
+ function projectDir(root, cwd) {
144
+ return cwd === void 0 ? join2(root, NO_CWD_DIR) : join2(root, projectKey(cwd));
145
+ }
146
+ function sessionDir(root, cwd, id) {
147
+ return join2(projectDir(root, cwd), encodeSegment(id));
148
+ }
149
+ var LOG_BASENAMES = [
150
+ "session.jsonl",
151
+ "session.v1.jsonl",
152
+ "session.v2.jsonl",
153
+ "session.v3.jsonl",
154
+ "session.v4.jsonl",
155
+ "session.jsonl.zstd",
156
+ "session.v1.jsonl.zstd",
157
+ "session.v2.jsonl.zstd",
158
+ "session.v3.jsonl.zstd",
159
+ "session.v4.jsonl.zstd"
160
+ ];
161
+ async function findLogFile(dir) {
162
+ const entries = await readdir(dir);
163
+ const present = LOG_BASENAMES.filter((name) => entries.includes(name));
164
+ if (present.length === 0) return void 0;
165
+ const zstd = present.filter((name) => name.endsWith(".zstd"));
166
+ const pool = zstd.length > 0 ? zstd : present;
167
+ return pool.sort((left, right) => right.length - left.length || right.localeCompare(left))[0];
168
+ }
169
+ function verifyEncoders(samples, root) {
170
+ if (samples.length === 0) return false;
171
+ return samples.every(({ cwd, id, project, dir }) => join2(root, project, dir) === sessionDir(root, cwd, id) && join2(root, project) === projectDir(root, cwd));
172
+ }
173
+
174
+ // src/session-log.mjs
175
+ import { constants, zstdCompressSync, zstdDecompressSync } from "node:zlib";
176
+ var ZSTD_MAGIC = 4247762216;
177
+ var SKIPPABLE_MAGIC_MIN = 407710288;
178
+ var SKIPPABLE_MAGIC_MAX = 407710303;
179
+ var CHECKSUM = { [constants.ZSTD_c_checksumFlag]: 1 };
180
+ function scanFrames(buffer) {
181
+ const frames = [];
182
+ let offset = 0;
183
+ while (offset < buffer.length) {
184
+ const start = offset;
185
+ if (buffer.length - offset < 4) throw new Error(`truncated zstd artifact: fewer than 4 bytes at ${offset}`);
186
+ const magic = buffer.readUInt32LE(offset);
187
+ if (magic >= SKIPPABLE_MAGIC_MIN && magic <= SKIPPABLE_MAGIC_MAX) {
188
+ const size = buffer.readUInt32LE(offset + 4);
189
+ offset += 8 + size;
190
+ if (offset > buffer.length) throw new Error(`truncated skippable frame at ${start}`);
191
+ continue;
192
+ }
193
+ if (magic !== ZSTD_MAGIC) throw new Error(`invalid frame magic at byte ${offset}`);
194
+ offset += 4;
195
+ if (offset === buffer.length) throw new Error(`frame header missing at byte ${start}`);
196
+ const descriptor = buffer.readUInt8(offset);
197
+ offset += 1;
198
+ if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`);
199
+ const contentSizeFlag = descriptor >>> 6;
200
+ const singleSegment = (descriptor & 32) !== 0;
201
+ const checksum = (descriptor & 4) !== 0;
202
+ const dictionaryFlag = descriptor & 3;
203
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
204
+ const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
205
+ const remaining = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
206
+ if (buffer.length - offset < remaining) throw new Error(`truncated frame header at byte ${start}`);
207
+ offset += remaining;
208
+ for (; ; ) {
209
+ if (buffer.length - offset < 3) throw new Error(`truncated block header at byte ${offset}`);
210
+ const blockHeader = buffer.readUIntLE(offset, 3);
211
+ offset += 3;
212
+ const lastBlock = (blockHeader & 1) !== 0;
213
+ const blockType = blockHeader >>> 1 & 3;
214
+ const blockSize = blockHeader >>> 3;
215
+ if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`);
216
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
217
+ if (buffer.length - offset < payloadBytes) throw new Error(`truncated block payload at byte ${offset}`);
218
+ offset += payloadBytes;
219
+ if (lastBlock) break;
220
+ }
221
+ if (checksum) {
222
+ if (buffer.length - offset < 4) throw new Error(`truncated frame checksum at byte ${offset}`);
223
+ offset += 4;
224
+ }
225
+ frames.push({ start, end: offset });
226
+ }
227
+ return frames;
228
+ }
229
+ function decodeFrame(buffer, frame) {
230
+ return zstdDecompressSync(buffer.subarray(frame.start, frame.end));
231
+ }
232
+ function readHeader(buffer) {
233
+ const frames = scanFrames(buffer);
234
+ if (frames.length === 0) throw new Error("empty zstd artifact");
235
+ const text = decodeFrame(buffer, frames[0]).toString("utf8").trim();
236
+ const header = JSON.parse(text);
237
+ if (header.type !== "session" || typeof header.id !== "string") throw new Error("malformed session header frame");
238
+ return { header, frames };
239
+ }
240
+ function rewriteHeaderFrame(buffer, nextCwd) {
241
+ const { header, frames } = readHeader(buffer);
242
+ const updated = { ...header };
243
+ if (nextCwd === void 0) delete updated.cwd;
244
+ else updated.cwd = nextCwd;
245
+ if (JSON.stringify(updated) === JSON.stringify(header)) return Buffer.from(buffer);
246
+ const frame0 = zstdCompressSync(Buffer.from(`${JSON.stringify(updated)}
247
+ `, "utf8"), { params: CHECKSUM });
248
+ scanFrames(frame0);
249
+ return Buffer.concat([frame0, buffer.subarray(frames[0].end)]);
250
+ }
251
+
252
+ // src/move.mjs
253
+ var PHASES = ["backup", "rewrite", "commit", "ledger", "projcache", "index"];
254
+ var MovePreflightError = class extends Error {
255
+ code;
256
+ details;
257
+ constructor(code, message, details = {}) {
258
+ super(message);
259
+ this.name = "MovePreflightError";
260
+ this.code = code;
261
+ this.details = details;
262
+ }
263
+ };
264
+ var sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
265
+ async function fsyncFile(path) {
266
+ const handle = await open(path, constants2.O_WRONLY);
267
+ try {
268
+ await handle.sync();
269
+ } finally {
270
+ await handle.close();
271
+ }
272
+ }
273
+ async function writeOwned(file, data, mode = 384) {
274
+ await mkdir2(dirname2(file), { recursive: true, mode: 448 });
275
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
276
+ await writeFile(temp, data, { mode, flag: "wx" });
277
+ await rename(temp, file);
278
+ return file;
279
+ }
280
+ async function relocate(source, target) {
281
+ await mkdir2(dirname2(target), { recursive: true, mode: 448 });
282
+ try {
283
+ await rename(source, target);
284
+ return;
285
+ } catch (error) {
286
+ if (error.code !== "EXDEV") throw error;
287
+ }
288
+ const { cp } = await import("node:fs/promises");
289
+ await cp(source, target, { recursive: true, errorOnExist: true, force: false });
290
+ await rm(source, { recursive: true, force: true });
291
+ }
292
+ var MoveEngine = class {
293
+ constructor(deps) {
294
+ this.root = deps.root;
295
+ this.sessionsRoot = deps.sessionsRoot;
296
+ this.projcacheRoot = deps.projcacheRoot;
297
+ this.journal = deps.journal;
298
+ this.caps = deps.capabilities ?? {};
299
+ this.now = deps.now ?? (() => Date.now());
300
+ }
301
+ async readJournal() {
302
+ return (await this.journal.read()).active ?? null;
303
+ }
304
+ async writeJournal(entry) {
305
+ await this.journal.write({ version: 1, active: entry });
306
+ }
307
+ /** List every journaled placement hazard for status surfaces. */
308
+ async recoverable() {
309
+ return await this.readJournal() !== null;
310
+ }
311
+ /**
312
+ * Fail-closed feasibility check (§10 MR1). Returns the plan or throws
313
+ * MovePreflightError with a stable code; never touches anything.
314
+ */
315
+ async preflight({ sessionId, toCwd, createTarget = false }) {
316
+ if (typeof sessionId !== "string" || !sessionId.startsWith("session-")) throw new MovePreflightError("bad-request", "sessionId must look like session-<uuid>");
317
+ if (typeof toCwd !== "string" || !toCwd.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(toCwd)) throw new MovePreflightError("bad-request", "toCwd must be an absolute path");
318
+ if (await this.readJournal() !== null) throw new MovePreflightError("journal-active", "another move is journaled; complete or roll it back first");
319
+ if (this.caps.sessionLive?.(sessionId)) throw new MovePreflightError("session-busy", `session ${sessionId} is live; stop its work before moving`);
320
+ const headers = await this.caps.listHeaders();
321
+ const header = headers.find((row) => row.id === sessionId);
322
+ if (header === void 0) throw new MovePreflightError("session-unknown", `no stored session ${sessionId}`);
323
+ if (typeof header.version === "number" && header.version < 4) throw new MovePreflightError("format-legacy", `stored log is format v${header.version}; open the session once in DSH so the host migrates it, then retry`);
324
+ const encoded = encodeSegment(sessionId);
325
+ const entries = await readdir2(this.sessionsRoot, { withFileTypes: true }).catch((error) => {
326
+ if (error.code === "ENOENT") return [];
327
+ throw error;
328
+ });
329
+ const projects = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
330
+ const found = [];
331
+ for (const project of projects) {
332
+ try {
333
+ if ((await stat2(join3(this.sessionsRoot, project, encoded))).isDirectory()) found.push(project);
334
+ } catch {
335
+ }
336
+ }
337
+ if (found.length > 1) throw new MovePreflightError("duplicate-artifact", `session ${sessionId} lives in ${found.length} project directories; refusing to guess`);
338
+ if (found.length === 0) throw new MovePreflightError("artifact-missing", `session ${sessionId} has no session directory under any project`);
339
+ const fromProject = found[0];
340
+ const logName = await findLogFile(join3(this.sessionsRoot, fromProject, encoded));
341
+ if (logName === void 0) throw new MovePreflightError("artifact-missing", `session directory for ${sessionId} holds no session log`);
342
+ const logPath = join3(this.sessionsRoot, fromProject, encoded, logName);
343
+ const logStat = await stat2(logPath);
344
+ if (this.now() - logStat.mtimeMs < 1e4) throw new MovePreflightError("session-churning", `session log was written ${Math.round((this.now() - logStat.mtimeMs) / 1e3)}s ago; make sure DSH has stopped using it, then retry`);
345
+ if (header.cwd !== void 0 && fromProject !== projectKey(header.cwd)) {
346
+ throw new MovePreflightError("unknown-layout", `cwd '${header.cwd}' should live at '${projectKey(header.cwd)}' but the artifact was observed at '${fromProject}'`);
347
+ }
348
+ const fromCwd = header.cwd;
349
+ let canonicalTo;
350
+ try {
351
+ canonicalTo = await realpath(toCwd.replace(/[/\\]+$/, "") || toCwd);
352
+ } catch {
353
+ canonicalTo = void 0;
354
+ }
355
+ if (canonicalTo === void 0) {
356
+ if (!createTarget) throw new MovePreflightError("target-missing", `target directory ${toCwd} does not exist (choose create to make it)`);
357
+ await mkdir2(toCwd, { recursive: true });
358
+ canonicalTo = await realpath(toCwd);
359
+ }
360
+ if (fromCwd !== void 0 && canonicalTo === fromCwd) throw new MovePreflightError("same-target", "the session already belongs to that project");
361
+ const samples = headers.filter((row) => row.cwd !== void 0).slice(0, 5).map((row) => ({ cwd: row.cwd, id: row.id, project: projectKey(row.cwd), dir: encodeSegment(row.id) }));
362
+ if (samples.length > 0 && !verifyEncoders(samples, this.sessionsRoot)) {
363
+ throw new MovePreflightError("unknown-layout", "project-key / segment encoders disagree with the shipped layout; refusing to write");
364
+ }
365
+ return {
366
+ sessionId,
367
+ fromCwd: fromCwd ?? null,
368
+ toCwd: canonicalTo,
369
+ fromProject,
370
+ toProject: projectKey(canonicalTo),
371
+ dir: join3(this.sessionsRoot, fromProject, encoded),
372
+ newDir: join3(this.sessionsRoot, projectKey(canonicalTo), encoded),
373
+ logName,
374
+ startedAt: new Date(this.now()).toISOString()
375
+ };
376
+ }
377
+ async #stage(entry, phase) {
378
+ if (!entry.done.includes(phase)) {
379
+ entry.done = [...entry.done, phase];
380
+ await this.writeJournal(entry);
381
+ }
382
+ }
383
+ /** Execute the whole move; on failure the journal survives for rollback/complete. */
384
+ async move(plan) {
385
+ const entry = {
386
+ ...plan,
387
+ done: [],
388
+ deferredLedger: false,
389
+ backup: join3(this.root, "backups", `${plan.sessionId}.orig`),
390
+ stagedNew: join3(this.root, "backups", `${plan.sessionId}.new`),
391
+ retired: join3(this.root, "pending-purge", `${plan.sessionId}__${this.now()}`)
392
+ };
393
+ await this.writeJournal(entry);
394
+ try {
395
+ await this.phaseBackup(entry);
396
+ await this.phaseRewrite(entry);
397
+ await this.phaseCommit(entry);
398
+ await this.phaseLedger(entry);
399
+ await this.phaseProjcache(entry);
400
+ await this.phaseIndex(entry);
401
+ } catch (error) {
402
+ error.journal = await this.readJournal();
403
+ throw error;
404
+ }
405
+ if (entry.deferredLedger === true) {
406
+ await this.writeJournal(entry);
407
+ return {
408
+ ok: true,
409
+ deferred: true,
410
+ sessionId: entry.sessionId,
411
+ toCwd: entry.toCwd,
412
+ note: "\u6587\u4EF6\u5DF2\u8FC1\u79FB\uFF1BDSH \u8D26\u672C\u5C06\u5728\u4E0B\u6B21\u542F\u52A8\u6216 move.complete \u65F6\u6536\u655B"
413
+ };
414
+ }
415
+ await this.#finish(entry);
416
+ return { ok: true, sessionId: entry.sessionId, toCwd: entry.toCwd, deferredLedger: entry.deferredLedger === true };
417
+ }
418
+ async #finish(entry) {
419
+ await this.writeJournal(null);
420
+ await rm(entry.backup, { force: true });
421
+ await rm(entry.stagedNew, { force: true });
422
+ if (entry.retired !== void 0) await rm(entry.retired, { recursive: true, force: true });
423
+ }
424
+ async phaseBackup(entry) {
425
+ const source = await readFile2(join3(entry.dir, entry.logName));
426
+ await writeOwned(entry.backup, source);
427
+ entry.sourceHash = sha256(source);
428
+ await this.#stage(entry, "backup");
429
+ }
430
+ async phaseRewrite(entry) {
431
+ const source = await readFile2(entry.backup);
432
+ if (sha256(source) !== entry.sourceHash) throw new MovePreflightError("source-changed", "the session log changed between backup and rewrite");
433
+ const current = readHeader(source);
434
+ if (current.header.id !== entry.sessionId) throw new MovePreflightError("artifact-mismatch", `log header id '${current.header.id}' does not name ${entry.sessionId}`);
435
+ const rewritten = rewriteHeaderFrame(source, entry.toCwd);
436
+ readHeader(rewritten);
437
+ if (sha256(rewritten) === entry.sourceHash) throw new MovePreflightError("rewrite-noop", "header rewrite produced identical bytes");
438
+ await writeOwned(entry.stagedNew, rewritten);
439
+ await fsyncFile(entry.stagedNew);
440
+ await this.#stage(entry, "rewrite");
441
+ }
442
+ async phaseCommit(entry) {
443
+ await relocate(entry.dir, entry.retired);
444
+ await mkdir2(entry.newDir, { recursive: true, mode: 448 });
445
+ await writeOwned(join3(entry.newDir, entry.logName), await readFile2(entry.stagedNew));
446
+ await fsyncFile(join3(entry.newDir, entry.logName));
447
+ for (const name of await readdir2(entry.retired).catch(() => [])) {
448
+ if (name === entry.logName || name === "session.lock") continue;
449
+ await rename(join3(entry.retired, name), join3(entry.newDir, name)).catch(() => {
450
+ });
451
+ }
452
+ await rm(join3(entry.newDir, "session.lock"), { force: true });
453
+ await this.#stage(entry, "commit");
454
+ }
455
+ async phaseLedger(entry) {
456
+ if (this.caps.moveLedger === void 0) {
457
+ entry.deferredLedger = true;
458
+ await this.#stage(entry, "ledger");
459
+ return;
460
+ }
461
+ try {
462
+ await this.caps.refreshHeaderCache?.();
463
+ await this.caps.moveLedger(entry.sessionId, entry.fromCwd ?? void 0, entry.toCwd);
464
+ entry.deferredLedger = false;
465
+ } catch (error) {
466
+ entry.deferredLedger = true;
467
+ entry.ledgerError = String(error?.message ?? error);
468
+ }
469
+ await this.#stage(entry, "ledger");
470
+ }
471
+ async phaseProjcache(entry) {
472
+ if (this.projcacheRoot !== void 0) await rm(join3(this.projcacheRoot, `${entry.sessionId}.json`), { force: true });
473
+ await this.#stage(entry, "projcache");
474
+ }
475
+ async phaseIndex(entry) {
476
+ await this.caps.resyncIndex?.();
477
+ await this.#stage(entry, "index");
478
+ }
479
+ /** Undo completed phases in reverse (§10 MR3). */
480
+ async rollback() {
481
+ const entry = await this.readJournal();
482
+ if (entry === null) throw new MovePreflightError("no-journal", "no journaled move to roll back");
483
+ if (entry.done.includes("commit")) {
484
+ await rm(entry.newDir, { recursive: true, force: true });
485
+ await mkdir2(dirname2(entry.dir), { recursive: true, mode: 448 });
486
+ if (entry.retired !== void 0) await relocate(entry.retired, entry.dir);
487
+ else throw new MovePreflightError("journal-order", "committed journal lost its retired directory");
488
+ }
489
+ if (entry.deferredLedger !== true && entry.done.includes("ledger") && this.caps.moveLedger !== void 0) {
490
+ await this.caps.moveLedger(entry.sessionId, entry.toCwd, entry.fromCwd ?? void 0).catch(() => {
491
+ });
492
+ await this.caps.refreshHeaderCache?.().catch(() => {
493
+ });
494
+ }
495
+ if (this.projcacheRoot !== void 0) await rm(join3(this.projcacheRoot, `${entry.sessionId}.json`), { force: true });
496
+ await this.caps.resyncIndex?.().catch(() => {
497
+ });
498
+ await this.#finish({ ...entry, retired: entry.done.includes("commit") ? void 0 : entry.retired });
499
+ return { ok: true, rolledBack: entry.done };
500
+ }
501
+ /** Resume a journaled move from its first missing phase (post-crash forward roll). */
502
+ async complete() {
503
+ const entry = await this.readJournal();
504
+ if (entry === null) throw new MovePreflightError("no-journal", "no journaled move to complete");
505
+ const runs = {
506
+ backup: (e) => this.phaseBackup(e),
507
+ rewrite: (e) => this.phaseRewrite(e),
508
+ commit: (e) => this.phaseCommit(e),
509
+ ledger: (e) => this.phaseLedger(e),
510
+ projcache: (e) => this.phaseProjcache(e),
511
+ index: (e) => this.phaseIndex(e)
512
+ };
513
+ for (const phase of PHASES) {
514
+ if (entry.done.includes(phase)) {
515
+ if (phase === "ledger" && entry.deferredLedger === true) await runs.ledger(entry);
516
+ continue;
517
+ }
518
+ if (phase !== "backup" && !entry.done.includes("backup")) throw new MovePreflightError("journal-order", `journal skipped '${phase}' before completing 'backup'`);
519
+ await runs[phase](entry);
520
+ }
521
+ if (entry.deferredLedger === true) {
522
+ await this.writeJournal(entry);
523
+ return {
524
+ ok: true,
525
+ deferred: true,
526
+ sessionId: entry.sessionId,
527
+ toCwd: entry.toCwd,
528
+ ledgerError: entry.ledgerError,
529
+ note: "\u6587\u4EF6\u5DF2\u8FC1\u79FB\u4F46 DSH \u8D26\u672C\u4ECD\u672A\u6536\u655B\uFF1B\u91CD\u542F DSH \u540E\u518D\u6B21\u70B9\u5B8C\u6210\uFF0C\u6216\u9009\u62E9\u56DE\u6EDA"
530
+ };
531
+ }
532
+ await this.#finish(entry);
533
+ return { ok: true, sessionId: entry.sessionId, toCwd: entry.toCwd };
534
+ }
535
+ };
536
+
537
+ // src/trash.mjs
538
+ import { readdir as readdir3, rm as rm2, stat as stat3 } from "node:fs/promises";
539
+ import { join as join4 } from "node:path";
540
+ import { randomUUID } from "node:crypto";
541
+ var TrashError = class extends Error {
542
+ code;
543
+ constructor(code, message) {
544
+ super(message);
545
+ this.name = "TrashError";
546
+ this.code = code;
547
+ }
548
+ };
549
+ var DAY = 24 * 60 * 60 * 1e3;
550
+ var TrashEngine = class {
551
+ /**
552
+ * @param deps - {adapter: RegistryAdapter, sessions?: () => liveSessionIds,
553
+ * sessionsRoot, projcacheRoot, kitRoot, ledger: JsonDocument, vault?,
554
+ * journal?: () => activeJournalEntry|null, settings?: () => trashSettings, now}
555
+ */
556
+ constructor(deps) {
557
+ this.deps = deps;
558
+ this.ledger = deps.ledger;
559
+ this.now = deps.now ?? (() => Date.now());
560
+ }
561
+ async items() {
562
+ return (await this.ledger.read()).items.map(publicItem);
563
+ }
564
+ #guardFree(id) {
565
+ if (typeof this.deps.journal === "function" && this.deps.journal() !== null) {
566
+ throw new TrashError("journal-active", "a journaled move is unresolved; finish or roll it back first");
567
+ }
568
+ const live = this.deps.sessions?.() ?? [];
569
+ if (live.includes(id)) throw new TrashError("session-busy", `session ${id} is live; open it and stop its work first`);
570
+ }
571
+ #find(state, predicate) {
572
+ return state.items.find(predicate);
573
+ }
574
+ /** §11 TB3: trash one session (archive + ledger; already-archived sessions only enter the bin). */
575
+ async trashSession(sessionId, { force = false } = {}) {
576
+ this.#guardFree(sessionId);
577
+ const adapter = this.deps.adapter;
578
+ const state = await this.ledger.read();
579
+ if (this.#find(state, (item2) => item2.kind === "session" && item2.id2 === sessionId)) throw new TrashError("already-trashed", "this session is already in the trash bin");
580
+ if (!await adapter.sessionKnown(sessionId)) throw new TrashError("session-unknown", `session ${sessionId} is unknown to the registry`);
581
+ const archived = await adapter.archivedSet();
582
+ const pinned = await adapter.pinnedSet();
583
+ const { workspaceId, beforeId, title } = await adapter.locateSession(sessionId);
584
+ if (!archived.has(sessionId)) await adapter.archiveSession(sessionId, force ? { stopActivity: true } : {});
585
+ const item = {
586
+ id: randomUUID(),
587
+ kind: "session",
588
+ id2: sessionId,
589
+ trashedAt: new Date(this.now()).toISOString(),
590
+ snapshot: {
591
+ sessionId,
592
+ workspaceId: workspaceId ?? null,
593
+ beforeId: beforeId ?? null,
594
+ previouslyArchived: archived.has(sessionId),
595
+ previouslyPinned: pinned.has(sessionId),
596
+ title: title ?? null
597
+ }
598
+ };
599
+ state.items.push(item);
600
+ await this.ledger.write(state);
601
+ return { ok: true, itemId: item.id };
602
+ }
603
+ /** §11 TB4: trash a project (archive all its sessions, snapshot order/state, delete registration). */
604
+ async trashWorkspace(workspaceId, { force = false } = {}) {
605
+ const adapter = this.deps.adapter;
606
+ const state = await this.ledger.read();
607
+ if (this.#find(state, (item2) => item2.kind === "workspace" && item2.id2 === workspaceId)) throw new TrashError("already-trashed", "this project is already in the trash bin");
608
+ const snapshot = await adapter.workspaceSnapshot(workspaceId);
609
+ if (snapshot === void 0) throw new TrashError("workspace-unknown", `workspace ${workspaceId} not found`);
610
+ const live = new Set(this.deps.sessions?.() ?? []);
611
+ if (!force && snapshot.sessions.some((row) => live.has(row.sessionId))) throw new TrashError("workspace-busy", "the project has live sessions; use force to stop and trash them");
612
+ for (const row of snapshot.sessions) {
613
+ if (row.previouslyArchived) continue;
614
+ await adapter.archiveSession(row.sessionId, live.has(row.sessionId) ? { stopActivity: true } : {});
615
+ }
616
+ const item = { id: randomUUID(), kind: "workspace", id2: workspaceId, trashedAt: new Date(this.now()).toISOString(), snapshot };
617
+ state.items.push(item);
618
+ await this.ledger.write(state);
619
+ await adapter.deleteWorkspace(workspaceId);
620
+ return { ok: true, itemId: item.id };
621
+ }
622
+ /** §11 TB5 idempotent restore: converge to the snapshot; failures propagate so the item survives for retry. */
623
+ async restore(itemId) {
624
+ const adapter = this.deps.adapter;
625
+ const state = await this.ledger.read();
626
+ const item = this.#find(state, (row) => row.id === itemId);
627
+ if (item === void 0) throw new TrashError("item-unknown", `no trash item ${itemId}`);
628
+ if (item.kind === "session") await this.#restoreSession(adapter, item.snapshot);
629
+ else await this.#restoreWorkspace(adapter, item.snapshot);
630
+ state.items = state.items.filter((row) => row.id !== itemId);
631
+ await this.ledger.write(state);
632
+ return { ok: true };
633
+ }
634
+ async #restoreSession(adapter, snapshot) {
635
+ const { sessionId, workspaceId, beforeId, previouslyArchived, previouslyPinned } = snapshot;
636
+ if (!previouslyArchived) await adapter.unarchiveSession(sessionId);
637
+ if (workspaceId !== null) await adapter.repairSlot(sessionId, workspaceId, beforeId);
638
+ if (previouslyPinned) await adapter.pinSession(sessionId);
639
+ }
640
+ async #restoreWorkspace(adapter, snapshot) {
641
+ await adapter.restoreWorkspace(snapshot);
642
+ for (const row of snapshot.sessions) {
643
+ if (!row.previouslyArchived) await adapter.unarchiveSession(row.sessionId);
644
+ }
645
+ for (const row of snapshot.sessions) {
646
+ if (row.previouslyPinned && !row.previouslyArchived) await adapter.pinSession(row.sessionId).catch(() => {
647
+ });
648
+ }
649
+ }
650
+ /** §11 TB6: hard-delete with staging so a crash never yields a half-deleted state. */
651
+ async purge(itemId) {
652
+ const adapter = this.deps.adapter;
653
+ const state = await this.ledger.read();
654
+ const item = this.#find(state, (row) => row.id === itemId);
655
+ if (item === void 0) throw new TrashError("item-unknown", `no trash item ${itemId}`);
656
+ const sessionIds = item.kind === "session" ? [item.snapshot.sessionId] : item.snapshot.sessions.map((row) => row.sessionId);
657
+ for (const sessionId of sessionIds) this.#guardFree(sessionId);
658
+ for (const sessionId of sessionIds) {
659
+ await this.#stageArtifact(sessionId);
660
+ if (adapter.purgeSession) await adapter.purgeSession(sessionId);
661
+ }
662
+ for (const sessionId of sessionIds) {
663
+ await adapter.unpinSession(sessionId).catch(() => {
664
+ });
665
+ await adapter.unarchiveSession(sessionId).catch(() => {
666
+ });
667
+ }
668
+ if (item.kind === "workspace") await adapter.deleteWorkspace(item.id2).catch(() => {
669
+ });
670
+ if (this.deps.vault?.dropSession) for (const sessionId of sessionIds) await this.deps.vault.dropSession(sessionId);
671
+ state.items = state.items.filter((row) => row.id !== itemId);
672
+ await this.ledger.write(state);
673
+ return { ok: true, purged: sessionIds };
674
+ }
675
+ async #stageArtifact(sessionId) {
676
+ const encoded = encodeSegment(sessionId);
677
+ const root = this.deps.sessionsRoot;
678
+ let found;
679
+ for (const project of await readdir3(root).catch(() => [])) {
680
+ if (project.startsWith(".")) continue;
681
+ try {
682
+ if ((await stat3(join4(root, project, encoded))).isDirectory()) {
683
+ found = join4(root, project, encoded);
684
+ break;
685
+ }
686
+ } catch {
687
+ }
688
+ }
689
+ if (found === void 0) return;
690
+ const staged = join4(this.deps.kitRoot, "pending-purge", `${sessionId}__${this.now()}`);
691
+ await relocate(found, staged);
692
+ if (this.deps.projcacheRoot) await rm2(join4(this.deps.projcacheRoot, `${sessionId}.json`), { force: true });
693
+ await rm2(staged, { recursive: true, force: true });
694
+ }
695
+ /** TTL sweep (§11 T5). With autoPurge, expired items are hard-deleted; otherwise reported. */
696
+ async sweep({ now = this.now() } = {}) {
697
+ const state = await this.ledger.read();
698
+ const settings = this.deps.settings?.() ?? {};
699
+ const ttlDays = settings.ttlDays ?? 30;
700
+ const auto = settings.autoPurge === true;
701
+ const report = { expired: [], purged: [], skipped: [] };
702
+ for (const item of state.items) {
703
+ const trashedAt = Date.parse(item.trashedAt);
704
+ if (!Number.isFinite(trashedAt) || now - trashedAt < ttlDays * DAY) continue;
705
+ report.expired.push({ itemId: item.id, kind: item.kind, targetId: item.id2 });
706
+ if (!auto) continue;
707
+ try {
708
+ await this.purge(item.id);
709
+ report.purged.push(item.id);
710
+ } catch (error) {
711
+ report.skipped.push({ itemId: item.id, reason: String(error?.message ?? error) });
712
+ }
713
+ }
714
+ return report;
715
+ }
716
+ };
717
+ function publicItem(item) {
718
+ return { itemId: item.id, kind: item.kind, targetId: item.id2, trashedAt: item.trashedAt, snapshot: item.snapshot };
719
+ }
720
+
721
+ // src/api.mjs
722
+ import { randomBytes, timingSafeEqual } from "node:crypto";
723
+ var MAX_BODY = 4 * 1024 * 1024;
724
+ var ApiError = class extends Error {
725
+ status;
726
+ constructor(status, message) {
727
+ super(message);
728
+ this.status = status;
729
+ }
730
+ };
731
+ var json = (res, status, value) => {
732
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
733
+ res.end(JSON.stringify(value));
734
+ };
735
+ var hostIsLoopback = (host) => {
736
+ const name = String(host ?? "").split(":")[0];
737
+ return ["localhost", "127.0.0.1", "::1", "[::1]", ""].includes(name) || name.startsWith("127.");
738
+ };
739
+ function apiHandler(kit) {
740
+ const key = randomBytes(32).toString("base64url");
741
+ return async (req, res) => {
742
+ try {
743
+ const remote = req.socket?.remoteAddress;
744
+ if (!hostIsLoopback(req.headers.host) || remote && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote)) throw new ApiError(403, "\u4EC5\u5141\u8BB8\u672C\u673A\u8BF7\u6C42");
745
+ const origin = req.headers.origin;
746
+ if (origin && origin !== `http://${req.headers.host}` && origin !== `https://${req.headers.host}`) throw new ApiError(403, "\u6765\u6E90\u65E0\u6548");
747
+ if (req.headers["sec-fetch-site"] === "cross-site") throw new ApiError(403, "\u6765\u6E90\u65E0\u6548");
748
+ if (req.method === "GET") {
749
+ json(res, 200, { ...await kit.state(), key });
750
+ return;
751
+ }
752
+ if (req.method !== "POST") throw new ApiError(405, "\u65B9\u6CD5\u65E0\u6548");
753
+ const actual = Buffer.from(String(req.headers["x-session-toolbox-key"] ?? "")), expected = Buffer.from(key);
754
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new ApiError(401, "\u8BF7\u5237\u65B0\u9762\u677F\u540E\u91CD\u8BD5");
755
+ if (!/^application\/json(?:\s*;|$)/i.test(String(req.headers["content-type"] ?? ""))) throw new ApiError(415, "\u9700\u8981 JSON");
756
+ let size = 0;
757
+ const chunks = [];
758
+ for await (const chunk of req) {
759
+ size += chunk.length;
760
+ if (size > MAX_BODY) throw new ApiError(413, "\u8BF7\u6C42\u8FC7\u5927");
761
+ chunks.push(chunk);
762
+ }
763
+ let input;
764
+ try {
765
+ input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
766
+ } catch {
767
+ throw new ApiError(400, "JSON \u65E0\u6548");
768
+ }
769
+ if (input === null || typeof input !== "object" || typeof input.type !== "string") throw new ApiError(400, "\u7F3A\u5C11\u64CD\u4F5C\u7C7B\u578B");
770
+ json(res, 200, { ...await kit.action(input), key });
771
+ } catch (error) {
772
+ json(res, error instanceof ApiError ? error.status : 500, { error: error instanceof ApiError ? error.message : `\u64CD\u4F5C\u5931\u8D25\uFF1A${error?.message ?? error}` });
773
+ }
774
+ };
775
+ }
776
+ async function dispatch(kit, input) {
777
+ switch (input.type) {
778
+ case "preflight":
779
+ return await kit.move.preflight({ sessionId: String(input.sessionId ?? ""), toCwd: String(input.toCwd ?? ""), createTarget: input.createTarget === true });
780
+ case "move":
781
+ return await kit.move.move(await kit.move.preflight({ sessionId: String(input.sessionId ?? ""), toCwd: String(input.toCwd ?? ""), createTarget: input.createTarget === true }));
782
+ case "move.rollback":
783
+ return await kit.move.rollback();
784
+ case "move.complete":
785
+ return await kit.move.complete();
786
+ case "trash.list":
787
+ return { items: await kit.trash.items() };
788
+ case "trash.session":
789
+ return await kit.trash.trashSession(String(input.sessionId ?? ""), { force: input.force === true });
790
+ case "trash.workspace":
791
+ return await kit.trash.trashWorkspace(String(input.workspaceId ?? ""), { force: input.force === true });
792
+ case "trash.restore":
793
+ return await kit.trash.restore(String(input.itemId ?? ""));
794
+ case "trash.purge":
795
+ return await kit.trash.purge(String(input.itemId ?? ""));
796
+ case "trash.sweep":
797
+ return await kit.trash.sweep();
798
+ case "settings.update":
799
+ return { settings: await kit.updateSettings(input.patch ?? {}) };
800
+ case "vault.sync":
801
+ return { records: (await kit.vaultSync(Array.isArray(input.records) ? input.records : [])).records.length };
802
+ case "vault.sweep": {
803
+ const settings = await kit.settings.read();
804
+ const ttlMs = (settings.draftVault?.ttlDays ?? 7) * 24 * 60 * 60 * 1e3;
805
+ const manifest = await kit.manifest.update((current) => ({
806
+ version: 1,
807
+ records: current.records.filter((record) => Date.now() - (record.createdAt ?? 0) <= ttlMs)
808
+ }));
809
+ return { records: manifest.records.length };
810
+ }
811
+ default:
812
+ throw new Error(`\u672A\u77E5\u7684\u64CD\u4F5C\u7C7B\u578B\uFF1A${input.type}`);
813
+ }
814
+ }
815
+
816
+ export {
817
+ JsonDocument,
818
+ guardSettings,
819
+ guardJournal,
820
+ guardLedger,
821
+ guardManifest,
822
+ writeOwned,
823
+ MoveEngine,
824
+ TrashEngine,
825
+ apiHandler,
826
+ dispatch
827
+ };
828
+ //# sourceMappingURL=chunk-4UQB3MPY.js.map