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/README.en.md +176 -0
- package/README.md +101 -101
- package/lib/client.js +1 -1
- package/lib/index.js +471 -22
- package/lib/types/client/candidates.d.ts +5 -4
- package/lib/types/index.d.ts +1 -1
- package/lib/types/rewind.d.ts +3 -2
- package/lib/types/snapshot.d.ts +229 -4
- package/package.json +3 -3
- package/README.zh.md +0 -159
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 =
|
|
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
|
|
266
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 =
|
|
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. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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. */
|
package/lib/types/rewind.d.ts
CHANGED
|
@@ -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).
|
|
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 =
|
|
87
|
+
export declare const DEFAULT_CANDIDATE_LIMIT = 100;
|
|
87
88
|
/**
|
|
88
89
|
* Turn number for the rewind marker.
|
|
89
90
|
*
|