dsh-rewind-plugin 0.4.0 → 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 -87
- package/docs/compat-audit.md +137 -0
- package/docs/harness-reference.md +10 -0
- package/docs/release.md +18 -0
- package/docs/troubleshooting.md +24 -0
- 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 +35 -29
- package/README.zh.md +0 -159
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
- [commands.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/commands.md) — command registration (`ctx.commands.register`, `CommandInvocation`, `CommandResult`)
|
|
13
13
|
- [tools.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/tools.md) — tool execution seam (`tools/pre-execute` / `tools/post-execute`, `ToolExecution`)
|
|
14
14
|
- [session-query.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-query.md) — session query / `foldSurface` read-only interfaces
|
|
15
|
+
- [token-meter.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/token-meter.md) — replay-aware token meter (`TokenMeter.measure`, surface pricing)
|
|
16
|
+
- [compaction.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/compaction.md) — compaction seam / `compaction/*` events / `BasicCompactionEngine` (`summarize()` hook)
|
|
17
|
+
- [session-projection.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-projection.md) — projection registry (`SessionProjectionRegistry.snapshot/checkpoint`)
|
|
18
|
+
- [session-stats](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/session/session-stats) — whole-log stats projection (folds ALL events; rewind does not roll back counts)
|
|
19
|
+
- [session-title.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-title.md) — durable title state (`foldSessionTitle`)
|
|
20
|
+
- [goal.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/goal.md) — goal fold (`foldGoal`) and round admission
|
|
21
|
+
|
|
22
|
+
Compatibility probes against these subsystems (test-driven investigation, the
|
|
23
|
+
`compat-invariants` / `compat-interop` vitest suites + the `verify-host` real
|
|
24
|
+
`/compact` chain): [compat-audit.md](compat-audit.md).
|
|
15
25
|
|
|
16
26
|
Also under `docs/` at the repo root: `persistence-catalog.md` (full
|
|
17
27
|
`SessionEventMap`), `tool-catalog.md` (tool inventory), `config-catalog.md`
|
package/docs/release.md
CHANGED
|
@@ -88,3 +88,21 @@ git push origin main --tags # 触发 .github/workflows/publish.yml
|
|
|
88
88
|
**幂等**——已发布的版本会跳过。
|
|
89
89
|
- CI(`.github/workflows/ci.yml`)在每次 push / PR 跑相同检查,外加
|
|
90
90
|
`npm pack --dry-run` 校验 tarball 含 `lib/` 与 `LICENSE`。
|
|
91
|
+
|
|
92
|
+
## DSH 版本适配(peer 范围维护)
|
|
93
|
+
|
|
94
|
+
DSH 仍在 rc 阶段,npm 的 prerelease 匹配规则要求 peer 范围与宿主版本
|
|
95
|
+
**同 `[major, minor, patch]` 元组**才能匹配。因此 peerDependencies 采用
|
|
96
|
+
OR 并集覆盖 DSH 已发布的每个 rc 元组系列(如 `^0.1.0-rc.6 || ^0.1.1-rc.2`),
|
|
97
|
+
并随 DSH 发版追加。
|
|
98
|
+
|
|
99
|
+
- **何时需要更新**:仅当 DSH 发布新元组(`0.1.1 → 0.1.2 → 0.2.x`)时;
|
|
100
|
+
同元组内 rc 滚动(`0.1.1-rc.2 → rc.3`)无需动作。DSH 所有包同版本发布,
|
|
101
|
+
`npm view @deepseek-ai/dsh version` 即权威信号。
|
|
102
|
+
- **自动检测**:`node scripts/check-dsh-version.mjs` 对比 npm 最新版本与
|
|
103
|
+
peer 覆盖的元组,输出是否需追加(exit 0 无需动作,exit 1 需要)。
|
|
104
|
+
- **更新步骤**:给每个 `@deepseek-ai/dsh-*` peer 追加 `|| ^<新元组>-rc.<n>`
|
|
105
|
+
→ devDependencies 同步升到最新 → `npm install` → `npm run typecheck` /
|
|
106
|
+
`npm test` / `npm run verify:host` → 发版。
|
|
107
|
+
- **正式版后收敛**:DSH 发布 final 版本后,正式版不受 prerelease 元组规则
|
|
108
|
+
限制,peer 可收敛为稳定的 `^0.1.x` 单范围,此节即可删除。
|
package/docs/troubleshooting.md
CHANGED
|
@@ -18,3 +18,27 @@ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-r
|
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
It only rewrites the marker events' `data.turn` (seqs, order, and zstd frame structure intact) and backs up the original file before writing — safe to run repeatedly. From a source checkout of a pre-v0.4.0 tag: `node scripts/repair-markers.mjs` (identical flags).
|
|
21
|
+
|
|
22
|
+
## Known compatibility boundaries
|
|
23
|
+
|
|
24
|
+
Behavioral notes verified by the compatibility probe suites
|
|
25
|
+
([compat-audit.md](compat-audit.md)) — none of these is a crash:
|
|
26
|
+
|
|
27
|
+
- **Session stats / telemetry do not roll back**: whole-log folds count the
|
|
28
|
+
withdrawn turns, and telemetry records the rewind's marker and ghost-step
|
|
29
|
+
frame under the reused turn. Expected whole-log semantics.
|
|
30
|
+
- **Withdrawn messages stay searchable and exported**: rewind cuts only the
|
|
31
|
+
model-visible surface; full-text search and `/export` still see the raw log.
|
|
32
|
+
- **Session titles may regenerate** after a rewind (title derives from the
|
|
33
|
+
current surface).
|
|
34
|
+
- **Files written by a cancelled tool call** (write happened, no result, no
|
|
35
|
+
snapshot commit) cannot be restored by "conversation and code".
|
|
36
|
+
|
|
37
|
+
**R-OPENSTEP** (harness-side, plugin does not guard): a session log carrying
|
|
38
|
+
an *unclosed* `step/start` (a crash before the agent loop's `finally` closed
|
|
39
|
+
the step) makes any later step activity break token-meter replay (and
|
|
40
|
+
`/compact`). Harness `0.1.1-rc.2` fixes the crash path on load
|
|
41
|
+
(`interruptedTurnClosers` closes leftover step/turn boundaries). A plugin-side
|
|
42
|
+
up-front rejection was implemented and **reverted** (false positives broke the
|
|
43
|
+
rewind feature on real session logs, `177ec14`); the residual runtime-produced
|
|
44
|
+
risk is accepted. Tracked in the compat-audit.
|
package/lib/client.js
CHANGED
|
@@ -92,7 +92,7 @@ function hiddenSeqsOf(snap) {
|
|
|
92
92
|
|
|
93
93
|
// src/client/candidates.ts
|
|
94
94
|
var PREVIEW_CHARS = 80;
|
|
95
|
-
var DEFAULT_CANDIDATE_LIMIT =
|
|
95
|
+
var DEFAULT_CANDIDATE_LIMIT = 100;
|
|
96
96
|
function messagePreviewOf(message) {
|
|
97
97
|
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
98
98
|
return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
|
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. */
|