dsh-rewind-plugin 0.11.0 → 0.12.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/CONTRIBUTING.md +4 -3
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/SECURITY.md +54 -23
- package/docs/architecture.md +12 -6
- package/docs/compat/audit.md +1 -1
- package/docs/compat/tracking-boundary.md +6 -6
- package/docs/compat/tracking-boundary.zh.md +2 -2
- package/docs/format.md +159 -53
- package/docs/snapshot-auto-cleanup.md +8 -10
- package/docs/snapshot-auto-cleanup.zh.md +2 -4
- package/lib/client.js +1 -1
- package/lib/index.js +1026 -223
- package/lib/types/locales.d.ts +1 -0
- package/lib/types/snapshot.d.ts +397 -83
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
-
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { copyFile as copyFile2, rm as rm2, stat as stat2, unlink } from "node:fs/promises";
|
|
4
4
|
import "@deepseek-ai/schemastery";
|
|
5
5
|
|
|
6
6
|
// src/locales.ts
|
|
@@ -25,8 +25,9 @@ var en = {
|
|
|
25
25
|
"failed": "Rewind failed: {error}. The session is unchanged.",
|
|
26
26
|
"restore.count": "restored {count} file(s)",
|
|
27
27
|
"delete.count": "deleted {count} file(s)",
|
|
28
|
-
"skip.count": "skipped {count}
|
|
28
|
+
"skip.count": "skipped {count} path(s)",
|
|
29
29
|
"noRestorable": "; no restorable write-class changes after the target",
|
|
30
|
+
"storeUnsupported": "file restore unavailable: these snapshots use a newer store format (v{version}) than this plugin understands; nothing was changed. Update the plugin or clear this session's snapshots.",
|
|
30
31
|
"success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
|
|
31
32
|
"noUserMessages": "This session has no rewindable user messages yet.",
|
|
32
33
|
"chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
|
|
@@ -73,8 +74,9 @@ var zh = {
|
|
|
73
74
|
"failed": "\u56DE\u9000\u5931\u8D25\uFF1A{error}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002",
|
|
74
75
|
"restore.count": "\u8FD8\u539F {count} \u4E2A\u6587\u4EF6",
|
|
75
76
|
"delete.count": "\u5220\u9664 {count} \u4E2A\u6587\u4EF6",
|
|
76
|
-
"skip.count": "\u8DF3\u8FC7 {count} \u4E2A\
|
|
77
|
+
"skip.count": "\u8DF3\u8FC7 {count} \u4E2A\u8DEF\u5F84",
|
|
77
78
|
"noRestorable": "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4",
|
|
79
|
+
"storeUnsupported": "\u6587\u4EF6\u8FD8\u539F\u4E0D\u53EF\u7528\uFF1A\u8FD9\u4E9B\u5FEB\u7167\u4F7F\u7528\u4E86\u6BD4\u672C\u63D2\u4EF6\u66F4\u65B0\u7684\u5B58\u50A8\u683C\u5F0F\uFF08v{version}\uFF09\uFF1B\u5DE5\u4F5C\u533A\u672A\u505A\u4EFB\u4F55\u6539\u52A8\u3002\u8BF7\u66F4\u65B0\u63D2\u4EF6\uFF0C\u6216\u6E05\u9664\u8BE5\u4F1A\u8BDD\u7684\u5FEB\u7167\u3002",
|
|
78
80
|
"success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
|
|
79
81
|
"noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
80
82
|
"chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
|
|
@@ -218,23 +220,104 @@ function execSessionCwd(exec, requestedPath) {
|
|
|
218
220
|
|
|
219
221
|
// src/snapshot.ts
|
|
220
222
|
import { createHash } from "node:crypto";
|
|
221
|
-
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
222
|
-
import { dirname, join } from "node:path";
|
|
223
|
+
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
224
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
223
225
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
224
226
|
var SNAPSHOT_DIR_NAME = "rewind-snapshots";
|
|
227
|
+
var SIDECAR_SUFFIX = ".before";
|
|
228
|
+
var PENDING_DIR = ".pending";
|
|
229
|
+
var RESCUE_DIR = "rescue";
|
|
230
|
+
var JOURNAL_PREFIX = "journal-";
|
|
231
|
+
var LEGACY_JOURNAL_PREFIX = "restore-journal-";
|
|
232
|
+
var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
233
|
+
var COMPARE_CHUNK_BYTES = 64 * 1024;
|
|
234
|
+
var REPLACEMENT_CHAR = "\uFFFD";
|
|
225
235
|
var DEFAULT_SNAPSHOT_ROOT = join(resolveDshHome(), SNAPSHOT_DIR_NAME);
|
|
226
236
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
227
237
|
var MAX_ANCHOR_GROUPS = 100;
|
|
238
|
+
var CURRENT_STORE_VERSION = 2;
|
|
239
|
+
var UnknownStoreVersionError = class extends Error {
|
|
240
|
+
constructor(version, source) {
|
|
241
|
+
super(`snapshot store version ${version} is newer than this plugin understands (${source})`);
|
|
242
|
+
this.version = version;
|
|
243
|
+
this.source = source;
|
|
244
|
+
this.name = "UnknownStoreVersionError";
|
|
245
|
+
}
|
|
246
|
+
version;
|
|
247
|
+
source;
|
|
248
|
+
};
|
|
249
|
+
function textSourceOf(text) {
|
|
250
|
+
return text.includes(REPLACEMENT_CHAR) ? { kind: "lossyText", text } : { kind: "text", bytes: Buffer.from(text, "utf8") };
|
|
251
|
+
}
|
|
228
252
|
function isLinkEntry(entry) {
|
|
229
253
|
return "ref" in entry;
|
|
230
254
|
}
|
|
255
|
+
async function sameFileBytes(aPath, bPath) {
|
|
256
|
+
const sizes = await Promise.all([stat(aPath), stat(bPath)]);
|
|
257
|
+
if (sizes[0].size !== sizes[1].size) return false;
|
|
258
|
+
const [a, b] = await Promise.all([open(aPath, "r"), open(bPath, "r")]);
|
|
259
|
+
try {
|
|
260
|
+
const aChunk = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
|
|
261
|
+
const bChunk = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
|
|
262
|
+
for (; ; ) {
|
|
263
|
+
const [ra, rb] = await Promise.all([
|
|
264
|
+
a.read(aChunk, 0, COMPARE_CHUNK_BYTES, null),
|
|
265
|
+
b.read(bChunk, 0, COMPARE_CHUNK_BYTES, null)
|
|
266
|
+
]);
|
|
267
|
+
if (ra.bytesRead !== rb.bytesRead) return false;
|
|
268
|
+
if (ra.bytesRead === 0) return true;
|
|
269
|
+
if (!aChunk.subarray(0, ra.bytesRead).equals(bChunk.subarray(0, rb.bytesRead))) return false;
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
await Promise.all([a.close(), b.close()]);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function sameFileBuffer(path, bytes) {
|
|
276
|
+
const st = await stat(path);
|
|
277
|
+
if (st.size !== bytes.length) return false;
|
|
278
|
+
const handle = await open(path, "r");
|
|
279
|
+
try {
|
|
280
|
+
const chunk = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
|
|
281
|
+
let offset = 0;
|
|
282
|
+
for (; ; ) {
|
|
283
|
+
const read = await handle.read(chunk, 0, Math.min(COMPARE_CHUNK_BYTES, bytes.length - offset), offset);
|
|
284
|
+
if (read.bytesRead === 0) return offset === bytes.length;
|
|
285
|
+
if (!chunk.subarray(0, read.bytesRead).equals(bytes.subarray(offset, offset + read.bytesRead))) return false;
|
|
286
|
+
offset += read.bytesRead;
|
|
287
|
+
}
|
|
288
|
+
} finally {
|
|
289
|
+
await handle.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function isEnoent(error) {
|
|
293
|
+
return error?.code === "ENOENT";
|
|
294
|
+
}
|
|
231
295
|
var defaultProbe = {
|
|
232
|
-
async
|
|
296
|
+
async matches(source, path) {
|
|
233
297
|
try {
|
|
234
|
-
|
|
298
|
+
if (source === null) {
|
|
299
|
+
await stat(path);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (source.kind === "blob") return await sameFileBytes(path, source.path);
|
|
303
|
+
if (source.kind === "text") return await sameFileBuffer(path, source.bytes);
|
|
304
|
+
return (await readFile(path)).toString("utf8") === source.text;
|
|
235
305
|
} catch (error) {
|
|
236
|
-
if (error
|
|
237
|
-
|
|
306
|
+
if (isEnoent(error)) return source === null ? true : false;
|
|
307
|
+
return void 0;
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
async copy(path, dest) {
|
|
311
|
+
try {
|
|
312
|
+
await copyFile(path, dest);
|
|
313
|
+
const st = await stat(dest);
|
|
314
|
+
return { kind: "copied", size: st.size };
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (isEnoent(error)) {
|
|
317
|
+
const source = await stat(path).catch(() => void 0);
|
|
318
|
+
if (source === void 0) return { kind: "absent" };
|
|
319
|
+
}
|
|
320
|
+
return { kind: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
238
321
|
}
|
|
239
322
|
},
|
|
240
323
|
isLink: isLinkPath
|
|
@@ -253,43 +336,253 @@ async function writeJsonAtomic(file, data, afterTempWrite) {
|
|
|
253
336
|
await rename(tmp, file);
|
|
254
337
|
}
|
|
255
338
|
var RESTORE_JOURNAL_STATES = /* @__PURE__ */ new Set(["running", "rollback-running", "completed", "rolled-back", "recovery-required"]);
|
|
339
|
+
function isRawJournalRef(value) {
|
|
340
|
+
if (value === null) return true;
|
|
341
|
+
if (typeof value === "string") return true;
|
|
342
|
+
if (typeof value !== "object") return false;
|
|
343
|
+
const ref = value;
|
|
344
|
+
return typeof ref.blob === "string" || typeof ref.text === "string";
|
|
345
|
+
}
|
|
256
346
|
function isRestoreJournal(value) {
|
|
257
347
|
if (typeof value !== "object" || value === null) return false;
|
|
258
348
|
const v = value;
|
|
259
349
|
if (typeof v.id !== "string" || typeof v.sessionId !== "string" || typeof v.targetSeq !== "number") return false;
|
|
350
|
+
if (v.version !== void 0 && v.version !== 1 && v.version !== 2) return false;
|
|
260
351
|
if (typeof v.state !== "string" || !RESTORE_JOURNAL_STATES.has(v.state)) return false;
|
|
261
352
|
if (!Array.isArray(v.actions)) return false;
|
|
262
353
|
return v.actions.every((action) => {
|
|
263
354
|
if (typeof action !== "object" || action === null) return false;
|
|
264
355
|
const a = action;
|
|
265
|
-
|
|
356
|
+
if (typeof a.path !== "string" || a.action !== "restore" && a.action !== "delete") return false;
|
|
357
|
+
if (typeof a.done !== "boolean") return false;
|
|
358
|
+
if (!isRawJournalRef(a.before) || !isRawJournalRef(a.rescue)) return false;
|
|
359
|
+
if (a.action === "restore" && a.before === null) return false;
|
|
360
|
+
return true;
|
|
266
361
|
});
|
|
267
362
|
}
|
|
268
|
-
|
|
363
|
+
function refToSource(raw, sessionDir) {
|
|
364
|
+
if (raw === null) return null;
|
|
365
|
+
if (typeof raw === "string") return textSourceOf(raw);
|
|
366
|
+
if (typeof raw !== "object") return void 0;
|
|
367
|
+
const ref = raw;
|
|
368
|
+
if (typeof ref.blob === "string") {
|
|
369
|
+
if (!isSafeBackupRef(ref.blob)) return void 0;
|
|
370
|
+
return { kind: "blob", path: join(sessionDir, ref.blob) };
|
|
371
|
+
}
|
|
372
|
+
if (typeof ref.text === "string") return textSourceOf(ref.text);
|
|
373
|
+
return void 0;
|
|
374
|
+
}
|
|
375
|
+
function sourceToRef(source, sessionDir, entryPath) {
|
|
376
|
+
if (source === null) return null;
|
|
377
|
+
if (source.kind === "blob") {
|
|
378
|
+
const blob = relative(sessionDir, source.path);
|
|
379
|
+
if (!isSafeBackupRef(blob)) throw new Error(`unsafe backup ref ${blob} for ${entryPath}`);
|
|
380
|
+
return { blob };
|
|
381
|
+
}
|
|
382
|
+
return { text: source.kind === "text" ? source.bytes.toString("utf8") : source.text };
|
|
383
|
+
}
|
|
384
|
+
function journalToJson(journal, sessionDir) {
|
|
385
|
+
return {
|
|
386
|
+
version: 2,
|
|
387
|
+
id: journal.id,
|
|
388
|
+
sessionId: journal.sessionId,
|
|
389
|
+
targetSeq: journal.targetSeq,
|
|
390
|
+
startedAt: journal.startedAt,
|
|
391
|
+
...journal.finishedAt !== void 0 ? { finishedAt: journal.finishedAt } : {},
|
|
392
|
+
state: journal.state,
|
|
393
|
+
actions: journal.actions.map((action) => ({
|
|
394
|
+
path: action.path,
|
|
395
|
+
action: action.action,
|
|
396
|
+
before: sourceToRef(action.before, sessionDir, action.path),
|
|
397
|
+
rescue: sourceToRef(action.rescue, sessionDir, action.path),
|
|
398
|
+
...action.rescueError !== void 0 ? { rescueError: action.rescueError } : {},
|
|
399
|
+
...action.mode !== void 0 ? { mode: action.mode } : {},
|
|
400
|
+
...action.rescueMode !== void 0 ? { rescueMode: action.rescueMode } : {},
|
|
401
|
+
...action.parent !== void 0 ? { parent: action.parent } : {},
|
|
402
|
+
done: action.done,
|
|
403
|
+
...action.failed !== void 0 ? { failed: action.failed } : {}
|
|
404
|
+
})),
|
|
405
|
+
...journal.rollbackError !== void 0 ? { rollbackError: journal.rollbackError } : {}
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function journalFromJson(raw, sessionDir) {
|
|
409
|
+
const actions = [];
|
|
410
|
+
for (const value of raw.actions) {
|
|
411
|
+
const before = refToSource(value.before, sessionDir);
|
|
412
|
+
const rescue = refToSource(value.rescue, sessionDir);
|
|
413
|
+
if (before === void 0 || rescue === void 0) return void 0;
|
|
414
|
+
actions.push({
|
|
415
|
+
path: value.path,
|
|
416
|
+
action: value.action,
|
|
417
|
+
before: value.action === "delete" ? null : before,
|
|
418
|
+
rescue,
|
|
419
|
+
...typeof value.rescueError === "string" ? { rescueError: value.rescueError } : {},
|
|
420
|
+
...typeof value.mode === "number" ? { mode: value.mode } : {},
|
|
421
|
+
...typeof value.rescueMode === "number" ? { rescueMode: value.rescueMode } : {},
|
|
422
|
+
...typeof value.parent === "string" && value.parent.length > 0 ? { parent: value.parent } : {},
|
|
423
|
+
done: value.done,
|
|
424
|
+
...typeof value.failed === "string" ? { failed: value.failed } : {}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const version = raw.version === 1 ? 1 : 2;
|
|
428
|
+
return {
|
|
429
|
+
version,
|
|
430
|
+
id: raw.id,
|
|
431
|
+
sessionId: raw.sessionId,
|
|
432
|
+
targetSeq: raw.targetSeq,
|
|
433
|
+
startedAt: typeof raw.startedAt === "number" ? raw.startedAt : 0,
|
|
434
|
+
...typeof raw.finishedAt === "number" ? { finishedAt: raw.finishedAt } : {},
|
|
435
|
+
state: raw.state,
|
|
436
|
+
actions,
|
|
437
|
+
...typeof raw.rollbackError === "string" ? { rollbackError: raw.rollbackError } : {}
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function isJournalName(name2) {
|
|
441
|
+
if (!name2.endsWith(".json")) return false;
|
|
442
|
+
return name2.startsWith(JOURNAL_PREFIX) || name2.startsWith(LEGACY_JOURNAL_PREFIX);
|
|
443
|
+
}
|
|
444
|
+
function journalOpIdOf(name2) {
|
|
445
|
+
const prefix = name2.startsWith(LEGACY_JOURNAL_PREFIX) ? LEGACY_JOURNAL_PREFIX : JOURNAL_PREFIX;
|
|
446
|
+
return name2.slice(prefix.length, -".json".length);
|
|
447
|
+
}
|
|
448
|
+
function entryFileName(callId) {
|
|
449
|
+
return `${safeFileId(callId)}-${shortHash(callId)}.json`;
|
|
450
|
+
}
|
|
451
|
+
function shortHash(value) {
|
|
452
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
453
|
+
}
|
|
454
|
+
function sidecarName(entryFile) {
|
|
455
|
+
return `${entryFile.slice(0, -".json".length)}${SIDECAR_SUFFIX}`;
|
|
456
|
+
}
|
|
457
|
+
function refAnchorOf(ref) {
|
|
458
|
+
const slash = ref.indexOf("/");
|
|
459
|
+
return slash === -1 ? Number.NaN : Number(ref.slice(0, slash));
|
|
460
|
+
}
|
|
461
|
+
function entryToJson(entry) {
|
|
462
|
+
const base = {
|
|
463
|
+
store: 2,
|
|
464
|
+
callId: entry.callId,
|
|
465
|
+
file: entry.path,
|
|
466
|
+
time: entry.time,
|
|
467
|
+
...entry.parent !== void 0 ? { parent: entry.parent } : {},
|
|
468
|
+
...entry.mode !== void 0 ? { mode: entry.mode } : {},
|
|
469
|
+
...entry.lossy === true ? { lossy: true } : {}
|
|
470
|
+
};
|
|
471
|
+
if (entry.before === null) return { ...base, blob: null, size: 0 };
|
|
472
|
+
if (entry.before.kind !== "blob") throw new Error(`entry for ${entry.path} is not blob-backed`);
|
|
473
|
+
return { ...base, blob: basename(entry.before.path), size: entry.size };
|
|
474
|
+
}
|
|
475
|
+
function linkToJson(link) {
|
|
476
|
+
return {
|
|
477
|
+
store: 2,
|
|
478
|
+
callId: link.callId,
|
|
479
|
+
file: link.path,
|
|
480
|
+
ref: link.ref,
|
|
481
|
+
time: link.time,
|
|
482
|
+
...link.parent !== void 0 ? { parent: link.parent } : {}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
async function readEntry(file, anchorSeq) {
|
|
486
|
+
let parsed;
|
|
269
487
|
try {
|
|
270
|
-
const
|
|
271
|
-
if (typeof
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
488
|
+
const value = JSON.parse(await readFile(file, "utf8"));
|
|
489
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
490
|
+
parsed = value;
|
|
491
|
+
} catch {
|
|
492
|
+
return void 0;
|
|
493
|
+
}
|
|
494
|
+
if (typeof parsed.store === "number" && parsed.store > CURRENT_STORE_VERSION) {
|
|
495
|
+
throw new UnknownStoreVersionError(parsed.store, file);
|
|
496
|
+
}
|
|
497
|
+
const callId = String(parsed.callId ?? "");
|
|
498
|
+
const time = typeof parsed.time === "number" ? parsed.time : 0;
|
|
499
|
+
const origin = { file };
|
|
500
|
+
if (parsed.store === 2) {
|
|
501
|
+
if (typeof parsed.file !== "string") return void 0;
|
|
502
|
+
const parent = typeof parsed.parent === "string" && parsed.parent.length > 0 ? parsed.parent : void 0;
|
|
503
|
+
const base2 = { callId, anchorSeq, path: parsed.file, time, ...parent !== void 0 ? { parent } : {}, ...origin };
|
|
504
|
+
if (typeof parsed.ref === "string") return { ...base2, ref: parsed.ref };
|
|
505
|
+
if (parsed.blob === null) {
|
|
506
|
+
if (parsed.lossy === true) return void 0;
|
|
507
|
+
if (typeof parsed.size === "number" && parsed.size !== 0) return void 0;
|
|
508
|
+
return { ...base2, before: null, size: 0 };
|
|
509
|
+
}
|
|
510
|
+
if (typeof parsed.blob !== "string") return void 0;
|
|
511
|
+
if (parsed.blob !== sidecarName(basename(file))) return void 0;
|
|
512
|
+
const size = typeof parsed.size === "number" && parsed.size >= 0 ? parsed.size : 0;
|
|
513
|
+
const mode = typeof parsed.mode === "number" ? parsed.mode : void 0;
|
|
514
|
+
if (parsed.lossy === true) {
|
|
515
|
+
try {
|
|
516
|
+
const text = await readFile(join(dirname(file), parsed.blob), "utf8");
|
|
517
|
+
return { ...base2, before: { kind: "lossyText", text }, size, lossy: true, ...mode !== void 0 ? { mode } : {} };
|
|
518
|
+
} catch {
|
|
519
|
+
return void 0;
|
|
520
|
+
}
|
|
280
521
|
}
|
|
281
522
|
return {
|
|
282
|
-
...
|
|
283
|
-
before:
|
|
523
|
+
...base2,
|
|
524
|
+
before: { kind: "blob", path: join(dirname(file), parsed.blob) },
|
|
525
|
+
size,
|
|
526
|
+
...mode !== void 0 ? { mode } : {}
|
|
284
527
|
};
|
|
528
|
+
}
|
|
529
|
+
if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
|
|
530
|
+
const base = { callId, anchorSeq: parsed.anchorSeq, path: parsed.path, time, ...origin };
|
|
531
|
+
if (typeof parsed.ref === "string") return { ...base, ref: parsed.ref };
|
|
532
|
+
if (parsed.before !== null && typeof parsed.before !== "string") return void 0;
|
|
533
|
+
if (parsed.before === null) return { ...base, before: null, size: 0 };
|
|
534
|
+
return { ...base, before: textSourceOf(parsed.before), size: Buffer.byteLength(parsed.before, "utf8") };
|
|
535
|
+
}
|
|
536
|
+
async function dirBytes(dir) {
|
|
537
|
+
let names;
|
|
538
|
+
try {
|
|
539
|
+
names = await readdir(dir);
|
|
285
540
|
} catch {
|
|
286
|
-
return
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
let total = 0;
|
|
544
|
+
for (const name2 of names) {
|
|
545
|
+
const full = join(dir, name2);
|
|
546
|
+
const st = await lstat(full).catch(() => void 0);
|
|
547
|
+
if (st === void 0) continue;
|
|
548
|
+
if (st.isDirectory()) total += await dirBytes(full);
|
|
549
|
+
else if (st.isFile()) total += st.size;
|
|
287
550
|
}
|
|
551
|
+
return total;
|
|
288
552
|
}
|
|
289
553
|
async function isLinkPath(path) {
|
|
290
554
|
try {
|
|
291
|
-
const
|
|
292
|
-
return
|
|
555
|
+
const stat3 = await lstat(path);
|
|
556
|
+
return stat3.isSymbolicLink() || stat3.nlink > 1;
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function nearestExistingAncestor(dir) {
|
|
562
|
+
let current = dir;
|
|
563
|
+
for (; ; ) {
|
|
564
|
+
const st = await lstat(current).catch(() => void 0);
|
|
565
|
+
if (st !== void 0) return current;
|
|
566
|
+
const parent = dirname(current);
|
|
567
|
+
if (parent === current) return void 0;
|
|
568
|
+
current = parent;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function parentStillMatches(path, recorded) {
|
|
572
|
+
if (recorded === void 0) return true;
|
|
573
|
+
const dir = dirname(path);
|
|
574
|
+
try {
|
|
575
|
+
return await realpath(dir) === recorded;
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (!isEnoent(error)) return false;
|
|
578
|
+
}
|
|
579
|
+
const ancestor = await nearestExistingAncestor(dir);
|
|
580
|
+
if (ancestor === void 0) return false;
|
|
581
|
+
try {
|
|
582
|
+
const real = await realpath(ancestor);
|
|
583
|
+
const inside = relative(real, recorded);
|
|
584
|
+
const escapes = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
585
|
+
return inside !== "" && !escapes;
|
|
293
586
|
} catch {
|
|
294
587
|
return false;
|
|
295
588
|
}
|
|
@@ -297,6 +590,13 @@ async function isLinkPath(path) {
|
|
|
297
590
|
function isSafeLinkRef(ref) {
|
|
298
591
|
return /^[0-9]+\/[a-zA-Z0-9._-]+\.json$/.test(ref);
|
|
299
592
|
}
|
|
593
|
+
function isSafeBackupRef(ref) {
|
|
594
|
+
if (ref.length === 0 || ref.startsWith("/") || ref.includes("\\")) return false;
|
|
595
|
+
const segments = ref.split("/");
|
|
596
|
+
if (segments.length !== 2 && segments.length !== 3) return false;
|
|
597
|
+
if (segments.length === 3 && segments[0] !== RESCUE_DIR) return false;
|
|
598
|
+
return segments.every((segment) => segment !== "." && segment !== ".." && /^[a-zA-Z0-9._-]+$/.test(segment));
|
|
599
|
+
}
|
|
300
600
|
async function dirSizeAndLastActive(dir) {
|
|
301
601
|
let size = 0;
|
|
302
602
|
let lastActiveMs = 0;
|
|
@@ -319,7 +619,7 @@ async function dirSizeAndLastActive(dir) {
|
|
|
319
619
|
return;
|
|
320
620
|
}
|
|
321
621
|
for (const name2 of names) {
|
|
322
|
-
if (name2.startsWith(".")) continue;
|
|
622
|
+
if (name2.startsWith(".") && name2 !== PENDING_DIR) continue;
|
|
323
623
|
await visit(join(current, name2));
|
|
324
624
|
}
|
|
325
625
|
};
|
|
@@ -329,6 +629,10 @@ async function dirSizeAndLastActive(dir) {
|
|
|
329
629
|
var SnapshotStore = class _SnapshotStore {
|
|
330
630
|
/** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
|
|
331
631
|
static PRUNE_INTERVAL_MS = 1e3;
|
|
632
|
+
/** Session-format-version marker file inside the session dir. Non-`.json`, so it never counts as a checkpoint entry. */
|
|
633
|
+
static FORMAT_FILE = "format";
|
|
634
|
+
/** Plugin STORE-format marker file inside the session dir (non-`.json`, same reasoning). */
|
|
635
|
+
static STORE_FILE = "store";
|
|
332
636
|
lastPruneAt = 0;
|
|
333
637
|
/**
|
|
334
638
|
* Monotonic entry clock. Date.now() has 1ms precision, so back-to-back
|
|
@@ -349,14 +653,18 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
349
653
|
root;
|
|
350
654
|
/**
|
|
351
655
|
* In-memory per-path "most recent entry" for content dedup, keyed by
|
|
352
|
-
* `<sessionId>\0<path>`. Each value holds the entry's effective
|
|
353
|
-
*
|
|
354
|
-
* to the immediately-prior entry (linear chain). Seeded lazily
|
|
355
|
-
* from the bounded on-disk window, so dedup survives a host
|
|
656
|
+
* `<sessionId>\0<path>`. Each value holds the entry's effective byte source
|
|
657
|
+
* (a handle, not a copy) and its own file ref, so a new record with the same
|
|
658
|
+
* content links to the immediately-prior entry (linear chain). Seeded lazily
|
|
659
|
+
* per session from the bounded on-disk window, so dedup survives a host
|
|
660
|
+
* restart. A handle whose bytes vanished (pruned out of band) is treated as
|
|
661
|
+
* "never recorded" — dedup then stores MORE, never less.
|
|
356
662
|
*/
|
|
357
663
|
lastEntry = /* @__PURE__ */ new Map();
|
|
358
664
|
/** Sessions whose dedup state has been seeded from disk this process. */
|
|
359
665
|
seededSessions = /* @__PURE__ */ new Set();
|
|
666
|
+
/** Sessions whose store-format marker this process has already stamped. */
|
|
667
|
+
storeStamped = /* @__PURE__ */ new Set();
|
|
360
668
|
/**
|
|
361
669
|
* Session-format version snapshots are anchored under, stamped into each
|
|
362
670
|
* session's `format` marker when an entry is recorded. `null` until the host
|
|
@@ -378,7 +686,54 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
378
686
|
}
|
|
379
687
|
/** Absolute file ref (relative to the session dir) of an entry. */
|
|
380
688
|
entryRefOf(sessionId, callId, anchorSeq) {
|
|
381
|
-
return `${anchorSeq}/${
|
|
689
|
+
return `${anchorSeq}/${entryFileName(callId)}`;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* The session-relative ref of an entry READ from disk: the file that really
|
|
693
|
+
* holds it. A v1 entry keeps its released name, so recomputing the name from
|
|
694
|
+
* the call id would produce a dangling reference.
|
|
695
|
+
*/
|
|
696
|
+
refOfRead(sessionId, entry) {
|
|
697
|
+
if (entry.file !== void 0) return relative(this.sessionDir(sessionId), entry.file);
|
|
698
|
+
return this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
|
|
699
|
+
}
|
|
700
|
+
/** Drop every in-memory trace of one session (its directory is gone). */
|
|
701
|
+
forgetSession(sessionId) {
|
|
702
|
+
this.seededSessions.delete(sessionId);
|
|
703
|
+
this.storeStamped.delete(sessionId);
|
|
704
|
+
for (const key of [...this.lastEntry.keys()]) {
|
|
705
|
+
if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Forget in-memory state for sessions whose directory no longer exists —
|
|
710
|
+
* after a sweep, or after the user removed a session dir out of band. A
|
|
711
|
+
* stale handle is SAFE (dedup and the boundary both fail toward storing
|
|
712
|
+
* more), but keeping it means the store holds state for a session it deleted
|
|
713
|
+
* and skips re-stamping that session's `format`/`store` markers.
|
|
714
|
+
*/
|
|
715
|
+
async forgetMissingSessions() {
|
|
716
|
+
const known = /* @__PURE__ */ new Set([...this.seededSessions, ...this.storeStamped]);
|
|
717
|
+
for (const key of this.lastEntry.keys()) {
|
|
718
|
+
const separator = key.indexOf("\0");
|
|
719
|
+
if (separator !== -1) known.add(key.slice(0, separator));
|
|
720
|
+
}
|
|
721
|
+
for (const sessionId of known) {
|
|
722
|
+
const present = await this.exists(this.sessionDir(sessionId)).catch(() => true);
|
|
723
|
+
if (!present) this.forgetSession(sessionId);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Stage a capture slot for one tool call: create the session's `.pending/`
|
|
728
|
+
* area and return the absolute path the caller copies the before-bytes into
|
|
729
|
+
* (never through memory). The slot lives inside the session dir so the
|
|
730
|
+
* commit can `rename` it into the anchor group atomically; a slot that is
|
|
731
|
+
* never committed is either unlinked by its caller or collected by `prune`.
|
|
732
|
+
*/
|
|
733
|
+
async stageCapture(sessionId, key) {
|
|
734
|
+
const dir = join(this.sessionDir(sessionId), PENDING_DIR);
|
|
735
|
+
await mkdir(dir, { recursive: true });
|
|
736
|
+
return join(dir, `${safeFileId(key)}-${shortHash(key)}${SIDECAR_SUFFIX}`);
|
|
382
737
|
}
|
|
383
738
|
/**
|
|
384
739
|
* Seed a session's dedup state from the existing (bounded) on-disk window:
|
|
@@ -393,8 +748,8 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
393
748
|
for (const entry of await this.entriesAfter(sessionId, 0)) {
|
|
394
749
|
const key = `${sessionId}\0${entry.path}`;
|
|
395
750
|
if (this.lastEntry.has(key)) continue;
|
|
396
|
-
const
|
|
397
|
-
this.lastEntry.set(key, {
|
|
751
|
+
const source = await this.resolveBefore(sessionId, entry);
|
|
752
|
+
this.lastEntry.set(key, { source, ref: this.refOfRead(sessionId, entry) });
|
|
398
753
|
}
|
|
399
754
|
} catch {
|
|
400
755
|
this.seededSessions.delete(sessionId);
|
|
@@ -408,44 +763,135 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
408
763
|
* path from a restore).
|
|
409
764
|
*/
|
|
410
765
|
async resolveBefore(sessionId, entry, seen = /* @__PURE__ */ new Set()) {
|
|
411
|
-
if (!isLinkEntry(entry)) return entry
|
|
766
|
+
if (!isLinkEntry(entry)) return this.validatedSource(entry);
|
|
412
767
|
const key = `${entry.anchorSeq}:${entry.callId}`;
|
|
413
768
|
if (seen.has(key)) throw new Error(`link cycle at ${entry.path} (${key})`);
|
|
414
769
|
seen.add(key);
|
|
415
770
|
if (!isSafeLinkRef(entry.ref)) throw new Error(`unsafe link ref ${entry.ref} for ${entry.path}`);
|
|
416
|
-
const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref));
|
|
771
|
+
const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref), refAnchorOf(entry.ref));
|
|
417
772
|
if (referenced === void 0) throw new Error(`dangling link ${entry.ref} for ${entry.path}`);
|
|
418
773
|
return this.resolveBefore(sessionId, referenced, seen);
|
|
419
774
|
}
|
|
420
|
-
/**
|
|
421
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Validate a real entry's byte source against the store's own files: a
|
|
777
|
+
* sidecar that is missing, not a regular file, or a different size than the
|
|
778
|
+
* metadata records is an INTEGRITY failure (thrown), never a silent skip and
|
|
779
|
+
* never a fallback to "the file was created" — a restore must not delete a
|
|
780
|
+
* file whose backup it cannot read.
|
|
781
|
+
*/
|
|
782
|
+
async validatedSource(entry) {
|
|
783
|
+
const source = entry.before;
|
|
784
|
+
if (source === null || source.kind !== "blob") return source;
|
|
785
|
+
const st = await stat(source.path).catch((error) => {
|
|
786
|
+
if (isEnoent(error)) throw new Error(`missing backup sidecar ${source.path} for ${entry.path}`);
|
|
787
|
+
throw error;
|
|
788
|
+
});
|
|
789
|
+
if (!st.isFile()) throw new Error(`backup sidecar is not a file: ${source.path}`);
|
|
790
|
+
if (st.size !== entry.size) {
|
|
791
|
+
throw new Error(`backup sidecar size mismatch for ${entry.path} (recorded ${entry.size}, found ${st.size})`);
|
|
792
|
+
}
|
|
793
|
+
return source;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* True when two recorded byte sources are the same content. Comparison is
|
|
797
|
+
* STREAMING (size first, then chunks) so large files never enter memory.
|
|
798
|
+
* Any unreadable handle — or any legacy lossy source, whose original bytes
|
|
799
|
+
* are unknowable — answers `false`: dedup must fail toward storing more,
|
|
800
|
+
* never toward claiming "unchanged".
|
|
801
|
+
*/
|
|
802
|
+
async sourcesMatch(a, b) {
|
|
803
|
+
if (a === null || b === null) return a === null && b === null;
|
|
804
|
+
try {
|
|
805
|
+
if (a.kind === "blob" && b.kind === "blob") return await sameFileBytes(a.path, b.path);
|
|
806
|
+
if (a.kind === "text" && b.kind === "text") return a.bytes.equals(b.bytes);
|
|
807
|
+
if (a.kind === "blob" && b.kind === "text") return await sameFileBuffer(a.path, b.bytes);
|
|
808
|
+
if (a.kind === "text" && b.kind === "blob") return await sameFileBuffer(b.path, a.bytes);
|
|
809
|
+
return false;
|
|
810
|
+
} catch {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Write raw bytes to a sidecar path atomically (temp + rename): a crash
|
|
816
|
+
* between the steps leaves only a `.tmp` that no reader picks up.
|
|
817
|
+
*/
|
|
818
|
+
async writeSidecar(dest, source) {
|
|
819
|
+
const tmp = `${dest}.tmp`;
|
|
820
|
+
if (source.kind === "blob") await copyFile(source.path, tmp);
|
|
821
|
+
else if (source.kind === "text") await writeFile(tmp, source.bytes);
|
|
822
|
+
else await writeFile(tmp, Buffer.from(source.text, "utf8"));
|
|
823
|
+
await rename(tmp, dest);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Place one entry's sidecar next to its entry file: MOVE a staged capture
|
|
827
|
+
* (same filesystem, atomic) or write the bytes from a source. Returns the
|
|
828
|
+
* blob source and its size, or null for a created file. The sidecar is
|
|
829
|
+
* always complete before the entry JSON is written.
|
|
830
|
+
*/
|
|
831
|
+
async placeSidecar(entryFile, content) {
|
|
832
|
+
if (content.source === null) return null;
|
|
833
|
+
const dest = join(dirname(entryFile), sidecarName(basename(entryFile)));
|
|
834
|
+
if (content.staged !== void 0) await rename(content.staged.file, dest);
|
|
835
|
+
else await this.writeSidecar(dest, content.source);
|
|
836
|
+
const st = await stat(dest);
|
|
837
|
+
return { source: { kind: "blob", path: dest }, size: st.size };
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Commit one entry (a full before-backup or an in-place dedup link) under
|
|
841
|
+
* its anchor group.
|
|
842
|
+
*/
|
|
843
|
+
async commit(sessionId, entry, content, opts) {
|
|
422
844
|
const time = Math.max(Date.now(), this.lastEntryTime + 1);
|
|
423
845
|
this.lastEntryTime = time;
|
|
846
|
+
const parent = await realpath(dirname(entry.path)).catch(() => void 0);
|
|
424
847
|
await this.ensureDedupSeeded(sessionId);
|
|
848
|
+
await this.assertKnownStoreVersion(sessionId);
|
|
425
849
|
const dir = this.anchorDir(sessionId, entry.anchorSeq);
|
|
426
850
|
await mkdir(dir, { recursive: true });
|
|
427
|
-
const file = join(dir,
|
|
851
|
+
const file = join(dir, entryFileName(entry.callId));
|
|
428
852
|
const selfRef = this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
|
|
429
853
|
const key = `${sessionId}\0${entry.path}`;
|
|
430
854
|
const prior = this.lastEntry.get(key);
|
|
431
|
-
|
|
855
|
+
const incoming = content.source === null ? null : content.staged !== void 0 ? { kind: "blob", path: content.staged.file } : content.source;
|
|
856
|
+
const link = this.dedup && opts?.dedup !== false && prior !== void 0 && await this.sourcesMatch(prior.source, incoming);
|
|
857
|
+
if (link) {
|
|
432
858
|
const committed = {
|
|
433
859
|
callId: entry.callId,
|
|
434
860
|
anchorSeq: entry.anchorSeq,
|
|
435
861
|
path: entry.path,
|
|
436
862
|
ref: prior.ref,
|
|
863
|
+
...parent !== void 0 ? { parent } : {},
|
|
437
864
|
time
|
|
438
865
|
};
|
|
439
|
-
await
|
|
440
|
-
|
|
866
|
+
if (content.staged !== void 0) await rm(content.staged.file, { force: true });
|
|
867
|
+
await writeJsonAtomic(file, linkToJson(committed), () => opts?.crash?.("after-temp-write"));
|
|
868
|
+
this.lastEntry.set(key, { source: prior.source, ref: selfRef });
|
|
441
869
|
} else {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
870
|
+
const placed = await this.placeSidecar(file, content);
|
|
871
|
+
const committed = {
|
|
872
|
+
callId: entry.callId,
|
|
873
|
+
anchorSeq: entry.anchorSeq,
|
|
874
|
+
path: entry.path,
|
|
875
|
+
before: placed?.source ?? null,
|
|
876
|
+
size: placed?.size ?? 0,
|
|
877
|
+
// Content that was already lossy when it reached the store (a v1
|
|
878
|
+
// string, or a materialized link to one) stays marked, so a later
|
|
879
|
+
// reader cannot mistake its re-encoded bytes for a faithful backup.
|
|
880
|
+
...incoming?.kind === "lossyText" ? { lossy: true } : {},
|
|
881
|
+
...content.mode !== void 0 ? { mode: content.mode } : {},
|
|
882
|
+
...parent !== void 0 ? { parent } : {},
|
|
883
|
+
time
|
|
884
|
+
};
|
|
885
|
+
await writeJsonAtomic(file, entryToJson(committed), () => opts?.crash?.("after-temp-write"));
|
|
886
|
+
this.lastEntry.set(key, { source: committed.before, ref: selfRef });
|
|
445
887
|
}
|
|
446
888
|
if (this.formatVersion !== null) {
|
|
447
889
|
await this.markFormatVersion(sessionId, this.formatVersion);
|
|
448
890
|
}
|
|
891
|
+
if (!this.storeStamped.has(sessionId)) {
|
|
892
|
+
await this.markStoreVersion(sessionId, CURRENT_STORE_VERSION);
|
|
893
|
+
this.storeStamped.add(sessionId);
|
|
894
|
+
}
|
|
449
895
|
const now = Date.now();
|
|
450
896
|
if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
|
|
451
897
|
this.lastPruneAt = now;
|
|
@@ -453,16 +899,39 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
453
899
|
}
|
|
454
900
|
}
|
|
455
901
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
902
|
+
* Commit one before-backup whose content the caller already holds as raw
|
|
903
|
+
* text (the boundary-friendly API: tests, synthetic records). The bytes are
|
|
904
|
+
* encoded UTF-8, exactly as the released v1 build did for text content.
|
|
905
|
+
*/
|
|
906
|
+
async recordEntry(sessionId, entry, opts) {
|
|
907
|
+
await this.commit(sessionId, entry, {
|
|
908
|
+
source: entry.before === null ? null : textSourceOf(entry.before)
|
|
909
|
+
}, opts);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Commit one before-backup whose content is an existing byte file (the
|
|
913
|
+
* capture and boundary paths): `backup.file` is MOVED into the anchor group
|
|
914
|
+
* (same filesystem, so this is atomic), or `null` when the file did not
|
|
915
|
+
* exist — a creation.
|
|
916
|
+
*/
|
|
917
|
+
async recordBackup(sessionId, entry, backup, opts) {
|
|
918
|
+
await this.commit(sessionId, entry, {
|
|
919
|
+
source: backup === null ? null : { kind: "blob", path: backup.file },
|
|
920
|
+
...backup !== null ? { staged: { file: backup.file } } : {},
|
|
921
|
+
...backup?.mode !== void 0 ? { mode: backup.mode } : {}
|
|
922
|
+
}, opts);
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* The byte source recorded by the path's MOST RECENT entry, or undefined
|
|
926
|
+
* when the path has never been recorded (a fresh tracking sight). This is
|
|
927
|
+
* the single in-memory "last known state" the boundary compares the disk
|
|
928
|
+
* against — the same source `recordEntry` dedups against, so there is one
|
|
929
|
+
* handle and one comparison per decision, not two. Seeding is idempotent
|
|
930
|
+
* (once per session from disk).
|
|
462
931
|
*/
|
|
463
932
|
async lastKnownContent(sessionId, path) {
|
|
464
933
|
await this.ensureDedupSeeded(sessionId);
|
|
465
|
-
return this.lastEntry.get(`${sessionId}\0${path}`)?.
|
|
934
|
+
return this.lastEntry.get(`${sessionId}\0${path}`)?.source;
|
|
466
935
|
}
|
|
467
936
|
/**
|
|
468
937
|
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
@@ -487,7 +956,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
487
956
|
const files = await readdir(this.anchorDir(sessionId, anchorSeq)).catch(() => []);
|
|
488
957
|
for (const file of files) {
|
|
489
958
|
if (!file.endsWith(".json")) continue;
|
|
490
|
-
const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file));
|
|
959
|
+
const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file), anchorSeq);
|
|
491
960
|
if (entry !== void 0) entries.push(entry);
|
|
492
961
|
}
|
|
493
962
|
}
|
|
@@ -521,9 +990,15 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
521
990
|
* `delete` ONLY when the file currently exists; an already-absent file
|
|
522
991
|
* is a no-op — this kills the "ghost impact" of replaying an entry a
|
|
523
992
|
* previous rewind already consumed.
|
|
524
|
-
* -
|
|
525
|
-
*
|
|
526
|
-
*
|
|
993
|
+
* - a recorded byte source plans a `restore` ONLY when the current bytes
|
|
994
|
+
* differ from it (or the file is missing); identical bytes are a no-op —
|
|
995
|
+
* this keeps repeated rewinds idempotent.
|
|
996
|
+
* - A released-v1 record that lost bytes to a lossy decode (`lossyText`) is
|
|
997
|
+
* compared with the same lossy decode but NEVER written back: a skip is
|
|
998
|
+
* reported instead of destroying live bytes with U+FFFD content.
|
|
999
|
+
* - An unreadable / unresolvable record is a per-file FAILURE, never a
|
|
1000
|
+
* delete: planning a delete for a file we cannot restore is the one
|
|
1001
|
+
* mistake that loses data.
|
|
527
1002
|
* - Symlinked / hard-linked paths are never planned (they are reported as
|
|
528
1003
|
* skipped by the restore pass, never written through).
|
|
529
1004
|
* - A probe failure (e.g. a permission error reading the file) plans the
|
|
@@ -536,40 +1011,54 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
536
1011
|
* @returns the planned actions, the link paths skipped, and per-file failures.
|
|
537
1012
|
*/
|
|
538
1013
|
async planRestore(sessionId, targetSeq, probe) {
|
|
1014
|
+
await this.assertKnownStoreVersion(sessionId);
|
|
539
1015
|
const actions = [];
|
|
540
1016
|
const skipped = [];
|
|
541
1017
|
const failed = [];
|
|
542
1018
|
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
1019
|
+
let source;
|
|
1020
|
+
try {
|
|
1021
|
+
source = await this.resolveBefore(sessionId, entry);
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
543
1026
|
try {
|
|
544
1027
|
if (await probe.isLink(entry.path)) {
|
|
545
1028
|
skipped.push(entry.path);
|
|
546
1029
|
continue;
|
|
547
1030
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
before = await this.resolveBefore(sessionId, entry);
|
|
551
|
-
} catch (error) {
|
|
552
|
-
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
1031
|
+
if (!await parentStillMatches(entry.path, entry.parent)) {
|
|
1032
|
+
skipped.push(entry.path);
|
|
553
1033
|
continue;
|
|
554
1034
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if (
|
|
558
|
-
|
|
559
|
-
actions.push({ path: entry.path, action: "restore", before });
|
|
1035
|
+
if (source !== null && source.kind === "lossyText") {
|
|
1036
|
+
const same2 = await probe.matches(source, entry.path);
|
|
1037
|
+
if (same2 !== true) skipped.push(entry.path);
|
|
1038
|
+
continue;
|
|
560
1039
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1040
|
+
const same = await probe.matches(source, entry.path);
|
|
1041
|
+
if (same === true) continue;
|
|
1042
|
+
const pin = entry.parent !== void 0 ? { parent: entry.parent } : {};
|
|
1043
|
+
if (source === null) actions.push({ path: entry.path, action: "delete", ...pin });
|
|
1044
|
+
else {
|
|
1045
|
+
const mode = isLinkEntry(entry) ? void 0 : entry.mode;
|
|
1046
|
+
actions.push({
|
|
1047
|
+
path: entry.path,
|
|
1048
|
+
action: "restore",
|
|
1049
|
+
before: source,
|
|
1050
|
+
...mode !== void 0 ? { mode } : {},
|
|
1051
|
+
...pin
|
|
1052
|
+
});
|
|
567
1053
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
1054
|
+
} catch {
|
|
1055
|
+
if (source !== null && source.kind === "lossyText") {
|
|
1056
|
+
skipped.push(entry.path);
|
|
1057
|
+
continue;
|
|
572
1058
|
}
|
|
1059
|
+
const pin = entry.parent !== void 0 ? { parent: entry.parent } : {};
|
|
1060
|
+
if (source === null) actions.push({ path: entry.path, action: "delete", ...pin });
|
|
1061
|
+
else actions.push({ path: entry.path, action: "restore", before: source, ...pin });
|
|
573
1062
|
}
|
|
574
1063
|
}
|
|
575
1064
|
return { actions, skipped, failed };
|
|
@@ -615,7 +1104,22 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
615
1104
|
const journalAction = journal.actions[i];
|
|
616
1105
|
let applied;
|
|
617
1106
|
try {
|
|
618
|
-
applied = await this.applyActionToDisk(
|
|
1107
|
+
applied = await this.applyActionToDisk(
|
|
1108
|
+
action.action,
|
|
1109
|
+
action.path,
|
|
1110
|
+
action.action === "restore" ? action.before : null,
|
|
1111
|
+
deleteFile,
|
|
1112
|
+
{
|
|
1113
|
+
...action.action === "restore" && action.mode !== void 0 ? { mode: action.mode } : {},
|
|
1114
|
+
...action.parent !== void 0 ? { parent: action.parent } : {}
|
|
1115
|
+
}
|
|
1116
|
+
);
|
|
1117
|
+
if (applied === "skipped") {
|
|
1118
|
+
journalAction.failed = `parent directory moved or repointed: ${dirname(action.path)}`;
|
|
1119
|
+
await this.saveJournal(journal);
|
|
1120
|
+
failed.push({ path: action.path, message: journalAction.failed });
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
619
1123
|
if (applied === "enoent") {
|
|
620
1124
|
journalAction.done = true;
|
|
621
1125
|
await this.saveJournal(journal);
|
|
@@ -640,61 +1144,95 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
640
1144
|
await this.saveJournal(journal);
|
|
641
1145
|
return { restored, deleted, skipped, failed };
|
|
642
1146
|
}
|
|
643
|
-
/**
|
|
644
|
-
static JOURNAL_PREFIX = "restore-journal-";
|
|
645
|
-
/** Session-format-version marker file inside the session dir. Non-`.json`, so it never counts as a checkpoint entry. */
|
|
646
|
-
static FORMAT_FILE = "format";
|
|
647
|
-
/** Absolute path of one restore-op journal file. */
|
|
1147
|
+
/** Absolute path of one restore-op journal file (the current prefix). */
|
|
648
1148
|
journalPath(sessionId, opId) {
|
|
649
|
-
return join(this.sessionDir(sessionId), `${
|
|
1149
|
+
return join(this.sessionDir(sessionId), `${JOURNAL_PREFIX}${safeFileId(opId)}.json`);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Locate an existing journal file for an op: the current prefix first, then
|
|
1153
|
+
* the prefix the released v1 build wrote (a restore interrupted before the
|
|
1154
|
+
* upgrade must still be continuable / rollbackable).
|
|
1155
|
+
*/
|
|
1156
|
+
async findJournalFile(sessionId, opId) {
|
|
1157
|
+
const dir = this.sessionDir(sessionId);
|
|
1158
|
+
for (const name2 of [`${JOURNAL_PREFIX}${safeFileId(opId)}.json`, `${LEGACY_JOURNAL_PREFIX}${safeFileId(opId)}.json`]) {
|
|
1159
|
+
const file = join(dir, name2);
|
|
1160
|
+
try {
|
|
1161
|
+
await stat(file);
|
|
1162
|
+
return file;
|
|
1163
|
+
} catch {
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return void 0;
|
|
650
1168
|
}
|
|
651
1169
|
/**
|
|
652
1170
|
* Best-effort journal persist: journal IO failures are non-fatal by design —
|
|
653
1171
|
* a restore must never fail because its audit journal could not be written.
|
|
654
1172
|
* reconcileRestores() re-derives the true state from the disk, so a missing
|
|
655
1173
|
* or stale journal only loses the trail, never the recovery ability.
|
|
1174
|
+
*
|
|
1175
|
+
* A journal read back from a legacy file is rewritten IN PLACE (same file),
|
|
1176
|
+
* so a redo / rollback of a pre-upgrade op never leaves two divergent
|
|
1177
|
+
* versions of the same op on disk.
|
|
656
1178
|
*/
|
|
657
1179
|
async saveJournal(journal) {
|
|
658
1180
|
try {
|
|
659
|
-
|
|
1181
|
+
const file = journal.sourceFile ?? this.journalPath(journal.sessionId, journal.id);
|
|
1182
|
+
await writeJsonAtomic(file, journalToJson(journal, this.sessionDir(journal.sessionId)));
|
|
660
1183
|
} catch {
|
|
661
1184
|
}
|
|
662
1185
|
}
|
|
663
1186
|
/**
|
|
664
1187
|
* Journal one restore pass before mutating anything: capture the rescue
|
|
665
|
-
* (pre-restore) state of every planned path and persist
|
|
666
|
-
* atomically. Returns the in-memory journal; a
|
|
667
|
-
* a journal-less restore (non-fatal, see
|
|
1188
|
+
* (pre-restore) state of every planned path as a raw byte copy and persist
|
|
1189
|
+
* the intent (references only) atomically. Returns the in-memory journal; a
|
|
1190
|
+
* persist failure degrades to a journal-less restore (non-fatal, see
|
|
1191
|
+
* {@link saveJournal}).
|
|
668
1192
|
*/
|
|
669
1193
|
async beginRestore(sessionId, targetSeq, actions, probe) {
|
|
670
1194
|
const sessionDir = this.sessionDir(sessionId);
|
|
671
1195
|
try {
|
|
672
1196
|
await this.pruneTerminalJournals(sessionDir, await readdir(sessionDir));
|
|
673
1197
|
} catch (error) {
|
|
674
|
-
if (error
|
|
1198
|
+
if (!isEnoent(error)) throw error;
|
|
675
1199
|
}
|
|
1200
|
+
const id = `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1201
|
+
const rescueDir = join(sessionDir, RESCUE_DIR, safeFileId(id));
|
|
1202
|
+
let rescueDirReady = false;
|
|
676
1203
|
const journalActions = [];
|
|
677
|
-
for (const action of actions) {
|
|
1204
|
+
for (const [index, action] of actions.entries()) {
|
|
678
1205
|
let rescue = null;
|
|
679
1206
|
let rescueError;
|
|
1207
|
+
const dest = join(rescueDir, `${index}${SIDECAR_SUFFIX}`);
|
|
680
1208
|
try {
|
|
681
|
-
|
|
1209
|
+
if (!rescueDirReady) {
|
|
1210
|
+
await mkdir(rescueDir, { recursive: true });
|
|
1211
|
+
rescueDirReady = true;
|
|
1212
|
+
}
|
|
1213
|
+
const copied = await probe.copy(action.path, dest);
|
|
1214
|
+
if (copied.kind === "copied") rescue = { kind: "blob", path: dest };
|
|
1215
|
+
else if (copied.kind === "failed") rescueError = copied.message;
|
|
682
1216
|
} catch (error) {
|
|
683
1217
|
rescueError = error instanceof Error ? error.message : String(error);
|
|
684
1218
|
}
|
|
1219
|
+
const rescueMode = (await stat(action.path).catch(() => void 0))?.mode;
|
|
685
1220
|
const journalAction = {
|
|
686
1221
|
path: action.path,
|
|
687
1222
|
action: action.action,
|
|
688
1223
|
before: action.action === "restore" ? action.before : null,
|
|
689
1224
|
rescue,
|
|
1225
|
+
...action.action === "restore" && action.mode !== void 0 ? { mode: action.mode } : {},
|
|
1226
|
+
...action.parent !== void 0 ? { parent: action.parent } : {},
|
|
1227
|
+
...rescueMode !== void 0 ? { rescueMode: rescueMode & 4095 } : {},
|
|
690
1228
|
done: false
|
|
691
1229
|
};
|
|
692
1230
|
if (rescueError !== void 0) journalAction.rescueError = rescueError;
|
|
693
1231
|
journalActions.push(journalAction);
|
|
694
1232
|
}
|
|
695
1233
|
const journal = {
|
|
696
|
-
version:
|
|
697
|
-
id
|
|
1234
|
+
version: 2,
|
|
1235
|
+
id,
|
|
698
1236
|
sessionId,
|
|
699
1237
|
targetSeq,
|
|
700
1238
|
startedAt: Date.now(),
|
|
@@ -710,26 +1248,24 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
710
1248
|
* a journal would silently erase the interrupted restore's recovery record.
|
|
711
1249
|
*/
|
|
712
1250
|
async readJournal(sessionId, opId) {
|
|
713
|
-
const file = this.
|
|
714
|
-
|
|
715
|
-
try {
|
|
716
|
-
text = await readFile(file, "utf8");
|
|
717
|
-
} catch (error) {
|
|
718
|
-
if (error.code === "ENOENT") return void 0;
|
|
719
|
-
throw error;
|
|
720
|
-
}
|
|
1251
|
+
const file = await this.findJournalFile(sessionId, opId);
|
|
1252
|
+
if (file === void 0) return void 0;
|
|
721
1253
|
let parsed;
|
|
722
1254
|
try {
|
|
723
|
-
parsed = JSON.parse(
|
|
1255
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
724
1256
|
} catch (error) {
|
|
725
1257
|
throw new Error(`restore journal ${file} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
|
|
726
1258
|
}
|
|
727
1259
|
if (!isRestoreJournal(parsed)) throw new Error(`restore journal ${file} failed schema validation`);
|
|
728
|
-
|
|
1260
|
+
const journal = journalFromJson(parsed, this.sessionDir(sessionId));
|
|
1261
|
+
if (journal === void 0) throw new Error(`restore journal ${file} failed schema validation`);
|
|
1262
|
+
journal.sourceFile = file;
|
|
1263
|
+
return journal;
|
|
729
1264
|
}
|
|
730
1265
|
/**
|
|
731
|
-
* Every journal file of a session — valid ones plus corrupt
|
|
732
|
-
* error — so reconciliation can report corruption instead of
|
|
1266
|
+
* Every journal file of a session (both prefixes) — valid ones plus corrupt
|
|
1267
|
+
* ones with their error — so reconciliation can report corruption instead of
|
|
1268
|
+
* dropping it.
|
|
733
1269
|
*/
|
|
734
1270
|
async listJournals(sessionId) {
|
|
735
1271
|
const sessionDir = this.sessionDir(sessionId);
|
|
@@ -737,20 +1273,26 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
737
1273
|
try {
|
|
738
1274
|
names = await readdir(sessionDir);
|
|
739
1275
|
} catch (error) {
|
|
740
|
-
if (error
|
|
1276
|
+
if (isEnoent(error)) return { journals: [], corrupt: [] };
|
|
741
1277
|
throw error;
|
|
742
1278
|
}
|
|
743
1279
|
const journals = [];
|
|
744
1280
|
const corrupt = [];
|
|
745
1281
|
for (const name2 of names) {
|
|
746
|
-
if (!
|
|
1282
|
+
if (!isJournalName(name2)) continue;
|
|
747
1283
|
try {
|
|
748
1284
|
const parsed = JSON.parse(await readFile(join(sessionDir, name2), "utf8"));
|
|
749
1285
|
if (!isRestoreJournal(parsed)) {
|
|
750
1286
|
corrupt.push({ file: name2, message: "journal failed schema validation" });
|
|
751
1287
|
continue;
|
|
752
1288
|
}
|
|
753
|
-
|
|
1289
|
+
const journal = journalFromJson(parsed, sessionDir);
|
|
1290
|
+
if (journal === void 0) {
|
|
1291
|
+
corrupt.push({ file: name2, message: "journal references are invalid" });
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
journal.sourceFile = join(sessionDir, name2);
|
|
1295
|
+
journals.push(journal);
|
|
754
1296
|
} catch (error) {
|
|
755
1297
|
corrupt.push({ file: name2, message: error instanceof Error ? error.message : String(error) });
|
|
756
1298
|
}
|
|
@@ -760,29 +1302,54 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
760
1302
|
/**
|
|
761
1303
|
* Execute ONE fs mutation with exactly the pre-journal semantics: a delete
|
|
762
1304
|
* runs through the injected deleteFile (ENOENT tolerated — the file is
|
|
763
|
-
* already absent, i.e. the target state is reached), a restore
|
|
764
|
-
*
|
|
765
|
-
* should record it.
|
|
1305
|
+
* already absent, i.e. the target state is reached), a restore copies the
|
|
1306
|
+
* recorded bytes back over the file (creating the parent if needed).
|
|
766
1307
|
*
|
|
767
1308
|
* This is the only place the store writes restored content to the real FS,
|
|
768
|
-
* and it is deliberately
|
|
769
|
-
* service: the caller only ever hands it a path from `planRestore` — one
|
|
770
|
-
* session's own write-class tool call recorded and resolved (never a
|
|
1309
|
+
* and it is deliberately raw `copyFile`/`writeFile`/`unlink` rather than the
|
|
1310
|
+
* fs service: the caller only ever hands it a path from `planRestore` — one
|
|
1311
|
+
* the session's own write-class tool call recorded and resolved (never a
|
|
771
1312
|
* symlink/hard link) and only when it differs from the live disk. So no
|
|
772
1313
|
* arbitrary path, no model input, never automatic.
|
|
1314
|
+
*
|
|
1315
|
+
* The write is IN PLACE (no temp + rename): it keeps the file's inode and
|
|
1316
|
+
* thus its xattrs/ACL, and crash safety is provided by the journal plus disk
|
|
1317
|
+
* reconciliation instead (a half-written file simply does not match the
|
|
1318
|
+
* goal, so a redo rewrites it).
|
|
1319
|
+
*
|
|
1320
|
+
* Permissions are best-effort (ADR-9/R3): the mode is only ever applied as
|
|
1321
|
+
* part of a CONTENT restore (never as a reason to plan one), and a chmod
|
|
1322
|
+
* failure never fails the restore.
|
|
773
1323
|
*/
|
|
774
|
-
async applyActionToDisk(kind, path, content, deleteFile) {
|
|
1324
|
+
async applyActionToDisk(kind, path, content, deleteFile, opts) {
|
|
1325
|
+
if (!await parentStillMatches(path, opts?.parent)) return "skipped";
|
|
775
1326
|
if (kind === "delete") {
|
|
776
1327
|
try {
|
|
777
1328
|
await deleteFile(path);
|
|
778
1329
|
return "deleted";
|
|
779
1330
|
} catch (error) {
|
|
780
|
-
if (error
|
|
1331
|
+
if (!isEnoent(error)) throw error;
|
|
781
1332
|
return "enoent";
|
|
782
1333
|
}
|
|
783
1334
|
}
|
|
1335
|
+
if (content === null) throw new Error(`restore of ${path} has no recorded content`);
|
|
784
1336
|
await mkdir(dirname(path), { recursive: true });
|
|
785
|
-
await
|
|
1337
|
+
const current = (await stat(path).catch(() => void 0))?.mode;
|
|
1338
|
+
let widened = false;
|
|
1339
|
+
if (current !== void 0 && (current & 128) === 0) {
|
|
1340
|
+
await chmod(path, current | 128).catch(() => void 0);
|
|
1341
|
+
widened = true;
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
if (content.kind === "blob") await copyFile(content.path, path);
|
|
1345
|
+
else if (content.kind === "text") await writeFile(path, content.bytes);
|
|
1346
|
+
else await writeFile(path, Buffer.from(content.text, "utf8"));
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
if (widened && current !== void 0) await chmod(path, current).catch(() => void 0);
|
|
1349
|
+
throw error;
|
|
1350
|
+
}
|
|
1351
|
+
if (opts?.mode !== void 0) await chmod(path, opts.mode).catch(() => void 0);
|
|
1352
|
+
else if (widened && current !== void 0) await chmod(path, current).catch(() => void 0);
|
|
786
1353
|
return "restored";
|
|
787
1354
|
}
|
|
788
1355
|
/**
|
|
@@ -794,6 +1361,13 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
794
1361
|
* terminal state and not reported. A corrupt journal is reported
|
|
795
1362
|
* `recovery-required` — never silently dropped.
|
|
796
1363
|
*
|
|
1364
|
+
* Deliberately NOT gated on the session's `store` marker: a journal is fully
|
|
1365
|
+
* self-describing (`version` plus byte references), and refusing to finish an
|
|
1366
|
+
* interrupted op merely because the SESSION marker looks newer would strand a
|
|
1367
|
+
* half-restored workspace — the outcome the legacy-journal support exists to
|
|
1368
|
+
* prevent. A reference the newer build moved shows up as a per-file failure,
|
|
1369
|
+
* never as a silent write.
|
|
1370
|
+
*
|
|
797
1371
|
* @param sessionId - session whose journals to reconcile.
|
|
798
1372
|
* @param probe - current-disk state probe (defaults to the real FS).
|
|
799
1373
|
* @returns one report per non-terminal journal still needing attention.
|
|
@@ -803,7 +1377,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
803
1377
|
const reports = [];
|
|
804
1378
|
for (const bad of corrupt) {
|
|
805
1379
|
reports.push({
|
|
806
|
-
opId: bad.file
|
|
1380
|
+
opId: journalOpIdOf(bad.file),
|
|
807
1381
|
state: "recovery-required",
|
|
808
1382
|
journalState: "recovery-required",
|
|
809
1383
|
targetSeq: 0,
|
|
@@ -843,9 +1417,8 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
843
1417
|
}
|
|
844
1418
|
let reached;
|
|
845
1419
|
try {
|
|
846
|
-
const state = await probe.readText(action.path) ?? null;
|
|
847
1420
|
const goal = rollbackPhase ? action.rescue : action.action === "delete" ? null : action.before;
|
|
848
|
-
reached =
|
|
1421
|
+
reached = await probe.matches(goal, action.path) === true;
|
|
849
1422
|
} catch {
|
|
850
1423
|
reached = false;
|
|
851
1424
|
}
|
|
@@ -873,7 +1446,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
873
1446
|
};
|
|
874
1447
|
}
|
|
875
1448
|
/**
|
|
876
|
-
*
|
|
1449
|
+
* Continue (redo) an interrupted restore: finish the op by applying every action
|
|
877
1450
|
* whose disk state does not yet match its goal — the restore target for
|
|
878
1451
|
* `running` journals. Actions are decided by the REAL disk (the same "disk
|
|
879
1452
|
* is truth" rule as reconciliation), so a crash between an fs op and its
|
|
@@ -896,8 +1469,8 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
896
1469
|
opts?.crash?.("before-action", i);
|
|
897
1470
|
let reached;
|
|
898
1471
|
try {
|
|
899
|
-
const
|
|
900
|
-
reached =
|
|
1472
|
+
const goal = action.action === "delete" ? null : action.before;
|
|
1473
|
+
reached = await probe.matches(goal, action.path) === true;
|
|
901
1474
|
} catch {
|
|
902
1475
|
reached = false;
|
|
903
1476
|
}
|
|
@@ -909,7 +1482,22 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
909
1482
|
}
|
|
910
1483
|
let applied;
|
|
911
1484
|
try {
|
|
912
|
-
applied = await this.applyActionToDisk(
|
|
1485
|
+
applied = await this.applyActionToDisk(
|
|
1486
|
+
action.action,
|
|
1487
|
+
action.path,
|
|
1488
|
+
action.action === "restore" ? action.before : null,
|
|
1489
|
+
deleteFile,
|
|
1490
|
+
{
|
|
1491
|
+
...action.action === "restore" && action.mode !== void 0 ? { mode: action.mode } : {},
|
|
1492
|
+
...action.parent !== void 0 ? { parent: action.parent } : {}
|
|
1493
|
+
}
|
|
1494
|
+
);
|
|
1495
|
+
if (applied === "skipped") {
|
|
1496
|
+
action.failed = `parent directory moved or repointed: ${dirname(action.path)}`;
|
|
1497
|
+
await this.saveJournal(journal);
|
|
1498
|
+
failed.push({ path: action.path, message: action.failed });
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
913
1501
|
if (applied === "enoent") {
|
|
914
1502
|
action.done = true;
|
|
915
1503
|
await this.saveJournal(journal);
|
|
@@ -936,7 +1524,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
936
1524
|
return { restored, deleted, skipped: [], failed };
|
|
937
1525
|
}
|
|
938
1526
|
/**
|
|
939
|
-
*
|
|
1527
|
+
* Roll back an interrupted restore: undo every action whose disk
|
|
940
1528
|
* state does not match its rescue (pre-restore) record, returning the
|
|
941
1529
|
* workspace to the exact state it had before the restore started. Decided
|
|
942
1530
|
* by the REAL disk, so actions the crash left applied-but-unmarked are
|
|
@@ -973,8 +1561,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
973
1561
|
opts?.crash?.("before-action", i);
|
|
974
1562
|
let reached;
|
|
975
1563
|
try {
|
|
976
|
-
|
|
977
|
-
reached = state === action.rescue;
|
|
1564
|
+
reached = await probe.matches(action.rescue, action.path) === true;
|
|
978
1565
|
} catch {
|
|
979
1566
|
reached = false;
|
|
980
1567
|
}
|
|
@@ -985,7 +1572,24 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
985
1572
|
}
|
|
986
1573
|
let applied;
|
|
987
1574
|
try {
|
|
988
|
-
applied = await this.applyActionToDisk(
|
|
1575
|
+
applied = await this.applyActionToDisk(
|
|
1576
|
+
action.rescue === null ? "delete" : "restore",
|
|
1577
|
+
action.path,
|
|
1578
|
+
action.rescue,
|
|
1579
|
+
deleteFile,
|
|
1580
|
+
{
|
|
1581
|
+
...action.rescueMode !== void 0 ? { mode: action.rescueMode } : {},
|
|
1582
|
+
...action.parent !== void 0 ? { parent: action.parent } : {}
|
|
1583
|
+
}
|
|
1584
|
+
);
|
|
1585
|
+
if (applied === "skipped") {
|
|
1586
|
+
journal.rollbackError = `parent directory moved or repointed: ${action.path}`;
|
|
1587
|
+
journal.state = "recovery-required";
|
|
1588
|
+
await this.saveJournal(journal);
|
|
1589
|
+
failed.push({ path: action.path, message: journal.rollbackError });
|
|
1590
|
+
rollbackFailed = true;
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
989
1593
|
if (applied === "enoent") {
|
|
990
1594
|
action.done = false;
|
|
991
1595
|
await this.saveJournal(journal);
|
|
@@ -1038,60 +1642,147 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1038
1642
|
try {
|
|
1039
1643
|
names = await readdir(sessionDir);
|
|
1040
1644
|
} catch (error) {
|
|
1041
|
-
if (error
|
|
1645
|
+
if (isEnoent(error)) return;
|
|
1042
1646
|
throw error;
|
|
1043
1647
|
}
|
|
1044
1648
|
await this.pruneTerminalJournals(sessionDir, names);
|
|
1649
|
+
await this.prunePendingCaptures(join(sessionDir, PENDING_DIR));
|
|
1045
1650
|
const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
|
|
1046
1651
|
const excess = seqs.length - keep;
|
|
1047
1652
|
if (excess <= 0) return;
|
|
1048
1653
|
const doomed = new Set(seqs.slice(0, excess));
|
|
1654
|
+
for (const seq of await this.pinnedAnchors(sessionDir, names)) doomed.delete(seq);
|
|
1655
|
+
if (doomed.size === 0) return;
|
|
1049
1656
|
for (const seq of seqs.slice(excess)) {
|
|
1050
1657
|
const files = await readdir(this.anchorDir(sessionId, seq)).catch(() => []);
|
|
1051
1658
|
for (const file of files) {
|
|
1052
1659
|
if (!file.endsWith(".json")) continue;
|
|
1053
|
-
const
|
|
1660
|
+
const entryFile = join(this.anchorDir(sessionId, seq), file);
|
|
1661
|
+
let entry;
|
|
1662
|
+
try {
|
|
1663
|
+
entry = await readEntry(entryFile, seq);
|
|
1664
|
+
} catch {
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1054
1667
|
if (entry === void 0 || !isLinkEntry(entry)) continue;
|
|
1055
1668
|
if (!isSafeLinkRef(entry.ref)) continue;
|
|
1056
|
-
const
|
|
1057
|
-
const refAnchor = slash === -1 ? Number.NaN : Number(entry.ref.slice(0, slash));
|
|
1669
|
+
const refAnchor = refAnchorOf(entry.ref);
|
|
1058
1670
|
if (!Number.isSafeInteger(refAnchor) || !doomed.has(refAnchor)) continue;
|
|
1059
|
-
let
|
|
1671
|
+
let source;
|
|
1060
1672
|
try {
|
|
1061
|
-
|
|
1062
|
-
} catch {
|
|
1673
|
+
source = await this.resolveBefore(sessionId, entry);
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
if (error instanceof UnknownStoreVersionError) throw error;
|
|
1063
1676
|
continue;
|
|
1064
1677
|
}
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1678
|
+
const pin = entry.parent !== void 0 ? { parent: entry.parent } : {};
|
|
1679
|
+
let real;
|
|
1680
|
+
if (source === null) {
|
|
1681
|
+
real = {
|
|
1682
|
+
callId: entry.callId,
|
|
1683
|
+
anchorSeq: entry.anchorSeq,
|
|
1684
|
+
path: entry.path,
|
|
1685
|
+
before: null,
|
|
1686
|
+
size: 0,
|
|
1687
|
+
...pin,
|
|
1688
|
+
time: entry.time
|
|
1689
|
+
};
|
|
1690
|
+
} else {
|
|
1691
|
+
const dest = join(dirname(entryFile), sidecarName(basename(entryFile)));
|
|
1692
|
+
await this.writeSidecar(dest, source);
|
|
1693
|
+
const st = await stat(dest);
|
|
1694
|
+
real = {
|
|
1695
|
+
callId: entry.callId,
|
|
1696
|
+
anchorSeq: entry.anchorSeq,
|
|
1697
|
+
path: entry.path,
|
|
1698
|
+
before: { kind: "blob", path: dest },
|
|
1699
|
+
size: st.size,
|
|
1700
|
+
...source.kind === "lossyText" ? { lossy: true } : {},
|
|
1701
|
+
...pin,
|
|
1702
|
+
time: entry.time
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
await writeJsonAtomic(entryFile, entryToJson(real), () => opts?.crash?.("after-temp-write"));
|
|
1073
1706
|
}
|
|
1074
1707
|
}
|
|
1075
1708
|
for (const seq of doomed) {
|
|
1076
1709
|
await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
|
|
1077
1710
|
}
|
|
1711
|
+
this.seededSessions.delete(sessionId);
|
|
1712
|
+
for (const key of [...this.lastEntry.keys()]) {
|
|
1713
|
+
if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Collect staged captures that were never committed and are older than
|
|
1718
|
+
* {@link PENDING_MAX_AGE_MS}: a crash between `tools/execute` and
|
|
1719
|
+
* `tools/post-execute` can leak one, and the process that would have
|
|
1720
|
+
* unlinked it is gone.
|
|
1721
|
+
*/
|
|
1722
|
+
async prunePendingCaptures(pendingDir) {
|
|
1723
|
+
let names;
|
|
1724
|
+
try {
|
|
1725
|
+
names = await readdir(pendingDir);
|
|
1726
|
+
} catch {
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const cutoff = Date.now() - PENDING_MAX_AGE_MS;
|
|
1730
|
+
for (const name2 of names) {
|
|
1731
|
+
const file = join(pendingDir, name2);
|
|
1732
|
+
const st = await lstat(file).catch(() => void 0);
|
|
1733
|
+
if (st === void 0 || !st.isFile() || st.mtimeMs >= cutoff) continue;
|
|
1734
|
+
await rm(file, { force: true });
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Anchor groups a NON-TERMINAL journal still depends on — the groups holding
|
|
1739
|
+
* the sidecars its actions restore from. `prune` must not evict them while
|
|
1740
|
+
* the op can still be finished. Rescue copies live under `rescue/`, never in
|
|
1741
|
+
* an anchor group, so only `before` references matter; a group is pinned only
|
|
1742
|
+
* for a well-formed, safe reference (a corrupt journal pins nothing).
|
|
1743
|
+
*/
|
|
1744
|
+
async pinnedAnchors(sessionDir, names) {
|
|
1745
|
+
const pinned = /* @__PURE__ */ new Set();
|
|
1746
|
+
for (const name2 of names) {
|
|
1747
|
+
if (!isJournalName(name2)) continue;
|
|
1748
|
+
let parsed;
|
|
1749
|
+
try {
|
|
1750
|
+
parsed = JSON.parse(await readFile(join(sessionDir, name2), "utf8"));
|
|
1751
|
+
} catch {
|
|
1752
|
+
continue;
|
|
1753
|
+
}
|
|
1754
|
+
if (!isRestoreJournal(parsed)) continue;
|
|
1755
|
+
const journal = journalFromJson(parsed, sessionDir);
|
|
1756
|
+
if (journal === void 0) continue;
|
|
1757
|
+
if (journal.state === "completed" || journal.state === "rolled-back") continue;
|
|
1758
|
+
for (const action of journal.actions) {
|
|
1759
|
+
const source = action.before;
|
|
1760
|
+
if (source === null || source.kind !== "blob") continue;
|
|
1761
|
+
const ref = relative(sessionDir, source.path);
|
|
1762
|
+
if (!isSafeBackupRef(ref)) continue;
|
|
1763
|
+
const anchor = refAnchorOf(ref);
|
|
1764
|
+
if (Number.isSafeInteger(anchor)) pinned.add(anchor);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return pinned;
|
|
1078
1768
|
}
|
|
1079
1769
|
/**
|
|
1080
1770
|
* Recycle terminal restore journals (`completed` / `rolled-back`): once an
|
|
1081
|
-
* op finished, its journal
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1771
|
+
* op finished, its journal and its rescue bytes are dead weight that would
|
|
1772
|
+
* otherwise accumulate without bound (one journal per both-mode rewind).
|
|
1773
|
+
* Non-terminal journals (crashed ops awaiting reconcile / continue /
|
|
1774
|
+
* rollback) and unclassifiable (corrupt) ones are ALWAYS kept — a recovery
|
|
1775
|
+
* record that cannot be classified is never destroyed.
|
|
1086
1776
|
*/
|
|
1087
1777
|
async pruneTerminalJournals(sessionDir, names) {
|
|
1088
1778
|
for (const name2 of names) {
|
|
1089
|
-
if (!
|
|
1779
|
+
if (!isJournalName(name2)) continue;
|
|
1090
1780
|
const file = join(sessionDir, name2);
|
|
1091
1781
|
try {
|
|
1092
1782
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1093
1783
|
if (parsed.state === "completed" || parsed.state === "rolled-back") {
|
|
1094
1784
|
await rm(file, { force: true });
|
|
1785
|
+
await rm(join(sessionDir, RESCUE_DIR, safeFileId(journalOpIdOf(name2))), { recursive: true, force: true });
|
|
1095
1786
|
}
|
|
1096
1787
|
} catch {
|
|
1097
1788
|
}
|
|
@@ -1103,7 +1794,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1103
1794
|
await stat(path);
|
|
1104
1795
|
return true;
|
|
1105
1796
|
} catch (error) {
|
|
1106
|
-
if (error
|
|
1797
|
+
if (isEnoent(error)) return false;
|
|
1107
1798
|
throw error;
|
|
1108
1799
|
}
|
|
1109
1800
|
}
|
|
@@ -1124,7 +1815,9 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1124
1815
|
* - a non-positive `maxAgeDays` throws instead of degenerating into a
|
|
1125
1816
|
* mass-destructive `cutoff` in the far future;
|
|
1126
1817
|
* - the walk uses `lstat` (no symlink following) and skips dot-prefixed
|
|
1127
|
-
* temp left overs
|
|
1818
|
+
* temp left overs — except the real `.pending/` area, whose staged bytes
|
|
1819
|
+
* are content and whose freshness is activity — so measurement stays
|
|
1820
|
+
* inside the store root.
|
|
1128
1821
|
*
|
|
1129
1822
|
* `dryRun` computes and reports exactly what would be removed without
|
|
1130
1823
|
* deleting anything — the `/snapshot-auto-cleanup run` preview.
|
|
@@ -1176,6 +1869,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1176
1869
|
remainingBytes += size;
|
|
1177
1870
|
}
|
|
1178
1871
|
}
|
|
1872
|
+
if (!dryRun) await this.forgetMissingSessions();
|
|
1179
1873
|
return report();
|
|
1180
1874
|
}
|
|
1181
1875
|
/**
|
|
@@ -1193,11 +1887,16 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1193
1887
|
}
|
|
1194
1888
|
/**
|
|
1195
1889
|
* Summarize a session's on-disk footprint for a clear dry-run: anchor-group
|
|
1196
|
-
* count, committed checkpoint-entry count
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1890
|
+
* count, committed checkpoint-entry count (one per `.json` in an anchor
|
|
1891
|
+
* group), restore-journal count (both journal prefixes), and the total bytes
|
|
1892
|
+
* the session dir occupies — entry JSONs, raw byte sidecars, `rescue/**` and
|
|
1893
|
+
* the staged `.pending/**` copies alike, so the number matches what a `du` of
|
|
1894
|
+
* that directory reports.
|
|
1895
|
+
*
|
|
1896
|
+
* Walks with `lstat` (never follows a symlink, so a hostile symlink cannot
|
|
1897
|
+
* escape the store root or inflate the measurement) and skips dot-prefixed
|
|
1898
|
+
* temp leftovers (the one exception is `.pending/`, whose staged bytes are
|
|
1899
|
+
* real store content).
|
|
1201
1900
|
*/
|
|
1202
1901
|
async sessionStats(sessionId) {
|
|
1203
1902
|
const sessionDir = this.sessionDir(sessionId);
|
|
@@ -1205,7 +1904,7 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1205
1904
|
try {
|
|
1206
1905
|
names = await readdir(sessionDir);
|
|
1207
1906
|
} catch (error) {
|
|
1208
|
-
if (error
|
|
1907
|
+
if (isEnoent(error)) return { anchorGroups: 0, entries: 0, journals: 0, bytes: 0 };
|
|
1209
1908
|
throw error;
|
|
1210
1909
|
}
|
|
1211
1910
|
let anchorGroups = 0;
|
|
@@ -1213,33 +1912,26 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1213
1912
|
let journals = 0;
|
|
1214
1913
|
let bytes = 0;
|
|
1215
1914
|
for (const name2 of names) {
|
|
1216
|
-
if (name2.startsWith(".")) continue;
|
|
1915
|
+
if (name2.startsWith(".") && name2 !== PENDING_DIR) continue;
|
|
1217
1916
|
const full = join(sessionDir, name2);
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
st = await lstat(full);
|
|
1221
|
-
} catch {
|
|
1222
|
-
continue;
|
|
1223
|
-
}
|
|
1917
|
+
const st = await lstat(full).catch(() => void 0);
|
|
1918
|
+
if (st === void 0) continue;
|
|
1224
1919
|
if (st.isDirectory()) {
|
|
1225
|
-
if (
|
|
1226
|
-
|
|
1227
|
-
let files;
|
|
1228
|
-
try {
|
|
1229
|
-
files = await readdir(full);
|
|
1230
|
-
} catch {
|
|
1920
|
+
if (name2 === PENDING_DIR || name2 === RESCUE_DIR) {
|
|
1921
|
+
bytes += await dirBytes(full);
|
|
1231
1922
|
continue;
|
|
1232
1923
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
if (fileSt !== void 0) bytes += fileSt.size;
|
|
1924
|
+
if (!Number.isSafeInteger(Number(name2))) continue;
|
|
1925
|
+
anchorGroups++;
|
|
1926
|
+
for (const file of await readdir(full).catch(() => [])) {
|
|
1927
|
+
if (file.endsWith(".json")) entries++;
|
|
1238
1928
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
bytes += st.size;
|
|
1929
|
+
bytes += await dirBytes(full);
|
|
1930
|
+
continue;
|
|
1242
1931
|
}
|
|
1932
|
+
if (!st.isFile()) continue;
|
|
1933
|
+
if (isJournalName(name2)) journals++;
|
|
1934
|
+
bytes += st.size;
|
|
1243
1935
|
}
|
|
1244
1936
|
return { anchorGroups, entries, journals, bytes };
|
|
1245
1937
|
}
|
|
@@ -1278,13 +1970,10 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1278
1970
|
const dryRun = opts?.dryRun ?? false;
|
|
1279
1971
|
const stats = await this.sessionStats(sessionId);
|
|
1280
1972
|
if (!dryRun) {
|
|
1281
|
-
if (stats.anchorGroups > 0 || stats.journals > 0) {
|
|
1973
|
+
if (stats.anchorGroups > 0 || stats.journals > 0 || stats.bytes > 0) {
|
|
1282
1974
|
await rm(this.sessionDir(sessionId), { recursive: true, force: true });
|
|
1283
1975
|
}
|
|
1284
|
-
this.
|
|
1285
|
-
for (const key of this.lastEntry.keys()) {
|
|
1286
|
-
if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
|
|
1287
|
-
}
|
|
1976
|
+
this.forgetSession(sessionId);
|
|
1288
1977
|
}
|
|
1289
1978
|
return { sessionId, ...stats, dryRun };
|
|
1290
1979
|
}
|
|
@@ -1328,6 +2017,48 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1328
2017
|
setFormatVersion(sessionVersion) {
|
|
1329
2018
|
this.formatVersion = sessionVersion;
|
|
1330
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Read the plugin's STORE-format marker for a session, or null when there is
|
|
2022
|
+
* none (a released-v1 dir, or a session that never recorded a snapshot). The
|
|
2023
|
+
* marker is a quick session-level signal; every entry and journal is also
|
|
2024
|
+
* self-describing (`store` / `version`), so a missing marker never changes
|
|
2025
|
+
* how an entry is read.
|
|
2026
|
+
*/
|
|
2027
|
+
async readStoreVersion(sessionId) {
|
|
2028
|
+
try {
|
|
2029
|
+
const raw = await readFile(join(this.sessionDir(sessionId), _SnapshotStore.STORE_FILE), "utf8");
|
|
2030
|
+
const parsed = Number(raw.trim());
|
|
2031
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2032
|
+
} catch {
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Stamp the store-format marker (atomically, like `format`). Written
|
|
2038
|
+
* alongside every byte-format entry, so a session that only ever holds the
|
|
2039
|
+
* released string format keeps no marker and is read as v1.
|
|
2040
|
+
*/
|
|
2041
|
+
async markStoreVersion(sessionId, storeVersion) {
|
|
2042
|
+
const dir = this.sessionDir(sessionId);
|
|
2043
|
+
await mkdir(dir, { recursive: true });
|
|
2044
|
+
const file = join(dir, _SnapshotStore.STORE_FILE);
|
|
2045
|
+
const tmp = `${file}.tmp`;
|
|
2046
|
+
await writeFile(tmp, `${storeVersion}`, "utf8");
|
|
2047
|
+
await rename(tmp, file);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Refuse to plan against (or write into) a session whose store format is
|
|
2051
|
+
* NEWER than this build understands (ADR-10): the caller reports it and
|
|
2052
|
+
* changes nothing — no partial restore, no clear, no v2 entry written into a
|
|
2053
|
+
* v3 store. Checked before any entry is read, so the marker alone is enough
|
|
2054
|
+
* to fail closed.
|
|
2055
|
+
*/
|
|
2056
|
+
async assertKnownStoreVersion(sessionId) {
|
|
2057
|
+
const version = await this.readStoreVersion(sessionId);
|
|
2058
|
+
if (version !== null && version > CURRENT_STORE_VERSION) {
|
|
2059
|
+
throw new UnknownStoreVersionError(version, join(this.sessionDir(sessionId), _SnapshotStore.STORE_FILE));
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
1331
2062
|
/**
|
|
1332
2063
|
* Session-format-version guard: clear a session's snapshot dir when the
|
|
1333
2064
|
* format its snapshots were anchored under differs from the current session
|
|
@@ -1359,25 +2090,37 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1359
2090
|
}
|
|
1360
2091
|
};
|
|
1361
2092
|
function hashPath(path) {
|
|
1362
|
-
return
|
|
2093
|
+
return shortHash(path);
|
|
1363
2094
|
}
|
|
1364
2095
|
async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = defaultProbe) {
|
|
2096
|
+
try {
|
|
2097
|
+
await store.assertKnownStoreVersion(sessionId);
|
|
2098
|
+
} catch {
|
|
2099
|
+
return 0;
|
|
2100
|
+
}
|
|
1365
2101
|
let recorded = 0;
|
|
1366
2102
|
for (const path of tracked) {
|
|
1367
2103
|
try {
|
|
1368
2104
|
if (await probe.isLink(path)) continue;
|
|
1369
|
-
const current = await probe.readText(path);
|
|
1370
|
-
const state = current ?? null;
|
|
1371
2105
|
const last = await store.lastKnownContent(sessionId, path);
|
|
1372
|
-
if (last
|
|
1373
|
-
await
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
2106
|
+
if (last !== void 0 && last !== null && last.kind !== "lossyText") {
|
|
2107
|
+
const same = await probe.matches(last, path);
|
|
2108
|
+
if (same === void 0 || same) continue;
|
|
2109
|
+
}
|
|
2110
|
+
const callId = `recheck-${anchorSeq}-${hashPath(path)}`;
|
|
2111
|
+
const staged = await store.stageCapture(sessionId, callId);
|
|
2112
|
+
const copied = await probe.copy(path, staged);
|
|
2113
|
+
if (copied.kind === "failed") {
|
|
2114
|
+
await rm(staged, { force: true });
|
|
2115
|
+
continue;
|
|
1380
2116
|
}
|
|
2117
|
+
await store.recordBackup(
|
|
2118
|
+
sessionId,
|
|
2119
|
+
{ callId, anchorSeq, path },
|
|
2120
|
+
copied.kind === "absent" ? null : { file: staged, size: copied.size },
|
|
2121
|
+
{ dedup: false }
|
|
2122
|
+
);
|
|
2123
|
+
recorded++;
|
|
1381
2124
|
} catch {
|
|
1382
2125
|
}
|
|
1383
2126
|
}
|
|
@@ -1518,6 +2261,13 @@ function usage() {
|
|
|
1518
2261
|
t("usage.blocked")
|
|
1519
2262
|
].join("\n");
|
|
1520
2263
|
}
|
|
2264
|
+
async function discardCapture(capture) {
|
|
2265
|
+
if (capture === void 0 || capture.backup === null) return;
|
|
2266
|
+
try {
|
|
2267
|
+
await rm2(capture.backup.file, { force: true });
|
|
2268
|
+
} catch {
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
1521
2271
|
function mutationPathOf(exec) {
|
|
1522
2272
|
const args = exec.arguments;
|
|
1523
2273
|
if (exec.name === "write" || exec.name === "edit") {
|
|
@@ -1549,43 +2299,72 @@ async function resolveTarget(fs, path, cwd, signal) {
|
|
|
1549
2299
|
return void 0;
|
|
1550
2300
|
}
|
|
1551
2301
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
} catch (error) {
|
|
1556
|
-
const code = error?.code;
|
|
1557
|
-
if (code === "ENOENT" || code === "FS_NOT_FOUND") return void 0;
|
|
1558
|
-
throw error;
|
|
1559
|
-
}
|
|
2302
|
+
function isNotFoundError(error) {
|
|
2303
|
+
const code = error?.code;
|
|
2304
|
+
return code === "ENOENT" || code === "FS_NOT_FOUND";
|
|
1560
2305
|
}
|
|
1561
|
-
async function captureBefore(fs, exec, pending) {
|
|
2306
|
+
async function captureBefore(fs, store, exec, pending) {
|
|
1562
2307
|
if (!TRACKED_TOOLS.has(exec.name)) return;
|
|
1563
|
-
const
|
|
2308
|
+
const session = exec.agent?.session;
|
|
2309
|
+
const header = session?.header;
|
|
1564
2310
|
if (header !== void 0 && (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0)) return;
|
|
1565
2311
|
const path = mutationPathOf(exec);
|
|
1566
2312
|
if (path === void 0) return;
|
|
1567
2313
|
const cwd = execSessionCwd(exec, path);
|
|
1568
2314
|
const target = await resolveTarget(fs, path, cwd, exec.signal);
|
|
1569
2315
|
if (target === void 0) return;
|
|
1570
|
-
const
|
|
1571
|
-
|
|
2316
|
+
const info = await fs.stat(target, exec.signal).catch((error) => {
|
|
2317
|
+
if (isNotFoundError(error)) return void 0;
|
|
2318
|
+
throw error;
|
|
2319
|
+
});
|
|
2320
|
+
if (session === void 0) return;
|
|
2321
|
+
if (info !== void 0 && info.type !== "file") return;
|
|
2322
|
+
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
2323
|
+
if (info === void 0) {
|
|
2324
|
+
pending.set(key, { path: target.displayPath, backup: null });
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const staged = await store.stageCapture(session.id, key);
|
|
2328
|
+
let backup;
|
|
2329
|
+
try {
|
|
2330
|
+
await copyFile2(target.displayPath, staged);
|
|
2331
|
+
const st = await stat2(staged);
|
|
2332
|
+
const source = await stat2(target.displayPath).catch(() => void 0);
|
|
2333
|
+
backup = {
|
|
2334
|
+
file: staged,
|
|
2335
|
+
size: st.size,
|
|
2336
|
+
...source !== void 0 ? { mode: source.mode & 4095 } : {}
|
|
2337
|
+
};
|
|
2338
|
+
} catch (error) {
|
|
2339
|
+
await rm2(staged, { force: true });
|
|
2340
|
+
throw error;
|
|
2341
|
+
}
|
|
2342
|
+
pending.set(key, { path: target.displayPath, backup });
|
|
1572
2343
|
}
|
|
1573
2344
|
async function commitEntry(store, pending, anchorCache, trackedBySession, exec, result) {
|
|
1574
2345
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
1575
2346
|
const capture = pending.get(key);
|
|
1576
2347
|
if (capture === void 0) return;
|
|
1577
2348
|
pending.delete(key);
|
|
1578
|
-
if (result.isError)
|
|
2349
|
+
if (result.isError) {
|
|
2350
|
+
await discardCapture(capture);
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
1579
2353
|
const agent = exec.agent;
|
|
1580
|
-
if (agent === void 0)
|
|
2354
|
+
if (agent === void 0) {
|
|
2355
|
+
await discardCapture(capture);
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
1581
2358
|
const anchorSeq = anchorSeqOf(agent.session, anchorCache);
|
|
1582
|
-
if (anchorSeq === void 0)
|
|
1583
|
-
|
|
2359
|
+
if (anchorSeq === void 0) {
|
|
2360
|
+
await discardCapture(capture);
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
await store.recordBackup(agent.session.id, {
|
|
1584
2364
|
callId: exec.callId,
|
|
1585
2365
|
anchorSeq,
|
|
1586
|
-
path: capture.path
|
|
1587
|
-
|
|
1588
|
-
});
|
|
2366
|
+
path: capture.path
|
|
2367
|
+
}, capture.backup);
|
|
1589
2368
|
let tracked = trackedBySession.get(agent.session.id);
|
|
1590
2369
|
if (tracked === void 0) {
|
|
1591
2370
|
tracked = /* @__PURE__ */ new Set();
|
|
@@ -1724,14 +2503,23 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
|
|
|
1724
2503
|
}
|
|
1725
2504
|
let restore = "";
|
|
1726
2505
|
if (mode === "both") {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2506
|
+
let outcome;
|
|
2507
|
+
try {
|
|
2508
|
+
outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
2509
|
+
} catch (error) {
|
|
2510
|
+
if (!(error instanceof UnknownStoreVersionError)) throw error;
|
|
2511
|
+
const version = await store.readStoreVersion(agent.session.id);
|
|
2512
|
+
restore = `\uFF1B${t("storeUnsupported", { version: version ?? error.version })}`;
|
|
2513
|
+
}
|
|
2514
|
+
if (outcome !== void 0) {
|
|
2515
|
+
await syncRestoreObservations(ctx, fs, agent, outcome);
|
|
2516
|
+
const parts = [];
|
|
2517
|
+
if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
|
|
2518
|
+
if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
|
|
2519
|
+
if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
|
|
2520
|
+
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
|
|
2521
|
+
restore += renderFailures(outcome.failed);
|
|
2522
|
+
}
|
|
1735
2523
|
}
|
|
1736
2524
|
return {
|
|
1737
2525
|
kind: "success",
|
|
@@ -1774,7 +2562,13 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
|
1774
2562
|
} catch (error) {
|
|
1775
2563
|
return rewindErrorResult(error);
|
|
1776
2564
|
}
|
|
1777
|
-
const impacts = await store.impactsAfter(session.id, plan.targetSeq)
|
|
2565
|
+
const impacts = await store.impactsAfter(session.id, plan.targetSeq).catch((error) => {
|
|
2566
|
+
if (error instanceof UnknownStoreVersionError) return void 0;
|
|
2567
|
+
throw error;
|
|
2568
|
+
});
|
|
2569
|
+
if (impacts === void 0) {
|
|
2570
|
+
return { kind: "error", text: t("storeUnsupported", { version: await store.readStoreVersion(session.id) ?? 0 }) };
|
|
2571
|
+
}
|
|
1778
2572
|
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
1779
2573
|
}
|
|
1780
2574
|
if (parts[0] === "__candidates") {
|
|
@@ -1961,6 +2755,12 @@ function apply(ctx, config) {
|
|
|
1961
2755
|
void (async () => {
|
|
1962
2756
|
try {
|
|
1963
2757
|
store.setFormatVersion(session.header.version);
|
|
2758
|
+
try {
|
|
2759
|
+
await store.assertKnownStoreVersion(session.id);
|
|
2760
|
+
} catch (error) {
|
|
2761
|
+
ctx.logger.warn(`[dsh-rewind] file restore disabled for ${session.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
1964
2764
|
const result = await store.reconcileFormatVersion(session.id, session.header.version);
|
|
1965
2765
|
if (result.cleared) {
|
|
1966
2766
|
ctx.logger.warn(`[dsh-rewind] cleared snapshots for ${session.id}: session format changed (v${session.header.version})`);
|
|
@@ -1995,7 +2795,7 @@ function apply(ctx, config) {
|
|
|
1995
2795
|
fsService = fs;
|
|
1996
2796
|
scope.on("tools/execute", async (exec, next) => {
|
|
1997
2797
|
try {
|
|
1998
|
-
await captureBefore(fs, exec, pending);
|
|
2798
|
+
await captureBefore(fs, store, exec, pending);
|
|
1999
2799
|
} catch (error) {
|
|
2000
2800
|
ctx.logger.warn(`[dsh-rewind] before-capture failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2001
2801
|
}
|
|
@@ -2011,7 +2811,10 @@ function apply(ctx, config) {
|
|
|
2011
2811
|
return next();
|
|
2012
2812
|
});
|
|
2013
2813
|
scope.on("tools/result", (exec) => {
|
|
2014
|
-
|
|
2814
|
+
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
2815
|
+
const capture = pending.get(key);
|
|
2816
|
+
pending.delete(key);
|
|
2817
|
+
void discardCapture(capture);
|
|
2015
2818
|
return void 0;
|
|
2016
2819
|
});
|
|
2017
2820
|
});
|