dsh-rewind-plugin 0.4.1 → 0.5.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 +72 -0
- package/README.en.md +174 -0
- package/README.md +105 -101
- package/SECURITY.md +154 -0
- package/docs/README.md +29 -0
- package/docs/architecture.md +128 -0
- package/docs/compat/tracking-boundary.md +103 -0
- package/docs/compat/tracking-boundary.zh.md +55 -0
- package/docs/compat/troubleshooting.md +41 -0
- package/docs/{troubleshooting.zh.md → compat/troubleshooting.zh.md} +15 -0
- package/docs/format.md +150 -0
- package/docs/harness-reference.md +7 -3
- package/docs/release/release.md +69 -0
- package/docs/{release.md → release/release.zh.md} +6 -52
- package/docs/snapshot-auto-cleanup.md +71 -0
- package/docs/snapshot-auto-cleanup.zh.md +42 -0
- package/lib/client.js +1 -1
- package/lib/index.js +982 -50
- package/lib/types/client/candidates.d.ts +5 -4
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/locales.d.ts +17 -0
- package/lib/types/rewind.d.ts +3 -2
- package/lib/types/snapshot-cleanup.d.ts +125 -0
- package/lib/types/snapshot.d.ts +374 -14
- package/package.json +6 -3
- package/README.zh.md +0 -159
- package/docs/troubleshooting.md +0 -44
- /package/docs/{compat-audit.md → compat/audit.md} +0 -0
- /package/docs/{client-contract.md → contract/client-contract.md} +0 -0
- /package/docs/{client-contract.zh.md → contract/client-contract.zh.md} +0 -0
package/lib/index.js
CHANGED
|
@@ -30,7 +30,24 @@ var en = {
|
|
|
30
30
|
"success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
|
|
31
31
|
"noUserMessages": "This session has no rewindable user messages yet.",
|
|
32
32
|
"chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
|
|
33
|
-
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
|
|
33
|
+
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
|
|
34
|
+
"cleanup.description": "Manage automatic cleanup of session snapshot backups",
|
|
35
|
+
"cleanup.inputHint": "on | off | max-age <days> | run [--apply] | status",
|
|
36
|
+
"cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s). Config: {path} ({present}).",
|
|
37
|
+
"cleanup.enabled": "enabled",
|
|
38
|
+
"cleanup.disabled": "disabled",
|
|
39
|
+
"cleanup.present": "file present",
|
|
40
|
+
"cleanup.absent": "no file \u2014 defaults",
|
|
41
|
+
"cleanup.onOk": "Auto-cleanup enabled.",
|
|
42
|
+
"cleanup.offOk": "Auto-cleanup disabled \u2014 all snapshots kept.",
|
|
43
|
+
"cleanup.maxAgeOk": "Auto-cleanup max age set to {days} day(s).",
|
|
44
|
+
"cleanup.cfgInvalid": "Snapshot cleanup config invalid: {detail}. Using the safe default (disabled).",
|
|
45
|
+
"cleanup.saveFailed": "Could not save cleanup config: {detail}.",
|
|
46
|
+
"cleanup.runDry": "Dry-run: would remove {deleted} session snapshot backup(s). Re-run with --apply to delete.",
|
|
47
|
+
"cleanup.runApply": "Removed {deleted} session snapshot backup(s), freeing {freed} bytes; {kept} kept, {remaining} bytes remain.",
|
|
48
|
+
"cleanup.runFailed": "Cleanup failed: {detail}.",
|
|
49
|
+
"cleanup.skipped": "({skipped} active session(s) skipped.)",
|
|
50
|
+
"cleanup.usage": "Usage:\n /snapshot-auto-cleanup show status\n /snapshot-auto-cleanup on|off enable/disable auto-cleanup\n /snapshot-auto-cleanup max-age <days> set the idle cutoff\n /snapshot-auto-cleanup run [--apply] dry-run, or execute with --apply"
|
|
34
51
|
};
|
|
35
52
|
var zh = {
|
|
36
53
|
"usage.title": "\u7528\u6CD5\uFF1A",
|
|
@@ -58,7 +75,24 @@ var zh = {
|
|
|
58
75
|
"success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
|
|
59
76
|
"noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
60
77
|
"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",
|
|
61
|
-
"command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09"
|
|
78
|
+
"command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
|
|
79
|
+
"cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
|
|
80
|
+
"cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] | status",
|
|
81
|
+
"cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002\u914D\u7F6E\uFF1A{path}\uFF08{present}\uFF09\u3002",
|
|
82
|
+
"cleanup.enabled": "\u5DF2\u5F00\u542F",
|
|
83
|
+
"cleanup.disabled": "\u5DF2\u5173\u95ED",
|
|
84
|
+
"cleanup.present": "\u5B58\u5728\u914D\u7F6E\u6587\u4EF6",
|
|
85
|
+
"cleanup.absent": "\u65E0\u6587\u4EF6\u2014\u2014\u4F7F\u7528\u9ED8\u8BA4\u503C",
|
|
86
|
+
"cleanup.onOk": "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406\u3002",
|
|
87
|
+
"cleanup.offOk": "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406\u2014\u2014\u4FDD\u7559\u5168\u90E8\u5FEB\u7167\u3002",
|
|
88
|
+
"cleanup.maxAgeOk": "\u5DF2\u5C06\u81EA\u52A8\u6E05\u7406\u7684\u6700\u5927\u4FDD\u7559\u5929\u6570\u8BBE\u4E3A {days} \u5929\u3002",
|
|
89
|
+
"cleanup.cfgInvalid": "\u5FEB\u7167\u6E05\u7406\u914D\u7F6E\u65E0\u6548\uFF1A{detail}\u3002\u5DF2\u4F7F\u7528\u5B89\u5168\u9ED8\u8BA4\u503C\uFF08\u5173\u95ED\uFF09\u3002",
|
|
90
|
+
"cleanup.saveFailed": "\u65E0\u6CD5\u4FDD\u5B58\u6E05\u7406\u914D\u7F6E\uFF1A{detail}\u3002",
|
|
91
|
+
"cleanup.runDry": "\u9884\u6F14\uFF1A\u5C06\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\u3002",
|
|
92
|
+
"cleanup.runApply": "\u5DF2\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\uFF0C\u91CA\u653E {freed} \u5B57\u8282\uFF1B\u4FDD\u7559 {kept} \u4E2A\uFF0C\u5269\u4F59 {remaining} \u5B57\u8282\u3002",
|
|
93
|
+
"cleanup.runFailed": "\u6E05\u7406\u5931\u8D25\uFF1A{detail}\u3002",
|
|
94
|
+
"cleanup.skipped": "\uFF08\u8DF3\u8FC7\u4E86 {skipped} \u4E2A\u6D3B\u52A8\u4F1A\u8BDD\u3002\uFF09",
|
|
95
|
+
"cleanup.usage": "\u7528\u6CD5\uFF1A\n /snapshot-auto-cleanup \u67E5\u770B\u72B6\u6001\n /snapshot-auto-cleanup on|off \u5F00\u542F/\u5173\u95ED\u81EA\u52A8\u6E05\u7406\n /snapshot-auto-cleanup max-age <\u5929\u6570> \u8BBE\u7F6E\u5931\u6D3B\u9608\u503C\uFF08\u5929\uFF09\n /snapshot-auto-cleanup run [--apply] \u9884\u6F14\uFF0C\u6216\u52A0 --apply \u6267\u884C"
|
|
62
96
|
};
|
|
63
97
|
var HOST_DICTS = { en, zh };
|
|
64
98
|
function translate(lang, key, params = {}) {
|
|
@@ -80,7 +114,7 @@ var RewindError = class extends Error {
|
|
|
80
114
|
code;
|
|
81
115
|
};
|
|
82
116
|
var CANDIDATE_PREVIEW_CHARS = 80;
|
|
83
|
-
var DEFAULT_CANDIDATE_LIMIT =
|
|
117
|
+
var DEFAULT_CANDIDATE_LIMIT = 100;
|
|
84
118
|
function markerTurnOf(events) {
|
|
85
119
|
let lastStarted = 0;
|
|
86
120
|
for (const event of events) {
|
|
@@ -195,12 +229,15 @@ function execSessionCwd(exec, requestedPath) {
|
|
|
195
229
|
|
|
196
230
|
// src/snapshot.ts
|
|
197
231
|
import { createHash } from "node:crypto";
|
|
198
|
-
import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
232
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
199
233
|
import { dirname, join } from "node:path";
|
|
200
234
|
import { homedir } from "node:os";
|
|
201
235
|
var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
|
|
202
236
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
203
237
|
var MAX_ANCHOR_GROUPS = 100;
|
|
238
|
+
function isLinkEntry(entry) {
|
|
239
|
+
return "ref" in entry;
|
|
240
|
+
}
|
|
204
241
|
var defaultProbe = {
|
|
205
242
|
async readText(path) {
|
|
206
243
|
try {
|
|
@@ -219,17 +256,42 @@ function safeSessionId(sessionId) {
|
|
|
219
256
|
const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
220
257
|
return safe === ".." || safe === "." ? "session" : safe;
|
|
221
258
|
}
|
|
259
|
+
async function writeJsonAtomic(file, data, afterTempWrite) {
|
|
260
|
+
const tmp = `${file}.tmp`;
|
|
261
|
+
await writeFile(tmp, JSON.stringify(data), "utf8");
|
|
262
|
+
afterTempWrite?.();
|
|
263
|
+
await rename(tmp, file);
|
|
264
|
+
}
|
|
265
|
+
var RESTORE_JOURNAL_STATES = /* @__PURE__ */ new Set(["running", "rollback-running", "completed", "rolled-back", "recovery-required"]);
|
|
266
|
+
function isRestoreJournal(value) {
|
|
267
|
+
if (typeof value !== "object" || value === null) return false;
|
|
268
|
+
const v = value;
|
|
269
|
+
if (typeof v.id !== "string" || typeof v.sessionId !== "string" || typeof v.targetSeq !== "number") return false;
|
|
270
|
+
if (typeof v.state !== "string" || !RESTORE_JOURNAL_STATES.has(v.state)) return false;
|
|
271
|
+
if (!Array.isArray(v.actions)) return false;
|
|
272
|
+
return v.actions.every((action) => {
|
|
273
|
+
if (typeof action !== "object" || action === null) return false;
|
|
274
|
+
const a = action;
|
|
275
|
+
return typeof a.path === "string" && (a.action === "restore" || a.action === "delete") && (typeof a.before === "string" || a.before === null) && (typeof a.rescue === "string" || a.rescue === null) && typeof a.done === "boolean";
|
|
276
|
+
});
|
|
277
|
+
}
|
|
222
278
|
async function readEntry(file) {
|
|
223
279
|
try {
|
|
224
280
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
225
281
|
if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
|
|
226
|
-
|
|
282
|
+
const base = {
|
|
227
283
|
callId: String(parsed.callId ?? ""),
|
|
228
284
|
anchorSeq: parsed.anchorSeq,
|
|
229
285
|
path: parsed.path,
|
|
230
|
-
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
231
286
|
time: typeof parsed.time === "number" ? parsed.time : 0
|
|
232
287
|
};
|
|
288
|
+
if (typeof parsed.ref === "string") {
|
|
289
|
+
return { ...base, ref: parsed.ref };
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
...base,
|
|
293
|
+
before: typeof parsed.before === "string" ? parsed.before : null
|
|
294
|
+
};
|
|
233
295
|
} catch {
|
|
234
296
|
return void 0;
|
|
235
297
|
}
|
|
@@ -242,14 +304,72 @@ async function isLinkPath(path) {
|
|
|
242
304
|
return false;
|
|
243
305
|
}
|
|
244
306
|
}
|
|
307
|
+
function isSafeLinkRef(ref) {
|
|
308
|
+
return /^[0-9]+\/[a-zA-Z0-9._-]+\.json$/.test(ref);
|
|
309
|
+
}
|
|
310
|
+
async function dirSizeAndLastActive(dir) {
|
|
311
|
+
let size = 0;
|
|
312
|
+
let lastActiveMs = 0;
|
|
313
|
+
const visit = async (current) => {
|
|
314
|
+
let st;
|
|
315
|
+
try {
|
|
316
|
+
st = await lstat(current);
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (st.mtimeMs > lastActiveMs) lastActiveMs = st.mtimeMs;
|
|
321
|
+
if (!st.isDirectory()) {
|
|
322
|
+
size += st.size;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
let names;
|
|
326
|
+
try {
|
|
327
|
+
names = await readdir(current);
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const name2 of names) {
|
|
332
|
+
if (name2.startsWith(".")) continue;
|
|
333
|
+
await visit(join(current, name2));
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
await visit(dir);
|
|
337
|
+
return { size, lastActiveMs };
|
|
338
|
+
}
|
|
245
339
|
var SnapshotStore = class _SnapshotStore {
|
|
246
|
-
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
|
|
340
|
+
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT, opts) {
|
|
247
341
|
this.root = root;
|
|
342
|
+
this.dedup = opts?.dedup ?? true;
|
|
248
343
|
}
|
|
249
344
|
root;
|
|
250
345
|
/** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
|
|
251
346
|
static PRUNE_INTERVAL_MS = 1e3;
|
|
252
347
|
lastPruneAt = 0;
|
|
348
|
+
/**
|
|
349
|
+
* Monotonic entry clock. Date.now() has 1ms precision, so back-to-back
|
|
350
|
+
* commits in the same millisecond would TIE on the entry `time` field and
|
|
351
|
+
* entriesAfter's (anchorSeq, time) sort would fall back to the readdir
|
|
352
|
+
* order — filesystem-dependent, so a re-read could pick the WRONG "earliest"
|
|
353
|
+
* version for a path. Bumping past the previous commit keeps the capture
|
|
354
|
+
* order reproducible after a re-read. The read-modify-write below is
|
|
355
|
+
* synchronous (before the first await), so concurrent commits can never
|
|
356
|
+
* observe the same value. Across restarts wall-clock monotonicity holds
|
|
357
|
+
* (restart gaps dwarf 1ms); a backwards NTP step is the only way to break
|
|
358
|
+
* it, and even then the in-process order still holds.
|
|
359
|
+
*/
|
|
360
|
+
lastEntryTime = 0;
|
|
361
|
+
/** Store options; `dedup` toggles in-place content dedup (default on). */
|
|
362
|
+
dedup;
|
|
363
|
+
/**
|
|
364
|
+
* In-memory per-path "most recent entry" for content dedup, keyed by
|
|
365
|
+
* `<sessionId>\0<path>`. Each value holds the entry's effective `before`
|
|
366
|
+
* content and its own file ref, so a new record with the same content links
|
|
367
|
+
* to the immediately-prior entry (linear chain). Seeded lazily per session
|
|
368
|
+
* from the bounded on-disk window, so dedup survives a host restart.
|
|
369
|
+
*/
|
|
370
|
+
lastEntry = /* @__PURE__ */ new Map();
|
|
371
|
+
/** Sessions whose dedup state has been seeded from disk this process. */
|
|
372
|
+
seededSessions = /* @__PURE__ */ new Set();
|
|
253
373
|
/** Absolute path of one session's snapshot directory (id sanitized). */
|
|
254
374
|
sessionDir(sessionId) {
|
|
255
375
|
return join(this.root, safeSessionId(sessionId));
|
|
@@ -258,18 +378,91 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
258
378
|
anchorDir(sessionId, anchorSeq) {
|
|
259
379
|
return join(this.sessionDir(sessionId), String(anchorSeq));
|
|
260
380
|
}
|
|
261
|
-
/**
|
|
262
|
-
|
|
381
|
+
/** Absolute file ref (relative to the session dir) of an entry. */
|
|
382
|
+
entryRefOf(sessionId, callId, anchorSeq) {
|
|
383
|
+
return `${anchorSeq}/${safeFileId(callId)}.json`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Seed a session's dedup state from the existing (bounded) on-disk window:
|
|
387
|
+
* scan entries newest-first and record the most recent entry per path. This
|
|
388
|
+
* makes content dedup survive a host restart within the session window. A
|
|
389
|
+
* no-op after the first seed (or when `dedup` is disabled).
|
|
390
|
+
*/
|
|
391
|
+
async ensureDedupSeeded(sessionId) {
|
|
392
|
+
if (!this.dedup || this.seededSessions.has(sessionId)) return;
|
|
393
|
+
this.seededSessions.add(sessionId);
|
|
394
|
+
try {
|
|
395
|
+
for (const entry of await this.entriesAfter(sessionId, 0)) {
|
|
396
|
+
const key = `${sessionId}\0${entry.path}`;
|
|
397
|
+
if (this.lastEntry.has(key)) continue;
|
|
398
|
+
const content = await this.resolveBefore(sessionId, entry);
|
|
399
|
+
this.lastEntry.set(key, { content, ref: this.entryRefOf(sessionId, entry.callId, entry.anchorSeq) });
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
this.seededSessions.delete(sessionId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Resolve an entry's effective `before` content, following a link chain to
|
|
407
|
+
* its terminal real snapshot. Refs are strictly backward in
|
|
408
|
+
* `(anchorSeq, time)`, so the chain is acyclic and finite. A dangling or
|
|
409
|
+
* cyclic link throws — callers fail per-file (never silently dropping the
|
|
410
|
+
* path from a restore).
|
|
411
|
+
*/
|
|
412
|
+
async resolveBefore(sessionId, entry, seen = /* @__PURE__ */ new Set()) {
|
|
413
|
+
if (!isLinkEntry(entry)) return entry.before;
|
|
414
|
+
const key = `${entry.anchorSeq}:${entry.callId}`;
|
|
415
|
+
if (seen.has(key)) throw new Error(`link cycle at ${entry.path} (${key})`);
|
|
416
|
+
seen.add(key);
|
|
417
|
+
if (!isSafeLinkRef(entry.ref)) throw new Error(`unsafe link ref ${entry.ref} for ${entry.path}`);
|
|
418
|
+
const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref));
|
|
419
|
+
if (referenced === void 0) throw new Error(`dangling link ${entry.ref} for ${entry.path}`);
|
|
420
|
+
return this.resolveBefore(sessionId, referenced, seen);
|
|
421
|
+
}
|
|
422
|
+
/** Commit one before-backup (or an in-place dedup link) under its anchor. */
|
|
423
|
+
async recordEntry(sessionId, entry, opts) {
|
|
424
|
+
const time = Math.max(Date.now(), this.lastEntryTime + 1);
|
|
425
|
+
this.lastEntryTime = time;
|
|
426
|
+
await this.ensureDedupSeeded(sessionId);
|
|
263
427
|
const dir = this.anchorDir(sessionId, entry.anchorSeq);
|
|
264
428
|
await mkdir(dir, { recursive: true });
|
|
265
|
-
const
|
|
266
|
-
|
|
429
|
+
const file = join(dir, `${safeFileId(entry.callId)}.json`);
|
|
430
|
+
const selfRef = this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
|
|
431
|
+
const key = `${sessionId}\0${entry.path}`;
|
|
432
|
+
const prior = this.lastEntry.get(key);
|
|
433
|
+
if (this.dedup && opts?.dedup !== false && prior !== void 0 && prior.content === entry.before) {
|
|
434
|
+
const committed = {
|
|
435
|
+
callId: entry.callId,
|
|
436
|
+
anchorSeq: entry.anchorSeq,
|
|
437
|
+
path: entry.path,
|
|
438
|
+
ref: prior.ref,
|
|
439
|
+
time
|
|
440
|
+
};
|
|
441
|
+
await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
|
|
442
|
+
this.lastEntry.set(key, { content: prior.content, ref: selfRef });
|
|
443
|
+
} else {
|
|
444
|
+
const committed = { ...entry, time };
|
|
445
|
+
await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
|
|
446
|
+
this.lastEntry.set(key, { content: entry.before, ref: selfRef });
|
|
447
|
+
}
|
|
267
448
|
const now = Date.now();
|
|
268
449
|
if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
|
|
269
450
|
this.lastPruneAt = now;
|
|
270
451
|
await this.prune(sessionId);
|
|
271
452
|
}
|
|
272
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* The effective content recorded by the path's MOST RECENT entry, or
|
|
456
|
+
* undefined when the path has never been recorded (a fresh tracking sight).
|
|
457
|
+
* This is the single in-memory "last known state" the boundary uses to
|
|
458
|
+
* decide whether a tracked file changed — the same source `recordEntry`
|
|
459
|
+
* dedups against, so there is one content copy and one comparison per
|
|
460
|
+
* decision, not two. Seeding is idempotent (once per session from disk).
|
|
461
|
+
*/
|
|
462
|
+
async lastKnownContent(sessionId, path) {
|
|
463
|
+
await this.ensureDedupSeeded(sessionId);
|
|
464
|
+
return this.lastEntry.get(`${sessionId}\0${path}`)?.content;
|
|
465
|
+
}
|
|
273
466
|
/**
|
|
274
467
|
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
275
468
|
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
@@ -339,32 +532,46 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
339
532
|
* @param sessionId - session whose snapshot store to plan against.
|
|
340
533
|
* @param targetSeq - rewind target; entries anchored at/after it apply.
|
|
341
534
|
* @param probe - current-disk state probe (defaults to the real FS).
|
|
342
|
-
* @returns the planned actions
|
|
535
|
+
* @returns the planned actions, the link paths skipped, and per-file failures.
|
|
343
536
|
*/
|
|
344
537
|
async planRestore(sessionId, targetSeq, probe) {
|
|
345
538
|
const actions = [];
|
|
346
539
|
const skipped = [];
|
|
540
|
+
const failed = [];
|
|
347
541
|
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
348
542
|
try {
|
|
349
543
|
if (await probe.isLink(entry.path)) {
|
|
350
544
|
skipped.push(entry.path);
|
|
351
545
|
continue;
|
|
352
546
|
}
|
|
547
|
+
let before;
|
|
548
|
+
try {
|
|
549
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
353
554
|
const current = await probe.readText(entry.path);
|
|
354
|
-
if (
|
|
555
|
+
if (before === null) {
|
|
355
556
|
if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
|
|
356
|
-
} else if (current !==
|
|
357
|
-
actions.push({ path: entry.path, action: "restore", before
|
|
557
|
+
} else if (current !== before) {
|
|
558
|
+
actions.push({ path: entry.path, action: "restore", before });
|
|
358
559
|
}
|
|
359
560
|
} catch (error) {
|
|
360
|
-
|
|
561
|
+
let before;
|
|
562
|
+
try {
|
|
563
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
564
|
+
} catch {
|
|
565
|
+
before = null;
|
|
566
|
+
}
|
|
567
|
+
if (before === null) {
|
|
361
568
|
actions.push({ path: entry.path, action: "delete" });
|
|
362
569
|
} else {
|
|
363
|
-
actions.push({ path: entry.path, action: "restore", before
|
|
570
|
+
actions.push({ path: entry.path, action: "restore", before });
|
|
364
571
|
}
|
|
365
572
|
}
|
|
366
573
|
}
|
|
367
|
-
return { actions, skipped };
|
|
574
|
+
return { actions, skipped, failed };
|
|
368
575
|
}
|
|
369
576
|
/** Per-file restore impact: only actions that would actually change the disk. */
|
|
370
577
|
async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
|
|
@@ -381,40 +588,441 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
381
588
|
* the backup; a delete whose file is ALREADY absent is a silent no-op (not
|
|
382
589
|
* a failure — the target state is already reached). Failures are per-file
|
|
383
590
|
* and never abort the pass.
|
|
591
|
+
*
|
|
592
|
+
* The pass is journaled for crash safety: the pre-restore ("rescue") state
|
|
593
|
+
* of every planned path is captured and an intent journal persisted BEFORE
|
|
594
|
+
* any mutation, then each action is marked done as it is applied. A host
|
|
595
|
+
* crash at any point leaves the journal on disk; after a restart
|
|
596
|
+
* {@link reconcileRestores} reports where the restore stopped,
|
|
597
|
+
* {@link continueRestore} finishes it and {@link rollbackRestore} undoes it
|
|
598
|
+
* back to the exact pre-restore state. Journal IO itself never fails the
|
|
599
|
+
* restore (it degrades to a journal-less pass).
|
|
384
600
|
*/
|
|
385
|
-
async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe) {
|
|
601
|
+
async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe, opts) {
|
|
386
602
|
const restored = [];
|
|
387
603
|
const deleted = [];
|
|
388
604
|
const skipped = [];
|
|
389
605
|
const failed = [];
|
|
390
|
-
const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
|
|
606
|
+
const { actions, skipped: skippedPaths, failed: planFailed } = await this.planRestore(sessionId, targetSeq, probe);
|
|
391
607
|
skipped.push(...skippedPaths);
|
|
392
|
-
|
|
608
|
+
failed.push(...planFailed);
|
|
609
|
+
if (actions.length === 0) return { restored, deleted, skipped, failed };
|
|
610
|
+
const journal = await this.beginRestore(sessionId, targetSeq, actions, probe);
|
|
611
|
+
for (let i = 0; i < actions.length; i++) {
|
|
612
|
+
const action = actions[i];
|
|
613
|
+
opts?.crash?.("before-action", i);
|
|
614
|
+
const journalAction = journal.actions[i];
|
|
615
|
+
let applied;
|
|
393
616
|
try {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
deleted.push(action.path);
|
|
402
|
-
} else {
|
|
403
|
-
await mkdir(dirname(action.path), { recursive: true });
|
|
404
|
-
await writeFile(action.path, action.before, "utf8");
|
|
405
|
-
restored.push(action.path);
|
|
617
|
+
applied = await this.applyActionToDisk(action.action, action.path, action.action === "restore" ? action.before : null, deleteFile);
|
|
618
|
+
if (applied === "enoent") {
|
|
619
|
+
journalAction.done = true;
|
|
620
|
+
await this.saveJournal(journal);
|
|
621
|
+
continue;
|
|
406
622
|
}
|
|
407
623
|
} catch (error) {
|
|
408
|
-
failed
|
|
624
|
+
journalAction.failed = error instanceof Error ? error.message : String(error);
|
|
625
|
+
await this.saveJournal(journal);
|
|
626
|
+
failed.push({ path: action.path, message: journalAction.failed });
|
|
627
|
+
continue;
|
|
409
628
|
}
|
|
629
|
+
opts?.crash?.("after-action", i);
|
|
630
|
+
journalAction.done = true;
|
|
631
|
+
await this.saveJournal(journal);
|
|
632
|
+
if (applied === "restored") restored.push(action.path);
|
|
633
|
+
else deleted.push(action.path);
|
|
634
|
+
}
|
|
635
|
+
if (failed.length === 0) {
|
|
636
|
+
journal.state = "completed";
|
|
637
|
+
journal.finishedAt = Date.now();
|
|
410
638
|
}
|
|
639
|
+
await this.saveJournal(journal);
|
|
411
640
|
return { restored, deleted, skipped, failed };
|
|
412
641
|
}
|
|
642
|
+
/** Prefix of one restore-op journal file inside the session dir. */
|
|
643
|
+
static JOURNAL_PREFIX = "restore-journal-";
|
|
644
|
+
/** Absolute path of one restore-op journal file. */
|
|
645
|
+
journalPath(sessionId, opId) {
|
|
646
|
+
return join(this.sessionDir(sessionId), `${_SnapshotStore.JOURNAL_PREFIX}${safeFileId(opId)}.json`);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Best-effort journal persist: journal IO failures are non-fatal by design —
|
|
650
|
+
* a restore must never fail because its audit journal could not be written.
|
|
651
|
+
* reconcileRestores() re-derives the true state from the disk, so a missing
|
|
652
|
+
* or stale journal only loses the trail, never the recovery ability.
|
|
653
|
+
*/
|
|
654
|
+
async saveJournal(journal) {
|
|
655
|
+
try {
|
|
656
|
+
await writeJsonAtomic(this.journalPath(journal.sessionId, journal.id), journal);
|
|
657
|
+
} catch {
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Journal one restore pass before mutating anything: capture the rescue
|
|
662
|
+
* (pre-restore) state of every planned path and persist the intent
|
|
663
|
+
* atomically. Returns the in-memory journal; a persist failure degrades to
|
|
664
|
+
* a journal-less restore (non-fatal, see {@link saveJournal}).
|
|
665
|
+
*/
|
|
666
|
+
async beginRestore(sessionId, targetSeq, actions, probe) {
|
|
667
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
668
|
+
try {
|
|
669
|
+
await this.pruneTerminalJournals(sessionDir, await readdir(sessionDir));
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (error.code !== "ENOENT") throw error;
|
|
672
|
+
}
|
|
673
|
+
const journalActions = [];
|
|
674
|
+
for (const action of actions) {
|
|
675
|
+
let rescue = null;
|
|
676
|
+
let rescueError;
|
|
677
|
+
try {
|
|
678
|
+
rescue = await probe.readText(action.path) ?? null;
|
|
679
|
+
} catch (error) {
|
|
680
|
+
rescueError = error instanceof Error ? error.message : String(error);
|
|
681
|
+
}
|
|
682
|
+
const journalAction = {
|
|
683
|
+
path: action.path,
|
|
684
|
+
action: action.action,
|
|
685
|
+
before: action.action === "restore" ? action.before : null,
|
|
686
|
+
rescue,
|
|
687
|
+
done: false
|
|
688
|
+
};
|
|
689
|
+
if (rescueError !== void 0) journalAction.rescueError = rescueError;
|
|
690
|
+
journalActions.push(journalAction);
|
|
691
|
+
}
|
|
692
|
+
const journal = {
|
|
693
|
+
version: 1,
|
|
694
|
+
id: `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
695
|
+
sessionId,
|
|
696
|
+
targetSeq,
|
|
697
|
+
startedAt: Date.now(),
|
|
698
|
+
state: "running",
|
|
699
|
+
actions: journalActions
|
|
700
|
+
};
|
|
701
|
+
await this.saveJournal(journal);
|
|
702
|
+
return journal;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Read one journal by op id; undefined when it does not exist. A corrupt
|
|
706
|
+
* journal THROWS (fail-loud): unlike checkpoint entries, silently dropping
|
|
707
|
+
* a journal would silently erase the interrupted restore's recovery record.
|
|
708
|
+
*/
|
|
709
|
+
async readJournal(sessionId, opId) {
|
|
710
|
+
const file = this.journalPath(sessionId, opId);
|
|
711
|
+
let text;
|
|
712
|
+
try {
|
|
713
|
+
text = await readFile(file, "utf8");
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (error.code === "ENOENT") return void 0;
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
let parsed;
|
|
719
|
+
try {
|
|
720
|
+
parsed = JSON.parse(text);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
throw new Error(`restore journal ${file} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
|
|
723
|
+
}
|
|
724
|
+
if (!isRestoreJournal(parsed)) throw new Error(`restore journal ${file} failed schema validation`);
|
|
725
|
+
return parsed;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Every journal file of a session — valid ones plus corrupt ones with their
|
|
729
|
+
* error — so reconciliation can report corruption instead of dropping it.
|
|
730
|
+
*/
|
|
731
|
+
async listJournals(sessionId) {
|
|
732
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
733
|
+
let names;
|
|
734
|
+
try {
|
|
735
|
+
names = await readdir(sessionDir);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (error.code === "ENOENT") return { journals: [], corrupt: [] };
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
const journals = [];
|
|
741
|
+
const corrupt = [];
|
|
742
|
+
for (const name2 of names) {
|
|
743
|
+
if (!name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) || !name2.endsWith(".json")) continue;
|
|
744
|
+
try {
|
|
745
|
+
const parsed = JSON.parse(await readFile(join(sessionDir, name2), "utf8"));
|
|
746
|
+
if (!isRestoreJournal(parsed)) {
|
|
747
|
+
corrupt.push({ file: name2, message: "journal failed schema validation" });
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
journals.push(parsed);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
corrupt.push({ file: name2, message: error instanceof Error ? error.message : String(error) });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return { journals, corrupt };
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Execute ONE fs mutation with exactly the pre-journal semantics: a delete
|
|
759
|
+
* runs through the injected deleteFile (ENOENT tolerated — the file is
|
|
760
|
+
* already absent, i.e. the target state is reached), a restore is a plain
|
|
761
|
+
* writeFile with a recursive mkdir of the parent. Returns how the outcome
|
|
762
|
+
* should record it.
|
|
763
|
+
*/
|
|
764
|
+
async applyActionToDisk(kind, path, content, deleteFile) {
|
|
765
|
+
if (kind === "delete") {
|
|
766
|
+
try {
|
|
767
|
+
await deleteFile(path);
|
|
768
|
+
return "deleted";
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (error.code !== "ENOENT") throw error;
|
|
771
|
+
return "enoent";
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
await mkdir(dirname(path), { recursive: true });
|
|
775
|
+
await writeFile(path, content, "utf8");
|
|
776
|
+
return "restored";
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Reconcile the session's restore journals against the real disk — the
|
|
780
|
+
* "host restart" account: for every interrupted op, report which paths
|
|
781
|
+
* already match its goal (restored) and which are still pending, and expose
|
|
782
|
+
* any recorded failures. Journals whose goal is already fully reached on
|
|
783
|
+
* disk (e.g. a later rewind completed the work) are auto-healed to their
|
|
784
|
+
* terminal state and not reported. A corrupt journal is reported
|
|
785
|
+
* `recovery-required` — never silently dropped.
|
|
786
|
+
*
|
|
787
|
+
* @param sessionId - session whose journals to reconcile.
|
|
788
|
+
* @param probe - current-disk state probe (defaults to the real FS).
|
|
789
|
+
* @returns one report per non-terminal journal still needing attention.
|
|
790
|
+
*/
|
|
791
|
+
async reconcileRestores(sessionId, probe = defaultProbe) {
|
|
792
|
+
const { journals, corrupt } = await this.listJournals(sessionId);
|
|
793
|
+
const reports = [];
|
|
794
|
+
for (const bad of corrupt) {
|
|
795
|
+
reports.push({
|
|
796
|
+
opId: bad.file.slice(_SnapshotStore.JOURNAL_PREFIX.length, -".json".length),
|
|
797
|
+
state: "recovery-required",
|
|
798
|
+
journalState: "recovery-required",
|
|
799
|
+
targetSeq: 0,
|
|
800
|
+
startedAt: 0,
|
|
801
|
+
restored: [],
|
|
802
|
+
pending: [],
|
|
803
|
+
failed: [],
|
|
804
|
+
corrupt: bad.message
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
for (const journal of journals) {
|
|
808
|
+
if (journal.state === "completed" || journal.state === "rolled-back") continue;
|
|
809
|
+
const report = await this.reconcileJournal(journal, probe);
|
|
810
|
+
if (report !== void 0) reports.push(report);
|
|
811
|
+
}
|
|
812
|
+
return reports.sort((a, b) => a.startedAt - b.startedAt || a.opId.localeCompare(b.opId));
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Reconcile ONE non-terminal journal against the real disk. Returns
|
|
816
|
+
* undefined when the op's goal is already fully reached (auto-heals to the
|
|
817
|
+
* terminal state); otherwise a report of restored/pending/failed paths.
|
|
818
|
+
* For `running` journals the goal is the restore target; for
|
|
819
|
+
* `rollback-running` / `recovery-required` journals it is the rescue
|
|
820
|
+
* (pre-restore) state.
|
|
821
|
+
*/
|
|
822
|
+
async reconcileJournal(journal, probe) {
|
|
823
|
+
const rollbackPhase = journal.state === "rollback-running" || journal.state === "recovery-required";
|
|
824
|
+
const restored = [];
|
|
825
|
+
const pending = [];
|
|
826
|
+
const failed = [];
|
|
827
|
+
let allReached = true;
|
|
828
|
+
for (const action of journal.actions) {
|
|
829
|
+
if (action.failed !== void 0) {
|
|
830
|
+
failed.push({ path: action.path, message: action.failed });
|
|
831
|
+
allReached = false;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
let reached;
|
|
835
|
+
try {
|
|
836
|
+
const state = await probe.readText(action.path) ?? null;
|
|
837
|
+
const goal = rollbackPhase ? action.rescue : action.action === "delete" ? null : action.before;
|
|
838
|
+
reached = state === goal;
|
|
839
|
+
} catch {
|
|
840
|
+
reached = false;
|
|
841
|
+
}
|
|
842
|
+
if (reached) restored.push(action.path);
|
|
843
|
+
else pending.push(action.path);
|
|
844
|
+
if (!reached) allReached = false;
|
|
845
|
+
}
|
|
846
|
+
if (allReached && failed.length === 0) {
|
|
847
|
+
if (rollbackPhase) journal.state = "rolled-back";
|
|
848
|
+
else journal.state = "completed";
|
|
849
|
+
journal.finishedAt = Date.now();
|
|
850
|
+
await this.saveJournal(journal);
|
|
851
|
+
return void 0;
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
opId: journal.id,
|
|
855
|
+
state: journal.state === "recovery-required" ? "recovery-required" : "interrupted",
|
|
856
|
+
journalState: journal.state,
|
|
857
|
+
targetSeq: journal.targetSeq,
|
|
858
|
+
startedAt: journal.startedAt,
|
|
859
|
+
restored,
|
|
860
|
+
pending,
|
|
861
|
+
failed,
|
|
862
|
+
...journal.rollbackError === void 0 ? {} : { rollbackError: journal.rollbackError }
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* 补做 (redo) an interrupted restore: finish the op by applying every action
|
|
867
|
+
* whose disk state does not yet match its goal — the restore target for
|
|
868
|
+
* `running` journals. Actions are decided by the REAL disk (the same "disk
|
|
869
|
+
* is truth" rule as reconciliation), so a crash between an fs op and its
|
|
870
|
+
* done-mark is completed deterministically and a path the user already
|
|
871
|
+
* fixed is marked done without being rewritten. Failed actions are retried;
|
|
872
|
+
* a re-failure re-records the failure. The journal becomes `completed` once
|
|
873
|
+
* every action reaches the target.
|
|
874
|
+
*/
|
|
875
|
+
async continueRestore(sessionId, opId, deleteFile, probe = defaultProbe, opts) {
|
|
876
|
+
const journal = await this.readJournal(sessionId, opId);
|
|
877
|
+
if (journal === void 0) throw new Error(`restore journal ${opId} not found for session ${sessionId}`);
|
|
878
|
+
if (journal.state !== "running") {
|
|
879
|
+
throw new Error(`restore journal ${opId} is in state ${journal.state}; only a running restore can be continued`);
|
|
880
|
+
}
|
|
881
|
+
const restored = [];
|
|
882
|
+
const deleted = [];
|
|
883
|
+
const failed = [];
|
|
884
|
+
for (let i = 0; i < journal.actions.length; i++) {
|
|
885
|
+
const action = journal.actions[i];
|
|
886
|
+
opts?.crash?.("before-action", i);
|
|
887
|
+
let reached;
|
|
888
|
+
try {
|
|
889
|
+
const state = await probe.readText(action.path) ?? null;
|
|
890
|
+
reached = state === (action.action === "delete" ? null : action.before);
|
|
891
|
+
} catch {
|
|
892
|
+
reached = false;
|
|
893
|
+
}
|
|
894
|
+
if (reached) {
|
|
895
|
+
action.done = true;
|
|
896
|
+
delete action.failed;
|
|
897
|
+
await this.saveJournal(journal);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
let applied;
|
|
901
|
+
try {
|
|
902
|
+
applied = await this.applyActionToDisk(action.action, action.path, action.action === "restore" ? action.before : null, deleteFile);
|
|
903
|
+
if (applied === "enoent") {
|
|
904
|
+
action.done = true;
|
|
905
|
+
await this.saveJournal(journal);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
} catch (error) {
|
|
909
|
+
action.failed = error instanceof Error ? error.message : String(error);
|
|
910
|
+
await this.saveJournal(journal);
|
|
911
|
+
failed.push({ path: action.path, message: action.failed });
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
opts?.crash?.("after-action", i);
|
|
915
|
+
action.done = true;
|
|
916
|
+
delete action.failed;
|
|
917
|
+
await this.saveJournal(journal);
|
|
918
|
+
if (applied === "restored") restored.push(action.path);
|
|
919
|
+
else deleted.push(action.path);
|
|
920
|
+
}
|
|
921
|
+
if (journal.actions.every((action) => action.done) && !journal.actions.some((action) => action.failed !== void 0)) {
|
|
922
|
+
journal.state = "completed";
|
|
923
|
+
journal.finishedAt = Date.now();
|
|
924
|
+
await this.saveJournal(journal);
|
|
925
|
+
}
|
|
926
|
+
return { restored, deleted, skipped: [], failed };
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* 回滚 (roll back) an interrupted restore: undo every action whose disk
|
|
930
|
+
* state does not match its rescue (pre-restore) record, returning the
|
|
931
|
+
* workspace to the exact state it had before the restore started. Decided
|
|
932
|
+
* by the REAL disk, so actions the crash left applied-but-unmarked are
|
|
933
|
+
* undone too, and a path already back at its rescue state is skipped —
|
|
934
|
+
* the pass is idempotent across crashes (a retry finishes the remaining
|
|
935
|
+
* actions). The journal moves `running` → `rollback-running` → `rolled-back`;
|
|
936
|
+
* a failed undo leaves it `recovery-required` (retryable), and paths whose
|
|
937
|
+
* rescue capture failed are reported and left untouched.
|
|
938
|
+
*/
|
|
939
|
+
async rollbackRestore(sessionId, opId, deleteFile, probe = defaultProbe, opts) {
|
|
940
|
+
const journal = await this.readJournal(sessionId, opId);
|
|
941
|
+
if (journal === void 0) throw new Error(`restore journal ${opId} not found for session ${sessionId}`);
|
|
942
|
+
if (journal.state === "completed" || journal.state === "rolled-back") {
|
|
943
|
+
throw new Error(`restore journal ${opId} is already ${journal.state}`);
|
|
944
|
+
}
|
|
945
|
+
if (journal.state !== "rollback-running") {
|
|
946
|
+
journal.state = "rollback-running";
|
|
947
|
+
await this.saveJournal(journal);
|
|
948
|
+
}
|
|
949
|
+
const restored = [];
|
|
950
|
+
const deleted = [];
|
|
951
|
+
const failed = [];
|
|
952
|
+
let rollbackFailed = false;
|
|
953
|
+
for (let i = 0; i < journal.actions.length; i++) {
|
|
954
|
+
const action = journal.actions[i];
|
|
955
|
+
if (action.rescueError !== void 0) {
|
|
956
|
+
journal.rollbackError = `rescue unavailable for ${action.path}: ${action.rescueError}`;
|
|
957
|
+
journal.state = "recovery-required";
|
|
958
|
+
await this.saveJournal(journal);
|
|
959
|
+
failed.push({ path: action.path, message: journal.rollbackError });
|
|
960
|
+
rollbackFailed = true;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
opts?.crash?.("before-action", i);
|
|
964
|
+
let reached;
|
|
965
|
+
try {
|
|
966
|
+
const state = await probe.readText(action.path) ?? null;
|
|
967
|
+
reached = state === action.rescue;
|
|
968
|
+
} catch {
|
|
969
|
+
reached = false;
|
|
970
|
+
}
|
|
971
|
+
if (reached) {
|
|
972
|
+
action.done = false;
|
|
973
|
+
await this.saveJournal(journal);
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
let applied;
|
|
977
|
+
try {
|
|
978
|
+
applied = await this.applyActionToDisk(action.rescue === null ? "delete" : "restore", action.path, action.rescue, deleteFile);
|
|
979
|
+
if (applied === "enoent") {
|
|
980
|
+
action.done = false;
|
|
981
|
+
await this.saveJournal(journal);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
} catch (error) {
|
|
985
|
+
journal.rollbackError = error instanceof Error ? error.message : String(error);
|
|
986
|
+
journal.state = "recovery-required";
|
|
987
|
+
await this.saveJournal(journal);
|
|
988
|
+
failed.push({ path: action.path, message: journal.rollbackError });
|
|
989
|
+
rollbackFailed = true;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
opts?.crash?.("after-action", i);
|
|
993
|
+
action.done = false;
|
|
994
|
+
await this.saveJournal(journal);
|
|
995
|
+
if (applied === "restored") restored.push(action.path);
|
|
996
|
+
else deleted.push(action.path);
|
|
997
|
+
}
|
|
998
|
+
if (!rollbackFailed) {
|
|
999
|
+
journal.state = "rolled-back";
|
|
1000
|
+
journal.finishedAt = Date.now();
|
|
1001
|
+
await this.saveJournal(journal);
|
|
1002
|
+
}
|
|
1003
|
+
return { restored, deleted, skipped: [], failed };
|
|
1004
|
+
}
|
|
413
1005
|
/**
|
|
414
1006
|
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
415
|
-
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
1007
|
+
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories. Also
|
|
1008
|
+
* recycles terminal restore journals (see {@link pruneTerminalJournals}),
|
|
1009
|
+
* so the per-commit cap bounds BOTH the checkpoint entries and the journal
|
|
1010
|
+
* accumulation.
|
|
1011
|
+
*
|
|
1012
|
+
* Because dedup links reference prior entries, eviction is LINK-AWARE: before
|
|
1013
|
+
* deleting the oldest groups, any SURVIVING (kept-group) link whose `ref`
|
|
1014
|
+
* lands on a real snapshot inside a doomed group is MATERIALIZED (rewritten
|
|
1015
|
+
* as a real snapshot carrying the resolved content), so no kept link is left
|
|
1016
|
+
* dangling. Links form a linear predecessor chain, so materializing the first
|
|
1017
|
+
* link after each doomed real is enough — later links already point at that
|
|
1018
|
+
* materialized entry (or at other kept links), requiring no rewrite.
|
|
1019
|
+
*
|
|
1020
|
+
* `opts.crash` is the test-only seam: a crash fired inside a materialization
|
|
1021
|
+
* write (between its temp write and rename) leaves ONLY a `.tmp` — the doomed
|
|
1022
|
+
* real is still on disk and the kept link still resolves, so nothing dangles
|
|
1023
|
+
* and a later prune simply re-materializes.
|
|
416
1024
|
*/
|
|
417
|
-
async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
|
|
1025
|
+
async prune(sessionId, keep = MAX_ANCHOR_GROUPS, opts) {
|
|
418
1026
|
const sessionDir = this.sessionDir(sessionId);
|
|
419
1027
|
let names;
|
|
420
1028
|
try {
|
|
@@ -423,13 +1031,62 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
423
1031
|
if (error.code === "ENOENT") return;
|
|
424
1032
|
throw error;
|
|
425
1033
|
}
|
|
1034
|
+
await this.pruneTerminalJournals(sessionDir, names);
|
|
426
1035
|
const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
|
|
427
1036
|
const excess = seqs.length - keep;
|
|
428
1037
|
if (excess <= 0) return;
|
|
429
|
-
|
|
1038
|
+
const doomed = new Set(seqs.slice(0, excess));
|
|
1039
|
+
for (const seq of seqs.slice(excess)) {
|
|
1040
|
+
const files = await readdir(this.anchorDir(sessionId, seq)).catch(() => []);
|
|
1041
|
+
for (const file of files) {
|
|
1042
|
+
if (!file.endsWith(".json")) continue;
|
|
1043
|
+
const entry = await readEntry(join(this.anchorDir(sessionId, seq), file));
|
|
1044
|
+
if (entry === void 0 || !isLinkEntry(entry)) continue;
|
|
1045
|
+
if (!isSafeLinkRef(entry.ref)) continue;
|
|
1046
|
+
const slash = entry.ref.indexOf("/");
|
|
1047
|
+
const refAnchor = slash === -1 ? Number.NaN : Number(entry.ref.slice(0, slash));
|
|
1048
|
+
if (!Number.isSafeInteger(refAnchor) || !doomed.has(refAnchor)) continue;
|
|
1049
|
+
let before;
|
|
1050
|
+
try {
|
|
1051
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
1052
|
+
} catch {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const real = {
|
|
1056
|
+
callId: entry.callId,
|
|
1057
|
+
anchorSeq: entry.anchorSeq,
|
|
1058
|
+
path: entry.path,
|
|
1059
|
+
before,
|
|
1060
|
+
time: entry.time
|
|
1061
|
+
};
|
|
1062
|
+
await writeJsonAtomic(join(this.anchorDir(sessionId, seq), file), real, () => opts?.crash?.("after-temp-write"));
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
for (const seq of doomed) {
|
|
430
1066
|
await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
|
|
431
1067
|
}
|
|
432
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Recycle terminal restore journals (`completed` / `rolled-back`): once an
|
|
1071
|
+
* op finished, its journal's before + rescue content is dead weight that
|
|
1072
|
+
* would otherwise accumulate without bound (one journal per both-mode
|
|
1073
|
+
* rewind). Non-terminal journals (crashed ops awaiting reconcile /
|
|
1074
|
+
* continue / rollback) and unclassifiable (corrupt) ones are ALWAYS kept —
|
|
1075
|
+
* a recovery record that cannot be classified is never destroyed.
|
|
1076
|
+
*/
|
|
1077
|
+
async pruneTerminalJournals(sessionDir, names) {
|
|
1078
|
+
for (const name2 of names) {
|
|
1079
|
+
if (!name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) || !name2.endsWith(".json")) continue;
|
|
1080
|
+
const file = join(sessionDir, name2);
|
|
1081
|
+
try {
|
|
1082
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1083
|
+
if (parsed.state === "completed" || parsed.state === "rolled-back") {
|
|
1084
|
+
await rm(file, { force: true });
|
|
1085
|
+
}
|
|
1086
|
+
} catch {
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
433
1090
|
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
434
1091
|
async exists(path) {
|
|
435
1092
|
try {
|
|
@@ -440,6 +1097,77 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
440
1097
|
throw error;
|
|
441
1098
|
}
|
|
442
1099
|
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Cross-session retention sweep: remove WHOLE session directories whose
|
|
1102
|
+
* newest member stamp is older than `maxAgeDays` days of idle, keeping the
|
|
1103
|
+
* active session (`keepActiveId`) untouched. This is the anti-growth policy
|
|
1104
|
+
* for finished sessions (rewind only ever reads the active session, so a
|
|
1105
|
+
* finished session's backups are provably dead weight).
|
|
1106
|
+
*
|
|
1107
|
+
* SAFETY:
|
|
1108
|
+
* - Only whole session directories are removed (dedup refs are
|
|
1109
|
+
* session-relative, so there is no cross-session dangling to materialize);
|
|
1110
|
+
* - the active session is never targeted (`keepActiveId`), and everything
|
|
1111
|
+
* else is protected by its own mtime — a session that is still written to
|
|
1112
|
+
* keeps scrolling its newest member stamp forward, so it is never old
|
|
1113
|
+
* enough to be pruned;
|
|
1114
|
+
* - a non-positive `maxAgeDays` throws instead of degenerating into a
|
|
1115
|
+
* mass-destructive `cutoff` in the far future;
|
|
1116
|
+
* - the walk uses `lstat` (no symlink following) and skips dot-prefixed
|
|
1117
|
+
* temp left overs, so measurement stays inside the store root.
|
|
1118
|
+
*
|
|
1119
|
+
* `dryRun` computes and reports exactly what would be removed without
|
|
1120
|
+
* deleting anything — the `/snapshot-auto-cleanup run` preview.
|
|
1121
|
+
*/
|
|
1122
|
+
async pruneStale(opts) {
|
|
1123
|
+
const { keepActiveId, dryRun = false } = opts;
|
|
1124
|
+
const maxAgeDays = opts.maxAgeDays;
|
|
1125
|
+
if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) {
|
|
1126
|
+
throw new RangeError("pruneStale: maxAgeDays must be a positive finite number");
|
|
1127
|
+
}
|
|
1128
|
+
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
1129
|
+
let scanned = 0;
|
|
1130
|
+
let deleted = 0;
|
|
1131
|
+
let freedBytes = 0;
|
|
1132
|
+
let kept = 0;
|
|
1133
|
+
let skippedActive = 0;
|
|
1134
|
+
let remainingBytes = 0;
|
|
1135
|
+
const report = () => ({ scanned, deleted, freedBytes, kept, remainingBytes, skippedActive, dryRun });
|
|
1136
|
+
let names;
|
|
1137
|
+
try {
|
|
1138
|
+
names = await readdir(this.root);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (error.code === "ENOENT") return report();
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
for (const name2 of names) {
|
|
1144
|
+
if (name2.startsWith(".")) continue;
|
|
1145
|
+
const full = join(this.root, name2);
|
|
1146
|
+
let st;
|
|
1147
|
+
try {
|
|
1148
|
+
st = await lstat(full);
|
|
1149
|
+
} catch {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (!st.isDirectory()) continue;
|
|
1153
|
+
scanned++;
|
|
1154
|
+
if (keepActiveId !== void 0 && safeSessionId(keepActiveId) === name2) {
|
|
1155
|
+
skippedActive++;
|
|
1156
|
+
remainingBytes += (await dirSizeAndLastActive(full)).size;
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const { size, lastActiveMs } = await dirSizeAndLastActive(full);
|
|
1160
|
+
if (lastActiveMs < cutoffMs) {
|
|
1161
|
+
deleted++;
|
|
1162
|
+
freedBytes += size;
|
|
1163
|
+
if (!dryRun) await rm(full, { recursive: true, force: true });
|
|
1164
|
+
} else {
|
|
1165
|
+
kept++;
|
|
1166
|
+
remainingBytes += size;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return report();
|
|
1170
|
+
}
|
|
443
1171
|
/**
|
|
444
1172
|
* All distinct paths ever recorded for a session — the "tracked files"
|
|
445
1173
|
* set. Mirrors Claude Code's global `trackedFiles` collection (files stay
|
|
@@ -457,22 +1185,21 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
457
1185
|
function hashPath(path) {
|
|
458
1186
|
return createHash("sha256").update(path).digest("hex").slice(0, 8);
|
|
459
1187
|
}
|
|
460
|
-
async function reconcileTracked(store, sessionId, anchorSeq, tracked,
|
|
1188
|
+
async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = defaultProbe) {
|
|
461
1189
|
let recorded = 0;
|
|
462
1190
|
for (const path of tracked) {
|
|
463
1191
|
try {
|
|
464
1192
|
if (await probe.isLink(path)) continue;
|
|
465
1193
|
const current = await probe.readText(path);
|
|
466
1194
|
const state = current ?? null;
|
|
467
|
-
const
|
|
468
|
-
if (
|
|
1195
|
+
const last = await store.lastKnownContent(sessionId, path);
|
|
1196
|
+
if (last === void 0 || last !== state) {
|
|
469
1197
|
await store.recordEntry(sessionId, {
|
|
470
1198
|
callId: `recheck-${anchorSeq}-${hashPath(path)}`,
|
|
471
1199
|
anchorSeq,
|
|
472
1200
|
path,
|
|
473
1201
|
before: state
|
|
474
|
-
});
|
|
475
|
-
states.set(path, state);
|
|
1202
|
+
}, { dedup: false });
|
|
476
1203
|
recorded++;
|
|
477
1204
|
}
|
|
478
1205
|
} catch {
|
|
@@ -481,6 +1208,130 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, pr
|
|
|
481
1208
|
return recorded;
|
|
482
1209
|
}
|
|
483
1210
|
|
|
1211
|
+
// src/snapshot-cleanup.ts
|
|
1212
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1213
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
1214
|
+
import { homedir as homedir2 } from "node:os";
|
|
1215
|
+
var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
|
|
1216
|
+
var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
|
|
1217
|
+
var DEFAULT_MAX_AGE_DAYS = 30;
|
|
1218
|
+
var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
|
|
1219
|
+
var AUTO_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
1220
|
+
function resolveCleanupConfigPath() {
|
|
1221
|
+
return process.env[CLEANUP_CONFIG_ENV] ?? join2(homedir2(), ".dsh", CLEANUP_CONFIG_FILENAME);
|
|
1222
|
+
}
|
|
1223
|
+
var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
|
|
1224
|
+
function resolveCleanupStatePath() {
|
|
1225
|
+
return join2(dirname2(resolveCleanupConfigPath()), STATE_FILENAME);
|
|
1226
|
+
}
|
|
1227
|
+
async function loadLastSweepAt(path) {
|
|
1228
|
+
try {
|
|
1229
|
+
const raw = JSON.parse(await readFile2(path, "utf8"));
|
|
1230
|
+
const value = raw["lastSweepAt"];
|
|
1231
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1232
|
+
} catch {
|
|
1233
|
+
return 0;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
async function saveLastSweepAt(path, ms) {
|
|
1237
|
+
const tmp = `${path}.tmp`;
|
|
1238
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1239
|
+
await writeFile2(tmp, JSON.stringify({ lastSweepAt: ms }), "utf8");
|
|
1240
|
+
await rename2(tmp, path);
|
|
1241
|
+
}
|
|
1242
|
+
async function runAutoCleanupCheck(deps, sessionId) {
|
|
1243
|
+
try {
|
|
1244
|
+
const loaded = await loadCleanupConfig(deps.configPath);
|
|
1245
|
+
if (!loaded.ok) {
|
|
1246
|
+
deps.log(`[dsh-rewind] snapshot cleanup config invalid; auto-cleanup skipped: ${loaded.error}`);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (!loaded.config.enabled) return;
|
|
1250
|
+
if (!shouldRunAutoSweep(await loadLastSweepAt(deps.statePath), Date.now())) return;
|
|
1251
|
+
await deps.pruner.pruneStale({ keepActiveId: sessionId, maxAgeDays: loaded.config.maxAgeDays });
|
|
1252
|
+
await saveLastSweepAt(deps.statePath, Date.now());
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
deps.log(`[dsh-rewind] snapshot auto-cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
function parseCleanupConfig(raw) {
|
|
1258
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1259
|
+
return { ok: false, error: "config must be a JSON object" };
|
|
1260
|
+
}
|
|
1261
|
+
const record = raw;
|
|
1262
|
+
let enabled = DEFAULT_CLEANUP_CONFIG.enabled;
|
|
1263
|
+
let maxAgeDays = DEFAULT_CLEANUP_CONFIG.maxAgeDays;
|
|
1264
|
+
if (record["enabled"] !== void 0) {
|
|
1265
|
+
if (typeof record["enabled"] !== "boolean") return { ok: false, error: '"enabled" must be a boolean' };
|
|
1266
|
+
enabled = record["enabled"];
|
|
1267
|
+
}
|
|
1268
|
+
if (record["maxAgeDays"] !== void 0) {
|
|
1269
|
+
const value = record["maxAgeDays"];
|
|
1270
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
1271
|
+
return { ok: false, error: '"maxAgeDays" must be a positive integer' };
|
|
1272
|
+
}
|
|
1273
|
+
maxAgeDays = value;
|
|
1274
|
+
}
|
|
1275
|
+
return { ok: true, config: { enabled, maxAgeDays } };
|
|
1276
|
+
}
|
|
1277
|
+
async function loadCleanupConfig(path) {
|
|
1278
|
+
let text;
|
|
1279
|
+
try {
|
|
1280
|
+
text = await readFile2(path, "utf8");
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
if (error.code === "ENOENT") {
|
|
1283
|
+
return { ok: true, config: { ...DEFAULT_CLEANUP_CONFIG }, fromFile: false };
|
|
1284
|
+
}
|
|
1285
|
+
return { ok: false, error: `config file unreadable: ${error instanceof Error ? error.message : String(error)}` };
|
|
1286
|
+
}
|
|
1287
|
+
let raw;
|
|
1288
|
+
try {
|
|
1289
|
+
raw = JSON.parse(text);
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
return { ok: false, error: `config file is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
1292
|
+
}
|
|
1293
|
+
const parsed = parseCleanupConfig(raw);
|
|
1294
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
1295
|
+
return { ok: true, config: parsed.config, fromFile: true };
|
|
1296
|
+
}
|
|
1297
|
+
async function saveCleanupConfig(path, config) {
|
|
1298
|
+
if (typeof config.enabled !== "boolean" || !Number.isInteger(config.maxAgeDays) || config.maxAgeDays <= 0) {
|
|
1299
|
+
throw new RangeError("invalid cleanup config: enabled must be a boolean and maxAgeDays a positive integer");
|
|
1300
|
+
}
|
|
1301
|
+
const tmp = `${path}.tmp`;
|
|
1302
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1303
|
+
await writeFile2(tmp, JSON.stringify(config, null, 2), "utf8");
|
|
1304
|
+
await rename2(tmp, path);
|
|
1305
|
+
}
|
|
1306
|
+
function parseCleanupCommand(rawInput) {
|
|
1307
|
+
const parts = rawInput.trim().split(/\s+/).filter(Boolean);
|
|
1308
|
+
if (parts.length === 0) return { action: "status" };
|
|
1309
|
+
switch (parts[0]) {
|
|
1310
|
+
case "status":
|
|
1311
|
+
return parts.length === 1 ? { action: "status" } : { error: "usage: /snapshot-auto-cleanup status" };
|
|
1312
|
+
case "on":
|
|
1313
|
+
return parts.length === 1 ? { action: "on" } : { error: "usage: /snapshot-auto-cleanup on" };
|
|
1314
|
+
case "off":
|
|
1315
|
+
return parts.length === 1 ? { action: "off" } : { error: "usage: /snapshot-auto-cleanup off" };
|
|
1316
|
+
case "max-age": {
|
|
1317
|
+
if (parts.length !== 2) return { error: "usage: /snapshot-auto-cleanup max-age <days>" };
|
|
1318
|
+
const days = Number(parts[1]);
|
|
1319
|
+
if (!Number.isInteger(days) || days <= 0) return { error: '"max-age" must be a positive integer (days)' };
|
|
1320
|
+
return { action: "max-age", value: days };
|
|
1321
|
+
}
|
|
1322
|
+
case "run": {
|
|
1323
|
+
if (parts.length === 1) return { action: "run" };
|
|
1324
|
+
if (parts.length === 2 && parts[1] === "--apply") return { action: "run-apply" };
|
|
1325
|
+
return { error: "usage: /snapshot-auto-cleanup run [--apply]" };
|
|
1326
|
+
}
|
|
1327
|
+
default:
|
|
1328
|
+
return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
function shouldRunAutoSweep(lastAtMs, nowMs) {
|
|
1332
|
+
return nowMs - lastAtMs >= AUTO_SWEEP_INTERVAL_MS;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
484
1335
|
// src/index.ts
|
|
485
1336
|
var name = "dsh-rewind";
|
|
486
1337
|
var inject = ["commands", "tools"];
|
|
@@ -788,13 +1639,91 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
|
788
1639
|
}
|
|
789
1640
|
return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
|
|
790
1641
|
}
|
|
1642
|
+
var autoSweepChecked = false;
|
|
1643
|
+
async function maybeRunAutoCleanup(ctx, store, sessionId) {
|
|
1644
|
+
if (autoSweepChecked) return;
|
|
1645
|
+
autoSweepChecked = true;
|
|
1646
|
+
await runAutoCleanupCheck({
|
|
1647
|
+
pruner: store,
|
|
1648
|
+
configPath: resolveCleanupConfigPath(),
|
|
1649
|
+
statePath: resolveCleanupStatePath(),
|
|
1650
|
+
log: (msg) => ctx.logger.warn(msg)
|
|
1651
|
+
}, sessionId);
|
|
1652
|
+
}
|
|
1653
|
+
function formatCleanupReport(report) {
|
|
1654
|
+
const key = report.dryRun ? "cleanup.runDry" : "cleanup.runApply";
|
|
1655
|
+
const text = t(key, {
|
|
1656
|
+
deleted: report.deleted,
|
|
1657
|
+
freed: report.freedBytes,
|
|
1658
|
+
kept: report.kept,
|
|
1659
|
+
remaining: report.remainingBytes
|
|
1660
|
+
});
|
|
1661
|
+
return report.skippedActive > 0 ? `${text}
|
|
1662
|
+
${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
|
|
1663
|
+
}
|
|
1664
|
+
async function handleSnapshotCleanup(store, invocation) {
|
|
1665
|
+
const parsed = parseCleanupCommand(invocation.rawInput);
|
|
1666
|
+
if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
|
|
1667
|
+
const configPath = resolveCleanupConfigPath();
|
|
1668
|
+
switch (parsed.action) {
|
|
1669
|
+
case "status": {
|
|
1670
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1671
|
+
if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
|
|
1672
|
+
return {
|
|
1673
|
+
kind: "success",
|
|
1674
|
+
text: t("cleanup.status", {
|
|
1675
|
+
state: t(loaded.config.enabled ? "cleanup.enabled" : "cleanup.disabled"),
|
|
1676
|
+
days: loaded.config.maxAgeDays,
|
|
1677
|
+
path: configPath,
|
|
1678
|
+
present: t(loaded.fromFile ? "cleanup.present" : "cleanup.absent")
|
|
1679
|
+
})
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
case "on":
|
|
1683
|
+
case "off": {
|
|
1684
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1685
|
+
const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, enabled: parsed.action === "on" };
|
|
1686
|
+
try {
|
|
1687
|
+
await saveCleanupConfig(configPath, next);
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1690
|
+
}
|
|
1691
|
+
return { kind: "success", text: t(parsed.action === "on" ? "cleanup.onOk" : "cleanup.offOk") };
|
|
1692
|
+
}
|
|
1693
|
+
case "max-age": {
|
|
1694
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1695
|
+
const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, maxAgeDays: parsed.value };
|
|
1696
|
+
try {
|
|
1697
|
+
await saveCleanupConfig(configPath, next);
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1700
|
+
}
|
|
1701
|
+
return { kind: "success", text: t("cleanup.maxAgeOk", { days: parsed.value }) };
|
|
1702
|
+
}
|
|
1703
|
+
case "run":
|
|
1704
|
+
case "run-apply": {
|
|
1705
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1706
|
+
if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
|
|
1707
|
+
try {
|
|
1708
|
+
const report = await store.pruneStale({
|
|
1709
|
+
keepActiveId: invocation.agent.session.id,
|
|
1710
|
+
maxAgeDays: loaded.config.maxAgeDays,
|
|
1711
|
+
dryRun: parsed.action === "run"
|
|
1712
|
+
});
|
|
1713
|
+
if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(), Date.now());
|
|
1714
|
+
return { kind: "success", text: formatCleanupReport(report) };
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
return { kind: "error", text: t("cleanup.runFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
791
1721
|
function apply(ctx, config) {
|
|
792
|
-
const store = new SnapshotStore(config?.snapshotDir);
|
|
1722
|
+
const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup });
|
|
793
1723
|
const pending = /* @__PURE__ */ new Map();
|
|
794
1724
|
const anchorCache = /* @__PURE__ */ new WeakMap();
|
|
795
1725
|
const inflight = /* @__PURE__ */ new Set();
|
|
796
1726
|
const trackedBySession = /* @__PURE__ */ new Map();
|
|
797
|
-
const statesBySession = /* @__PURE__ */ new Map();
|
|
798
1727
|
let fsService;
|
|
799
1728
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
800
1729
|
const section = settingsCtx.settings.get(settingsNamespace("locale"));
|
|
@@ -808,6 +1737,12 @@ function apply(ctx, config) {
|
|
|
808
1737
|
description: t("command.description"),
|
|
809
1738
|
handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
|
|
810
1739
|
});
|
|
1740
|
+
yield ctx.commands.register({
|
|
1741
|
+
name: "snapshot-auto-cleanup",
|
|
1742
|
+
description: t("cleanup.description"),
|
|
1743
|
+
input: { hint: t("cleanup.inputHint") },
|
|
1744
|
+
handler: (invocation) => handleSnapshotCleanup(store, invocation)
|
|
1745
|
+
});
|
|
811
1746
|
}, "dsh-rewind command");
|
|
812
1747
|
ctx.on("session/event", (session, event) => {
|
|
813
1748
|
if (event.type !== "user/message") return;
|
|
@@ -816,18 +1751,14 @@ function apply(ctx, config) {
|
|
|
816
1751
|
void (async () => {
|
|
817
1752
|
try {
|
|
818
1753
|
const sessionId = session.id;
|
|
1754
|
+
void maybeRunAutoCleanup(ctx, store, sessionId);
|
|
819
1755
|
let tracked = trackedBySession.get(sessionId);
|
|
820
1756
|
if (tracked === void 0) {
|
|
821
1757
|
tracked = await store.trackedPaths(sessionId);
|
|
822
1758
|
trackedBySession.set(sessionId, tracked);
|
|
823
1759
|
}
|
|
824
1760
|
if (tracked.size === 0) return;
|
|
825
|
-
|
|
826
|
-
if (states === void 0) {
|
|
827
|
-
states = /* @__PURE__ */ new Map();
|
|
828
|
-
statesBySession.set(sessionId, states);
|
|
829
|
-
}
|
|
830
|
-
await reconcileTracked(store, sessionId, event.seq, tracked, states);
|
|
1761
|
+
await reconcileTracked(store, sessionId, event.seq, tracked);
|
|
831
1762
|
} catch (error) {
|
|
832
1763
|
ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
833
1764
|
}
|
|
@@ -846,6 +1777,7 @@ function apply(ctx, config) {
|
|
|
846
1777
|
});
|
|
847
1778
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
848
1779
|
try {
|
|
1780
|
+
void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id);
|
|
849
1781
|
await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
|
|
850
1782
|
} catch (error) {
|
|
851
1783
|
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|