dsh-rewind-plugin 0.4.1 → 0.4.2

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
@@ -80,7 +80,7 @@ var RewindError = class extends Error {
80
80
  code;
81
81
  };
82
82
  var CANDIDATE_PREVIEW_CHARS = 80;
83
- var DEFAULT_CANDIDATE_LIMIT = 50;
83
+ var DEFAULT_CANDIDATE_LIMIT = 100;
84
84
  function markerTurnOf(events) {
85
85
  let lastStarted = 0;
86
86
  for (const event of events) {
@@ -195,7 +195,7 @@ function execSessionCwd(exec, requestedPath) {
195
195
 
196
196
  // src/snapshot.ts
197
197
  import { createHash } from "node:crypto";
198
- import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
198
+ import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
199
199
  import { dirname, join } from "node:path";
200
200
  import { homedir } from "node:os";
201
201
  var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
@@ -219,6 +219,25 @@ function safeSessionId(sessionId) {
219
219
  const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
220
220
  return safe === ".." || safe === "." ? "session" : safe;
221
221
  }
222
+ async function writeJsonAtomic(file, data, afterTempWrite) {
223
+ const tmp = `${file}.tmp`;
224
+ await writeFile(tmp, JSON.stringify(data), "utf8");
225
+ afterTempWrite?.();
226
+ await rename(tmp, file);
227
+ }
228
+ var RESTORE_JOURNAL_STATES = /* @__PURE__ */ new Set(["running", "rollback-running", "completed", "rolled-back", "recovery-required"]);
229
+ function isRestoreJournal(value) {
230
+ if (typeof value !== "object" || value === null) return false;
231
+ const v = value;
232
+ if (typeof v.id !== "string" || typeof v.sessionId !== "string" || typeof v.targetSeq !== "number") return false;
233
+ if (typeof v.state !== "string" || !RESTORE_JOURNAL_STATES.has(v.state)) return false;
234
+ if (!Array.isArray(v.actions)) return false;
235
+ return v.actions.every((action) => {
236
+ if (typeof action !== "object" || action === null) return false;
237
+ const a = action;
238
+ 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";
239
+ });
240
+ }
222
241
  async function readEntry(file) {
223
242
  try {
224
243
  const parsed = JSON.parse(await readFile(file, "utf8"));
@@ -250,6 +269,19 @@ var SnapshotStore = class _SnapshotStore {
250
269
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
251
270
  static PRUNE_INTERVAL_MS = 1e3;
252
271
  lastPruneAt = 0;
272
+ /**
273
+ * Monotonic entry clock. Date.now() has 1ms precision, so back-to-back
274
+ * commits in the same millisecond would TIE on the entry `time` field and
275
+ * entriesAfter's (anchorSeq, time) sort would fall back to the readdir
276
+ * order — filesystem-dependent, so a re-read could pick the WRONG "earliest"
277
+ * version for a path. Bumping past the previous commit keeps the capture
278
+ * order reproducible after a re-read. The read-modify-write below is
279
+ * synchronous (before the first await), so concurrent commits can never
280
+ * observe the same value. Across restarts wall-clock monotonicity holds
281
+ * (restart gaps dwarf 1ms); a backwards NTP step is the only way to break
282
+ * it, and even then the in-process order still holds.
283
+ */
284
+ lastEntryTime = 0;
253
285
  /** Absolute path of one session's snapshot directory (id sanitized). */
254
286
  sessionDir(sessionId) {
255
287
  return join(this.root, safeSessionId(sessionId));
@@ -258,12 +290,20 @@ var SnapshotStore = class _SnapshotStore {
258
290
  anchorDir(sessionId, anchorSeq) {
259
291
  return join(this.sessionDir(sessionId), String(anchorSeq));
260
292
  }
261
- /** Commit one before-backup under its turn's anchor group. */
262
- async recordEntry(sessionId, entry) {
293
+ /** Commit one before-backup under its turn's anchor group (atomic write). */
294
+ async recordEntry(sessionId, entry, opts) {
295
+ const time = Math.max(Date.now(), this.lastEntryTime + 1);
296
+ this.lastEntryTime = time;
263
297
  const dir = this.anchorDir(sessionId, entry.anchorSeq);
264
298
  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");
299
+ const committed = { ...entry, time };
300
+ await writeJsonAtomic(
301
+ join(dir, `${safeFileId(entry.callId)}.json`),
302
+ committed,
303
+ // Test-only crash seam: fire after the temp write so a "half-written"
304
+ // crash leaves no committed entry behind.
305
+ () => opts?.crash?.("after-temp-write")
306
+ );
267
307
  const now = Date.now();
268
308
  if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
269
309
  this.lastPruneAt = now;
@@ -381,38 +421,425 @@ var SnapshotStore = class _SnapshotStore {
381
421
  * the backup; a delete whose file is ALREADY absent is a silent no-op (not
382
422
  * a failure — the target state is already reached). Failures are per-file
383
423
  * and never abort the pass.
424
+ *
425
+ * The pass is journaled for crash safety: the pre-restore ("rescue") state
426
+ * of every planned path is captured and an intent journal persisted BEFORE
427
+ * any mutation, then each action is marked done as it is applied. A host
428
+ * crash at any point leaves the journal on disk; after a restart
429
+ * {@link reconcileRestores} reports where the restore stopped,
430
+ * {@link continueRestore} finishes it and {@link rollbackRestore} undoes it
431
+ * back to the exact pre-restore state. Journal IO itself never fails the
432
+ * restore (it degrades to a journal-less pass).
384
433
  */
385
- async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe) {
434
+ async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe, opts) {
386
435
  const restored = [];
387
436
  const deleted = [];
388
437
  const skipped = [];
389
438
  const failed = [];
390
439
  const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
391
440
  skipped.push(...skippedPaths);
392
- for (const action of actions) {
441
+ if (actions.length === 0) return { restored, deleted, skipped, failed };
442
+ const journal = await this.beginRestore(sessionId, targetSeq, actions, probe);
443
+ for (let i = 0; i < actions.length; i++) {
444
+ const action = actions[i];
445
+ opts?.crash?.("before-action", i);
446
+ const journalAction = journal.actions[i];
447
+ let applied;
393
448
  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);
449
+ applied = await this.applyActionToDisk(action.action, action.path, action.action === "restore" ? action.before : null, deleteFile);
450
+ if (applied === "enoent") {
451
+ journalAction.done = true;
452
+ await this.saveJournal(journal);
453
+ continue;
406
454
  }
407
455
  } catch (error) {
408
- failed.push({ path: action.path, message: error instanceof Error ? error.message : String(error) });
456
+ journalAction.failed = error instanceof Error ? error.message : String(error);
457
+ await this.saveJournal(journal);
458
+ failed.push({ path: action.path, message: journalAction.failed });
459
+ continue;
409
460
  }
461
+ opts?.crash?.("after-action", i);
462
+ journalAction.done = true;
463
+ await this.saveJournal(journal);
464
+ if (applied === "restored") restored.push(action.path);
465
+ else deleted.push(action.path);
466
+ }
467
+ if (failed.length === 0) {
468
+ journal.state = "completed";
469
+ journal.finishedAt = Date.now();
410
470
  }
471
+ await this.saveJournal(journal);
411
472
  return { restored, deleted, skipped, failed };
412
473
  }
474
+ /** Prefix of one restore-op journal file inside the session dir. */
475
+ static JOURNAL_PREFIX = "restore-journal-";
476
+ /** Absolute path of one restore-op journal file. */
477
+ journalPath(sessionId, opId) {
478
+ return join(this.sessionDir(sessionId), `${_SnapshotStore.JOURNAL_PREFIX}${safeFileId(opId)}.json`);
479
+ }
480
+ /**
481
+ * Best-effort journal persist: journal IO failures are non-fatal by design —
482
+ * a restore must never fail because its audit journal could not be written.
483
+ * reconcileRestores() re-derives the true state from the disk, so a missing
484
+ * or stale journal only loses the trail, never the recovery ability.
485
+ */
486
+ async saveJournal(journal) {
487
+ try {
488
+ await writeJsonAtomic(this.journalPath(journal.sessionId, journal.id), journal);
489
+ } catch {
490
+ }
491
+ }
492
+ /**
493
+ * Journal one restore pass before mutating anything: capture the rescue
494
+ * (pre-restore) state of every planned path and persist the intent
495
+ * atomically. Returns the in-memory journal; a persist failure degrades to
496
+ * a journal-less restore (non-fatal, see {@link saveJournal}).
497
+ */
498
+ async beginRestore(sessionId, targetSeq, actions, probe) {
499
+ const sessionDir = this.sessionDir(sessionId);
500
+ try {
501
+ await this.pruneTerminalJournals(sessionDir, await readdir(sessionDir));
502
+ } catch (error) {
503
+ if (error.code !== "ENOENT") throw error;
504
+ }
505
+ const journalActions = [];
506
+ for (const action of actions) {
507
+ let rescue = null;
508
+ let rescueError;
509
+ try {
510
+ rescue = await probe.readText(action.path) ?? null;
511
+ } catch (error) {
512
+ rescueError = error instanceof Error ? error.message : String(error);
513
+ }
514
+ const journalAction = {
515
+ path: action.path,
516
+ action: action.action,
517
+ before: action.action === "restore" ? action.before : null,
518
+ rescue,
519
+ done: false
520
+ };
521
+ if (rescueError !== void 0) journalAction.rescueError = rescueError;
522
+ journalActions.push(journalAction);
523
+ }
524
+ const journal = {
525
+ version: 1,
526
+ id: `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
527
+ sessionId,
528
+ targetSeq,
529
+ startedAt: Date.now(),
530
+ state: "running",
531
+ actions: journalActions
532
+ };
533
+ await this.saveJournal(journal);
534
+ return journal;
535
+ }
536
+ /**
537
+ * Read one journal by op id; undefined when it does not exist. A corrupt
538
+ * journal THROWS (fail-loud): unlike checkpoint entries, silently dropping
539
+ * a journal would silently erase the interrupted restore's recovery record.
540
+ */
541
+ async readJournal(sessionId, opId) {
542
+ const file = this.journalPath(sessionId, opId);
543
+ let text;
544
+ try {
545
+ text = await readFile(file, "utf8");
546
+ } catch (error) {
547
+ if (error.code === "ENOENT") return void 0;
548
+ throw error;
549
+ }
550
+ let parsed;
551
+ try {
552
+ parsed = JSON.parse(text);
553
+ } catch (error) {
554
+ throw new Error(`restore journal ${file} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
555
+ }
556
+ if (!isRestoreJournal(parsed)) throw new Error(`restore journal ${file} failed schema validation`);
557
+ return parsed;
558
+ }
559
+ /**
560
+ * Every journal file of a session — valid ones plus corrupt ones with their
561
+ * error — so reconciliation can report corruption instead of dropping it.
562
+ */
563
+ async listJournals(sessionId) {
564
+ const sessionDir = this.sessionDir(sessionId);
565
+ let names;
566
+ try {
567
+ names = await readdir(sessionDir);
568
+ } catch (error) {
569
+ if (error.code === "ENOENT") return { journals: [], corrupt: [] };
570
+ throw error;
571
+ }
572
+ const journals = [];
573
+ const corrupt = [];
574
+ for (const name2 of names) {
575
+ if (!name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) || !name2.endsWith(".json")) continue;
576
+ try {
577
+ const parsed = JSON.parse(await readFile(join(sessionDir, name2), "utf8"));
578
+ if (!isRestoreJournal(parsed)) {
579
+ corrupt.push({ file: name2, message: "journal failed schema validation" });
580
+ continue;
581
+ }
582
+ journals.push(parsed);
583
+ } catch (error) {
584
+ corrupt.push({ file: name2, message: error instanceof Error ? error.message : String(error) });
585
+ }
586
+ }
587
+ return { journals, corrupt };
588
+ }
589
+ /**
590
+ * Execute ONE fs mutation with exactly the pre-journal semantics: a delete
591
+ * runs through the injected deleteFile (ENOENT tolerated — the file is
592
+ * already absent, i.e. the target state is reached), a restore is a plain
593
+ * writeFile with a recursive mkdir of the parent. Returns how the outcome
594
+ * should record it.
595
+ */
596
+ async applyActionToDisk(kind, path, content, deleteFile) {
597
+ if (kind === "delete") {
598
+ try {
599
+ await deleteFile(path);
600
+ return "deleted";
601
+ } catch (error) {
602
+ if (error.code !== "ENOENT") throw error;
603
+ return "enoent";
604
+ }
605
+ }
606
+ await mkdir(dirname(path), { recursive: true });
607
+ await writeFile(path, content, "utf8");
608
+ return "restored";
609
+ }
610
+ /**
611
+ * Reconcile the session's restore journals against the real disk — the
612
+ * "host restart" account: for every interrupted op, report which paths
613
+ * already match its goal (restored) and which are still pending, and expose
614
+ * any recorded failures. Journals whose goal is already fully reached on
615
+ * disk (e.g. a later rewind completed the work) are auto-healed to their
616
+ * terminal state and not reported. A corrupt journal is reported
617
+ * `recovery-required` — never silently dropped.
618
+ *
619
+ * @param sessionId - session whose journals to reconcile.
620
+ * @param probe - current-disk state probe (defaults to the real FS).
621
+ * @returns one report per non-terminal journal still needing attention.
622
+ */
623
+ async reconcileRestores(sessionId, probe = defaultProbe) {
624
+ const { journals, corrupt } = await this.listJournals(sessionId);
625
+ const reports = [];
626
+ for (const bad of corrupt) {
627
+ reports.push({
628
+ opId: bad.file.slice(_SnapshotStore.JOURNAL_PREFIX.length, -".json".length),
629
+ state: "recovery-required",
630
+ journalState: "recovery-required",
631
+ targetSeq: 0,
632
+ startedAt: 0,
633
+ restored: [],
634
+ pending: [],
635
+ failed: [],
636
+ corrupt: bad.message
637
+ });
638
+ }
639
+ for (const journal of journals) {
640
+ if (journal.state === "completed" || journal.state === "rolled-back") continue;
641
+ const report = await this.reconcileJournal(journal, probe);
642
+ if (report !== void 0) reports.push(report);
643
+ }
644
+ return reports.sort((a, b) => a.startedAt - b.startedAt || a.opId.localeCompare(b.opId));
645
+ }
646
+ /**
647
+ * Reconcile ONE non-terminal journal against the real disk. Returns
648
+ * undefined when the op's goal is already fully reached (auto-heals to the
649
+ * terminal state); otherwise a report of restored/pending/failed paths.
650
+ * For `running` journals the goal is the restore target; for
651
+ * `rollback-running` / `recovery-required` journals it is the rescue
652
+ * (pre-restore) state.
653
+ */
654
+ async reconcileJournal(journal, probe) {
655
+ const rollbackPhase = journal.state === "rollback-running" || journal.state === "recovery-required";
656
+ const restored = [];
657
+ const pending = [];
658
+ const failed = [];
659
+ let allReached = true;
660
+ for (const action of journal.actions) {
661
+ if (action.failed !== void 0) {
662
+ failed.push({ path: action.path, message: action.failed });
663
+ allReached = false;
664
+ continue;
665
+ }
666
+ let reached;
667
+ try {
668
+ const state = await probe.readText(action.path) ?? null;
669
+ const goal = rollbackPhase ? action.rescue : action.action === "delete" ? null : action.before;
670
+ reached = state === goal;
671
+ } catch {
672
+ reached = false;
673
+ }
674
+ if (reached) restored.push(action.path);
675
+ else pending.push(action.path);
676
+ if (!reached) allReached = false;
677
+ }
678
+ if (allReached && failed.length === 0) {
679
+ if (rollbackPhase) journal.state = "rolled-back";
680
+ else journal.state = "completed";
681
+ journal.finishedAt = Date.now();
682
+ await this.saveJournal(journal);
683
+ return void 0;
684
+ }
685
+ return {
686
+ opId: journal.id,
687
+ state: journal.state === "recovery-required" ? "recovery-required" : "interrupted",
688
+ journalState: journal.state,
689
+ targetSeq: journal.targetSeq,
690
+ startedAt: journal.startedAt,
691
+ restored,
692
+ pending,
693
+ failed,
694
+ ...journal.rollbackError === void 0 ? {} : { rollbackError: journal.rollbackError }
695
+ };
696
+ }
697
+ /**
698
+ * 补做 (redo) an interrupted restore: finish the op by applying every action
699
+ * whose disk state does not yet match its goal — the restore target for
700
+ * `running` journals. Actions are decided by the REAL disk (the same "disk
701
+ * is truth" rule as reconciliation), so a crash between an fs op and its
702
+ * done-mark is completed deterministically and a path the user already
703
+ * fixed is marked done without being rewritten. Failed actions are retried;
704
+ * a re-failure re-records the failure. The journal becomes `completed` once
705
+ * every action reaches the target.
706
+ */
707
+ async continueRestore(sessionId, opId, deleteFile, probe = defaultProbe, opts) {
708
+ const journal = await this.readJournal(sessionId, opId);
709
+ if (journal === void 0) throw new Error(`restore journal ${opId} not found for session ${sessionId}`);
710
+ if (journal.state !== "running") {
711
+ throw new Error(`restore journal ${opId} is in state ${journal.state}; only a running restore can be continued`);
712
+ }
713
+ const restored = [];
714
+ const deleted = [];
715
+ const failed = [];
716
+ for (let i = 0; i < journal.actions.length; i++) {
717
+ const action = journal.actions[i];
718
+ opts?.crash?.("before-action", i);
719
+ let reached;
720
+ try {
721
+ const state = await probe.readText(action.path) ?? null;
722
+ reached = state === (action.action === "delete" ? null : action.before);
723
+ } catch {
724
+ reached = false;
725
+ }
726
+ if (reached) {
727
+ action.done = true;
728
+ delete action.failed;
729
+ await this.saveJournal(journal);
730
+ continue;
731
+ }
732
+ let applied;
733
+ try {
734
+ applied = await this.applyActionToDisk(action.action, action.path, action.action === "restore" ? action.before : null, deleteFile);
735
+ if (applied === "enoent") {
736
+ action.done = true;
737
+ await this.saveJournal(journal);
738
+ continue;
739
+ }
740
+ } catch (error) {
741
+ action.failed = error instanceof Error ? error.message : String(error);
742
+ await this.saveJournal(journal);
743
+ failed.push({ path: action.path, message: action.failed });
744
+ continue;
745
+ }
746
+ opts?.crash?.("after-action", i);
747
+ action.done = true;
748
+ delete action.failed;
749
+ await this.saveJournal(journal);
750
+ if (applied === "restored") restored.push(action.path);
751
+ else deleted.push(action.path);
752
+ }
753
+ if (journal.actions.every((action) => action.done) && !journal.actions.some((action) => action.failed !== void 0)) {
754
+ journal.state = "completed";
755
+ journal.finishedAt = Date.now();
756
+ await this.saveJournal(journal);
757
+ }
758
+ return { restored, deleted, skipped: [], failed };
759
+ }
760
+ /**
761
+ * 回滚 (roll back) an interrupted restore: undo every action whose disk
762
+ * state does not match its rescue (pre-restore) record, returning the
763
+ * workspace to the exact state it had before the restore started. Decided
764
+ * by the REAL disk, so actions the crash left applied-but-unmarked are
765
+ * undone too, and a path already back at its rescue state is skipped —
766
+ * the pass is idempotent across crashes (a retry finishes the remaining
767
+ * actions). The journal moves `running` → `rollback-running` → `rolled-back`;
768
+ * a failed undo leaves it `recovery-required` (retryable), and paths whose
769
+ * rescue capture failed are reported and left untouched.
770
+ */
771
+ async rollbackRestore(sessionId, opId, deleteFile, probe = defaultProbe, opts) {
772
+ const journal = await this.readJournal(sessionId, opId);
773
+ if (journal === void 0) throw new Error(`restore journal ${opId} not found for session ${sessionId}`);
774
+ if (journal.state === "completed" || journal.state === "rolled-back") {
775
+ throw new Error(`restore journal ${opId} is already ${journal.state}`);
776
+ }
777
+ if (journal.state !== "rollback-running") {
778
+ journal.state = "rollback-running";
779
+ await this.saveJournal(journal);
780
+ }
781
+ const restored = [];
782
+ const deleted = [];
783
+ const failed = [];
784
+ let rollbackFailed = false;
785
+ for (let i = 0; i < journal.actions.length; i++) {
786
+ const action = journal.actions[i];
787
+ if (action.rescueError !== void 0) {
788
+ journal.rollbackError = `rescue unavailable for ${action.path}: ${action.rescueError}`;
789
+ journal.state = "recovery-required";
790
+ await this.saveJournal(journal);
791
+ failed.push({ path: action.path, message: journal.rollbackError });
792
+ rollbackFailed = true;
793
+ continue;
794
+ }
795
+ opts?.crash?.("before-action", i);
796
+ let reached;
797
+ try {
798
+ const state = await probe.readText(action.path) ?? null;
799
+ reached = state === action.rescue;
800
+ } catch {
801
+ reached = false;
802
+ }
803
+ if (reached) {
804
+ action.done = false;
805
+ await this.saveJournal(journal);
806
+ continue;
807
+ }
808
+ let applied;
809
+ try {
810
+ applied = await this.applyActionToDisk(action.rescue === null ? "delete" : "restore", action.path, action.rescue, deleteFile);
811
+ if (applied === "enoent") {
812
+ action.done = false;
813
+ await this.saveJournal(journal);
814
+ continue;
815
+ }
816
+ } catch (error) {
817
+ journal.rollbackError = error instanceof Error ? error.message : String(error);
818
+ journal.state = "recovery-required";
819
+ await this.saveJournal(journal);
820
+ failed.push({ path: action.path, message: journal.rollbackError });
821
+ rollbackFailed = true;
822
+ continue;
823
+ }
824
+ opts?.crash?.("after-action", i);
825
+ action.done = false;
826
+ await this.saveJournal(journal);
827
+ if (applied === "restored") restored.push(action.path);
828
+ else deleted.push(action.path);
829
+ }
830
+ if (!rollbackFailed) {
831
+ journal.state = "rolled-back";
832
+ journal.finishedAt = Date.now();
833
+ await this.saveJournal(journal);
834
+ }
835
+ return { restored, deleted, skipped: [], failed };
836
+ }
413
837
  /**
414
838
  * Drop the session's oldest anchor groups beyond `keep` (default
415
- * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
839
+ * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories. Also
840
+ * recycles terminal restore journals (see {@link pruneTerminalJournals}),
841
+ * so the per-commit cap bounds BOTH the checkpoint entries and the journal
842
+ * accumulation.
416
843
  */
417
844
  async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
418
845
  const sessionDir = this.sessionDir(sessionId);
@@ -423,6 +850,7 @@ var SnapshotStore = class _SnapshotStore {
423
850
  if (error.code === "ENOENT") return;
424
851
  throw error;
425
852
  }
853
+ await this.pruneTerminalJournals(sessionDir, names);
426
854
  const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
427
855
  const excess = seqs.length - keep;
428
856
  if (excess <= 0) return;
@@ -430,6 +858,27 @@ var SnapshotStore = class _SnapshotStore {
430
858
  await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
431
859
  }
432
860
  }
861
+ /**
862
+ * Recycle terminal restore journals (`completed` / `rolled-back`): once an
863
+ * op finished, its journal's before + rescue content is dead weight that
864
+ * would otherwise accumulate without bound (one journal per both-mode
865
+ * rewind). Non-terminal journals (crashed ops awaiting reconcile /
866
+ * continue / rollback) and unclassifiable (corrupt) ones are ALWAYS kept —
867
+ * a recovery record that cannot be classified is never destroyed.
868
+ */
869
+ async pruneTerminalJournals(sessionDir, names) {
870
+ for (const name2 of names) {
871
+ if (!name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) || !name2.endsWith(".json")) continue;
872
+ const file = join(sessionDir, name2);
873
+ try {
874
+ const parsed = JSON.parse(await readFile(file, "utf8"));
875
+ if (parsed.state === "completed" || parsed.state === "rolled-back") {
876
+ await rm(file, { force: true });
877
+ }
878
+ } catch {
879
+ }
880
+ }
881
+ }
433
882
  /** True when a path exists on disk (used by tests and diagnostics). */
434
883
  async exists(path) {
435
884
  try {
@@ -18,11 +18,12 @@ export declare const PREVIEW_CHARS = 80;
18
18
  /**
19
19
  * Default cap on how many user messages the rewind picker lists (newest kept).
20
20
  *
21
- * A fixed 10 made long sessions look "incomplete" (only the newest 10 shown).
22
- * 50 keeps the picker scrollable/searchable via the popupSelect shell while
23
- * covering far longer sessions; callers can still pass an explicit `limit`.
21
+ * Matches the snapshot store's MAX_ANCHOR_GROUPS (100), so the picker shows
22
+ * every anchor group that can still restore file backups; 100 stays
23
+ * scrollable/searchable via the popupSelect shell, and callers can still
24
+ * pass an explicit `limit`.
24
25
  */
25
- export declare const DEFAULT_CANDIDATE_LIMIT = 50;
26
+ export declare const DEFAULT_CANDIDATE_LIMIT = 100;
26
27
  /** One selectable rewind target. */
27
28
  export interface RewindCandidate {
28
29
  /** Absolute log seq of the `user/message` event. */
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import type { Context } from '@deepseek-ai/cordis';
28
28
  export { SnapshotStore } from './snapshot.ts';
29
- export type { CheckpointEntry, FileImpact, RestoreOutcome } from './snapshot.ts';
29
+ export type { CheckpointEntry, FileImpact, RestoreOutcome, RestoreJournal, RestoreJournalState, RestoreReconcileReport } from './snapshot.ts';
30
30
  export declare const name = "dsh-rewind";
31
31
  export declare const inject: string[];
32
32
  /** Plugin config: optional override of the checkpoint store root. */
@@ -80,10 +80,11 @@ export interface RewindPlan {
80
80
  export declare const CANDIDATE_PREVIEW_CHARS = 80;
81
81
  /**
82
82
  * Default cap on how many user messages a candidate listing returns (newest
83
- * kept). Raised from 10 so long sessions don't look incomplete; callers can
83
+ * kept). Matches the snapshot store's MAX_ANCHOR_GROUPS (100), so every
84
+ * anchor group that still has restorable file backups is listed; callers can
84
85
  * still pass an explicit `limit`.
85
86
  */
86
- export declare const DEFAULT_CANDIDATE_LIMIT = 50;
87
+ export declare const DEFAULT_CANDIDATE_LIMIT = 100;
87
88
  /**
88
89
  * Turn number for the rewind marker.
89
90
  *