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/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 = 50;
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
- return {
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
- /** Commit one before-backup under its turn's anchor group. */
262
- async recordEntry(sessionId, entry) {
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 committed = { ...entry, time: Date.now() };
266
- await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
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 plus the link paths that were skipped.
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 (entry.before === null) {
555
+ if (before === null) {
355
556
  if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
356
- } else if (current !== entry.before) {
357
- actions.push({ path: entry.path, action: "restore", before: entry.before });
557
+ } else if (current !== before) {
558
+ actions.push({ path: entry.path, action: "restore", before });
358
559
  }
359
560
  } catch (error) {
360
- if (entry.before === null) {
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: entry.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
- for (const action of actions) {
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
- if (action.action === "delete") {
395
- try {
396
- await deleteFile(action.path);
397
- } catch (error) {
398
- if (error.code !== "ENOENT") throw error;
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.push({ path: action.path, message: error instanceof Error ? error.message : String(error) });
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
- for (const seq of seqs.slice(0, excess)) {
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, states, probe = defaultProbe) {
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 prev = states.get(path);
468
- if (prev === void 0 || prev !== state) {
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
- let states = statesBySession.get(sessionId);
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)}`);