dsh-rewind-plugin 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +72 -0
- package/README.en.md +53 -40
- package/README.md +44 -40
- package/SECURITY.md +160 -0
- package/docs/README.md +30 -0
- package/docs/architecture.md +128 -0
- package/docs/compat/audit.md +149 -0
- package/docs/compat/tracking-boundary.md +103 -0
- package/docs/compat/tracking-boundary.zh.md +55 -0
- package/docs/compat/troubleshooting.md +41 -0
- package/docs/{troubleshooting.zh.md → compat/troubleshooting.zh.md} +15 -0
- package/docs/format.md +151 -0
- package/docs/harness-reference.md +8 -4
- package/docs/release/release.md +69 -0
- package/docs/{release.md → release/release.zh.md} +6 -52
- package/docs/snapshot-auto-cleanup.md +90 -0
- package/docs/snapshot-auto-cleanup.zh.md +57 -0
- package/lib/client.js +7 -64
- package/lib/index.js +688 -45
- package/lib/types/client/index.d.ts +13 -13
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/index.d.ts +9 -4
- package/lib/types/locales.d.ts +22 -0
- package/lib/types/snapshot-cleanup.d.ts +145 -0
- package/lib/types/snapshot.d.ts +226 -13
- package/package.json +9 -1
- package/docs/compat-audit.md +0 -137
- package/docs/troubleshooting.md +0 -44
- /package/docs/{client-contract.md → contract/client-contract.md} +0 -0
- /package/docs/{client-contract.zh.md → contract/client-contract.zh.md} +0 -0
package/lib/index.js
CHANGED
|
@@ -8,7 +8,7 @@ var en = {
|
|
|
8
8
|
"usage.title": "Usage:",
|
|
9
9
|
"usage.noArgs": " /rewind (no args) withdraw the most recent user message",
|
|
10
10
|
"usage.seq": " /rewind @<seq> chat|both rewind to the given message (chat = conversation only / both = conversation + files)",
|
|
11
|
-
"usage.blocked": "
|
|
11
|
+
"usage.blocked": " /rewind or /undo open the rewind picker",
|
|
12
12
|
"describeTarget.seq": "seq {seq}",
|
|
13
13
|
"describeTarget.index": "message {index}",
|
|
14
14
|
"plan.rewinding": "Rewind to seq {targetSeq}, removing {count} node(s) from the model context (conversation log kept).",
|
|
@@ -30,13 +30,35 @@ var en = {
|
|
|
30
30
|
"success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
|
|
31
31
|
"noUserMessages": "This session has no rewindable user messages yet.",
|
|
32
32
|
"chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
|
|
33
|
-
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
|
|
33
|
+
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
|
|
34
|
+
"cleanup.description": "Manage automatic cleanup of session snapshot backups",
|
|
35
|
+
"cleanup.inputHint": "on | off | max-age <days> | run [--apply] [--current]",
|
|
36
|
+
"cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s). Config: {path} ({present}).",
|
|
37
|
+
"cleanup.enabled": "enabled",
|
|
38
|
+
"cleanup.disabled": "disabled",
|
|
39
|
+
"cleanup.present": "file present",
|
|
40
|
+
"cleanup.absent": "no file \u2014 defaults",
|
|
41
|
+
"cleanup.onOk": "Auto-cleanup enabled.",
|
|
42
|
+
"cleanup.offOk": "Auto-cleanup disabled \u2014 all snapshots kept.",
|
|
43
|
+
"cleanup.maxAgeOk": "Auto-cleanup max age set to {days} day(s).",
|
|
44
|
+
"cleanup.cfgInvalid": 'Snapshot cleanup config invalid: {detail}. Nothing was executed; use "on|off|max-age" to reset the config.',
|
|
45
|
+
"cleanup.saveFailed": "Could not save cleanup config: {detail}.",
|
|
46
|
+
"cleanup.runDry": "Dry-run: would remove {deleted} session snapshot backup(s), freeing {freed} bytes. Re-run with --apply to delete.",
|
|
47
|
+
"cleanup.runApply": "Removed {deleted} session snapshot backup(s), freeing {freed} bytes; {kept} kept, {remaining} bytes remain.",
|
|
48
|
+
"cleanup.runFailed": "Cleanup failed: {detail}.",
|
|
49
|
+
"cleanup.skipped": "({skipped} active session(s) skipped.)",
|
|
50
|
+
"cleanup.clearDry": "Dry-run: would clear {entries} snapshot(s) of the current session, freeing {bytes} bytes. Re-run with --apply to delete.",
|
|
51
|
+
"cleanup.clearApply": "Cleared {entries} snapshot(s) of the current session, freeing {bytes} bytes. This session now records snapshots fresh from its current state.",
|
|
52
|
+
"cleanup.clearActive": "Could not clear session {sessionId}: the session is still running and could not be stopped. Try again once it is idle.",
|
|
53
|
+
"cleanup.clearCancelled": "Clear cancelled.",
|
|
54
|
+
"cleanup.clearFailed": "Could not clear session {sessionId}: {detail}.",
|
|
55
|
+
"cleanup.usage": "Usage:\n /snapshot-auto-cleanup show status\n /snapshot-auto-cleanup on|off enable/disable auto-cleanup\n /snapshot-auto-cleanup max-age <days> set the idle cutoff\n /snapshot-auto-cleanup run [--apply] dry-run, or execute with --apply\n /snapshot-auto-cleanup run --current [--apply] dry-run/clear this session's snapshots"
|
|
34
56
|
};
|
|
35
57
|
var zh = {
|
|
36
58
|
"usage.title": "\u7528\u6CD5\uFF1A",
|
|
37
59
|
"usage.noArgs": " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
|
|
38
60
|
"usage.seq": " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
|
|
39
|
-
"usage.blocked": "
|
|
61
|
+
"usage.blocked": " /rewind \u6216 /undo \u6253\u5F00\u56DE\u9000\u9009\u62E9\u9762\u677F",
|
|
40
62
|
"describeTarget.seq": "seq {seq}",
|
|
41
63
|
"describeTarget.index": "\u7B2C {index} \u6761\u6D88\u606F",
|
|
42
64
|
"plan.rewinding": "\u5C06\u56DE\u9000\u5230 seq {targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 {count} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002",
|
|
@@ -58,7 +80,29 @@ var zh = {
|
|
|
58
80
|
"success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
|
|
59
81
|
"noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
60
82
|
"chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
|
|
61
|
-
"command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09"
|
|
83
|
+
"command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
|
|
84
|
+
"cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
|
|
85
|
+
"cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] [--current]",
|
|
86
|
+
"cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002\u914D\u7F6E\uFF1A{path}\uFF08{present}\uFF09\u3002",
|
|
87
|
+
"cleanup.enabled": "\u5DF2\u5F00\u542F",
|
|
88
|
+
"cleanup.disabled": "\u5DF2\u5173\u95ED",
|
|
89
|
+
"cleanup.present": "\u5B58\u5728\u914D\u7F6E\u6587\u4EF6",
|
|
90
|
+
"cleanup.absent": "\u65E0\u6587\u4EF6\u2014\u2014\u4F7F\u7528\u9ED8\u8BA4\u503C",
|
|
91
|
+
"cleanup.onOk": "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406\u3002",
|
|
92
|
+
"cleanup.offOk": "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406\u2014\u2014\u4FDD\u7559\u5168\u90E8\u5FEB\u7167\u3002",
|
|
93
|
+
"cleanup.maxAgeOk": "\u5DF2\u5C06\u81EA\u52A8\u6E05\u7406\u7684\u6700\u5927\u4FDD\u7559\u5929\u6570\u8BBE\u4E3A {days} \u5929\u3002",
|
|
94
|
+
"cleanup.cfgInvalid": "\u5FEB\u7167\u6E05\u7406\u914D\u7F6E\u65E0\u6548\uFF1A{detail}\u3002\u672A\u6267\u884C\u4EFB\u4F55\u64CD\u4F5C\uFF1B\u8BF7\u7528\u300Con|off|max-age\u300D\u91CD\u8BBE\u914D\u7F6E\u4EE5\u4FEE\u590D\u3002",
|
|
95
|
+
"cleanup.saveFailed": "\u65E0\u6CD5\u4FDD\u5B58\u6E05\u7406\u914D\u7F6E\uFF1A{detail}\u3002",
|
|
96
|
+
"cleanup.runDry": "\u9884\u6F14\uFF1A\u5C06\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\uFF0C\u91CA\u653E {freed} \u5B57\u8282\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\u3002",
|
|
97
|
+
"cleanup.runApply": "\u5DF2\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\uFF0C\u91CA\u653E {freed} \u5B57\u8282\uFF1B\u4FDD\u7559 {kept} \u4E2A\uFF0C\u5269\u4F59 {remaining} \u5B57\u8282\u3002",
|
|
98
|
+
"cleanup.runFailed": "\u6E05\u7406\u5931\u8D25\uFF1A{detail}\u3002",
|
|
99
|
+
"cleanup.skipped": "\uFF08\u8DF3\u8FC7\u4E86 {skipped} \u4E2A\u6D3B\u52A8\u4F1A\u8BDD\u3002\uFF09",
|
|
100
|
+
"cleanup.clearDry": "\u9884\u6F14\uFF1A\u5C06\u6E05\u9664\u5F53\u524D\u4F1A\u8BDD\u7684 {entries} \u4E2A\u5FEB\u7167\uFF0C\u91CA\u653E {bytes} \u5B57\u8282\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\u3002",
|
|
101
|
+
"cleanup.clearApply": "\u5DF2\u6E05\u9664\u5F53\u524D\u4F1A\u8BDD\u7684 {entries} \u4E2A\u5FEB\u7167\uFF0C\u91CA\u653E {bytes} \u5B57\u8282\u3002\u8BE5\u4F1A\u8BDD\u5DF2\u91CD\u7F6E\u4E3A\u4ECE\u5F53\u524D\u72B6\u6001\u91CD\u65B0\u8BB0\u5F55\u5FEB\u7167\u3002",
|
|
102
|
+
"cleanup.clearActive": "\u65E0\u6CD5\u6E05\u9664\u4F1A\u8BDD {sessionId}\uFF1A\u4F1A\u8BDD\u4ECD\u5728\u8FD0\u884C\u4E14\u672A\u80FD\u505C\u6B62\uFF0C\u8BF7\u5F85\u5176\u7A7A\u95F2\u540E\u91CD\u8BD5\u3002",
|
|
103
|
+
"cleanup.clearCancelled": "\u6E05\u7A7A\u5DF2\u53D6\u6D88\u3002",
|
|
104
|
+
"cleanup.clearFailed": "\u65E0\u6CD5\u6E05\u9664\u4F1A\u8BDD {sessionId}\uFF1A{detail}\u3002",
|
|
105
|
+
"cleanup.usage": "\u7528\u6CD5\uFF1A\n /snapshot-auto-cleanup \u67E5\u770B\u72B6\u6001\n /snapshot-auto-cleanup on|off \u5F00\u542F/\u5173\u95ED\u81EA\u52A8\u6E05\u7406\n /snapshot-auto-cleanup max-age <\u5929\u6570> \u8BBE\u7F6E\u5931\u6D3B\u9608\u503C\uFF08\u5929\uFF09\n /snapshot-auto-cleanup run [--apply] \u9884\u6F14\uFF0C\u6216\u52A0 --apply \u6267\u884C\n /snapshot-auto-cleanup run --current [--apply] \u9884\u6F14/\u6E05\u9664\u672C\u4F1A\u8BDD\u5FEB\u7167"
|
|
62
106
|
};
|
|
63
107
|
var HOST_DICTS = { en, zh };
|
|
64
108
|
function translate(lang, key, params = {}) {
|
|
@@ -197,10 +241,14 @@ function execSessionCwd(exec, requestedPath) {
|
|
|
197
241
|
import { createHash } from "node:crypto";
|
|
198
242
|
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
199
243
|
import { dirname, join } from "node:path";
|
|
200
|
-
import {
|
|
201
|
-
var
|
|
244
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
245
|
+
var SNAPSHOT_DIR_NAME = "rewind-snapshots";
|
|
246
|
+
var DEFAULT_SNAPSHOT_ROOT = join(resolveDshHome(), SNAPSHOT_DIR_NAME);
|
|
202
247
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
203
248
|
var MAX_ANCHOR_GROUPS = 100;
|
|
249
|
+
function isLinkEntry(entry) {
|
|
250
|
+
return "ref" in entry;
|
|
251
|
+
}
|
|
204
252
|
var defaultProbe = {
|
|
205
253
|
async readText(path) {
|
|
206
254
|
try {
|
|
@@ -242,13 +290,19 @@ async function readEntry(file) {
|
|
|
242
290
|
try {
|
|
243
291
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
244
292
|
if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
|
|
245
|
-
|
|
293
|
+
const base = {
|
|
246
294
|
callId: String(parsed.callId ?? ""),
|
|
247
295
|
anchorSeq: parsed.anchorSeq,
|
|
248
296
|
path: parsed.path,
|
|
249
|
-
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
250
297
|
time: typeof parsed.time === "number" ? parsed.time : 0
|
|
251
298
|
};
|
|
299
|
+
if (typeof parsed.ref === "string") {
|
|
300
|
+
return { ...base, ref: parsed.ref };
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
...base,
|
|
304
|
+
before: typeof parsed.before === "string" ? parsed.before : null
|
|
305
|
+
};
|
|
252
306
|
} catch {
|
|
253
307
|
return void 0;
|
|
254
308
|
}
|
|
@@ -261,11 +315,39 @@ async function isLinkPath(path) {
|
|
|
261
315
|
return false;
|
|
262
316
|
}
|
|
263
317
|
}
|
|
318
|
+
function isSafeLinkRef(ref) {
|
|
319
|
+
return /^[0-9]+\/[a-zA-Z0-9._-]+\.json$/.test(ref);
|
|
320
|
+
}
|
|
321
|
+
async function dirSizeAndLastActive(dir) {
|
|
322
|
+
let size = 0;
|
|
323
|
+
let lastActiveMs = 0;
|
|
324
|
+
const visit = async (current) => {
|
|
325
|
+
let st;
|
|
326
|
+
try {
|
|
327
|
+
st = await lstat(current);
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (st.mtimeMs > lastActiveMs) lastActiveMs = st.mtimeMs;
|
|
332
|
+
if (!st.isDirectory()) {
|
|
333
|
+
size += st.size;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
let names;
|
|
337
|
+
try {
|
|
338
|
+
names = await readdir(current);
|
|
339
|
+
} catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
for (const name2 of names) {
|
|
343
|
+
if (name2.startsWith(".")) continue;
|
|
344
|
+
await visit(join(current, name2));
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
await visit(dir);
|
|
348
|
+
return { size, lastActiveMs };
|
|
349
|
+
}
|
|
264
350
|
var SnapshotStore = class _SnapshotStore {
|
|
265
|
-
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
|
|
266
|
-
this.root = root;
|
|
267
|
-
}
|
|
268
|
-
root;
|
|
269
351
|
/** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
|
|
270
352
|
static PRUNE_INTERVAL_MS = 1e3;
|
|
271
353
|
lastPruneAt = 0;
|
|
@@ -282,6 +364,24 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
282
364
|
* it, and even then the in-process order still holds.
|
|
283
365
|
*/
|
|
284
366
|
lastEntryTime = 0;
|
|
367
|
+
/** Store options; `dedup` toggles in-place content dedup (default on). */
|
|
368
|
+
dedup;
|
|
369
|
+
/** Resolved checkpoint store root (absolute); see the constructor's fallback. */
|
|
370
|
+
root;
|
|
371
|
+
/**
|
|
372
|
+
* In-memory per-path "most recent entry" for content dedup, keyed by
|
|
373
|
+
* `<sessionId>\0<path>`. Each value holds the entry's effective `before`
|
|
374
|
+
* content and its own file ref, so a new record with the same content links
|
|
375
|
+
* to the immediately-prior entry (linear chain). Seeded lazily per session
|
|
376
|
+
* from the bounded on-disk window, so dedup survives a host restart.
|
|
377
|
+
*/
|
|
378
|
+
lastEntry = /* @__PURE__ */ new Map();
|
|
379
|
+
/** Sessions whose dedup state has been seeded from disk this process. */
|
|
380
|
+
seededSessions = /* @__PURE__ */ new Set();
|
|
381
|
+
constructor(root, opts) {
|
|
382
|
+
this.dedup = opts?.dedup ?? true;
|
|
383
|
+
this.root = root ?? process.env[SNAPSHOT_ROOT_ENV] ?? join(resolveDshHome(opts?.dshHome), SNAPSHOT_DIR_NAME);
|
|
384
|
+
}
|
|
285
385
|
/** Absolute path of one session's snapshot directory (id sanitized). */
|
|
286
386
|
sessionDir(sessionId) {
|
|
287
387
|
return join(this.root, safeSessionId(sessionId));
|
|
@@ -290,26 +390,91 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
290
390
|
anchorDir(sessionId, anchorSeq) {
|
|
291
391
|
return join(this.sessionDir(sessionId), String(anchorSeq));
|
|
292
392
|
}
|
|
293
|
-
/**
|
|
393
|
+
/** Absolute file ref (relative to the session dir) of an entry. */
|
|
394
|
+
entryRefOf(sessionId, callId, anchorSeq) {
|
|
395
|
+
return `${anchorSeq}/${safeFileId(callId)}.json`;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Seed a session's dedup state from the existing (bounded) on-disk window:
|
|
399
|
+
* scan entries newest-first and record the most recent entry per path. This
|
|
400
|
+
* makes content dedup survive a host restart within the session window. A
|
|
401
|
+
* no-op after the first seed (or when `dedup` is disabled).
|
|
402
|
+
*/
|
|
403
|
+
async ensureDedupSeeded(sessionId) {
|
|
404
|
+
if (!this.dedup || this.seededSessions.has(sessionId)) return;
|
|
405
|
+
this.seededSessions.add(sessionId);
|
|
406
|
+
try {
|
|
407
|
+
for (const entry of await this.entriesAfter(sessionId, 0)) {
|
|
408
|
+
const key = `${sessionId}\0${entry.path}`;
|
|
409
|
+
if (this.lastEntry.has(key)) continue;
|
|
410
|
+
const content = await this.resolveBefore(sessionId, entry);
|
|
411
|
+
this.lastEntry.set(key, { content, ref: this.entryRefOf(sessionId, entry.callId, entry.anchorSeq) });
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
this.seededSessions.delete(sessionId);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Resolve an entry's effective `before` content, following a link chain to
|
|
419
|
+
* its terminal real snapshot. Refs are strictly backward in
|
|
420
|
+
* `(anchorSeq, time)`, so the chain is acyclic and finite. A dangling or
|
|
421
|
+
* cyclic link throws — callers fail per-file (never silently dropping the
|
|
422
|
+
* path from a restore).
|
|
423
|
+
*/
|
|
424
|
+
async resolveBefore(sessionId, entry, seen = /* @__PURE__ */ new Set()) {
|
|
425
|
+
if (!isLinkEntry(entry)) return entry.before;
|
|
426
|
+
const key = `${entry.anchorSeq}:${entry.callId}`;
|
|
427
|
+
if (seen.has(key)) throw new Error(`link cycle at ${entry.path} (${key})`);
|
|
428
|
+
seen.add(key);
|
|
429
|
+
if (!isSafeLinkRef(entry.ref)) throw new Error(`unsafe link ref ${entry.ref} for ${entry.path}`);
|
|
430
|
+
const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref));
|
|
431
|
+
if (referenced === void 0) throw new Error(`dangling link ${entry.ref} for ${entry.path}`);
|
|
432
|
+
return this.resolveBefore(sessionId, referenced, seen);
|
|
433
|
+
}
|
|
434
|
+
/** Commit one before-backup (or an in-place dedup link) under its anchor. */
|
|
294
435
|
async recordEntry(sessionId, entry, opts) {
|
|
295
436
|
const time = Math.max(Date.now(), this.lastEntryTime + 1);
|
|
296
437
|
this.lastEntryTime = time;
|
|
438
|
+
await this.ensureDedupSeeded(sessionId);
|
|
297
439
|
const dir = this.anchorDir(sessionId, entry.anchorSeq);
|
|
298
440
|
await mkdir(dir, { recursive: true });
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
441
|
+
const file = join(dir, `${safeFileId(entry.callId)}.json`);
|
|
442
|
+
const selfRef = this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
|
|
443
|
+
const key = `${sessionId}\0${entry.path}`;
|
|
444
|
+
const prior = this.lastEntry.get(key);
|
|
445
|
+
if (this.dedup && opts?.dedup !== false && prior !== void 0 && prior.content === entry.before) {
|
|
446
|
+
const committed = {
|
|
447
|
+
callId: entry.callId,
|
|
448
|
+
anchorSeq: entry.anchorSeq,
|
|
449
|
+
path: entry.path,
|
|
450
|
+
ref: prior.ref,
|
|
451
|
+
time
|
|
452
|
+
};
|
|
453
|
+
await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
|
|
454
|
+
this.lastEntry.set(key, { content: prior.content, ref: selfRef });
|
|
455
|
+
} else {
|
|
456
|
+
const committed = { ...entry, time };
|
|
457
|
+
await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
|
|
458
|
+
this.lastEntry.set(key, { content: entry.before, ref: selfRef });
|
|
459
|
+
}
|
|
307
460
|
const now = Date.now();
|
|
308
461
|
if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
|
|
309
462
|
this.lastPruneAt = now;
|
|
310
463
|
await this.prune(sessionId);
|
|
311
464
|
}
|
|
312
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* The effective content recorded by the path's MOST RECENT entry, or
|
|
468
|
+
* undefined when the path has never been recorded (a fresh tracking sight).
|
|
469
|
+
* This is the single in-memory "last known state" the boundary uses to
|
|
470
|
+
* decide whether a tracked file changed — the same source `recordEntry`
|
|
471
|
+
* dedups against, so there is one content copy and one comparison per
|
|
472
|
+
* decision, not two. Seeding is idempotent (once per session from disk).
|
|
473
|
+
*/
|
|
474
|
+
async lastKnownContent(sessionId, path) {
|
|
475
|
+
await this.ensureDedupSeeded(sessionId);
|
|
476
|
+
return this.lastEntry.get(`${sessionId}\0${path}`)?.content;
|
|
477
|
+
}
|
|
313
478
|
/**
|
|
314
479
|
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
315
480
|
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
@@ -379,32 +544,46 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
379
544
|
* @param sessionId - session whose snapshot store to plan against.
|
|
380
545
|
* @param targetSeq - rewind target; entries anchored at/after it apply.
|
|
381
546
|
* @param probe - current-disk state probe (defaults to the real FS).
|
|
382
|
-
* @returns the planned actions
|
|
547
|
+
* @returns the planned actions, the link paths skipped, and per-file failures.
|
|
383
548
|
*/
|
|
384
549
|
async planRestore(sessionId, targetSeq, probe) {
|
|
385
550
|
const actions = [];
|
|
386
551
|
const skipped = [];
|
|
552
|
+
const failed = [];
|
|
387
553
|
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
388
554
|
try {
|
|
389
555
|
if (await probe.isLink(entry.path)) {
|
|
390
556
|
skipped.push(entry.path);
|
|
391
557
|
continue;
|
|
392
558
|
}
|
|
559
|
+
let before;
|
|
560
|
+
try {
|
|
561
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
393
566
|
const current = await probe.readText(entry.path);
|
|
394
|
-
if (
|
|
567
|
+
if (before === null) {
|
|
395
568
|
if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
|
|
396
|
-
} else if (current !==
|
|
397
|
-
actions.push({ path: entry.path, action: "restore", before
|
|
569
|
+
} else if (current !== before) {
|
|
570
|
+
actions.push({ path: entry.path, action: "restore", before });
|
|
398
571
|
}
|
|
399
572
|
} catch (error) {
|
|
400
|
-
|
|
573
|
+
let before;
|
|
574
|
+
try {
|
|
575
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
576
|
+
} catch {
|
|
577
|
+
before = null;
|
|
578
|
+
}
|
|
579
|
+
if (before === null) {
|
|
401
580
|
actions.push({ path: entry.path, action: "delete" });
|
|
402
581
|
} else {
|
|
403
|
-
actions.push({ path: entry.path, action: "restore", before
|
|
582
|
+
actions.push({ path: entry.path, action: "restore", before });
|
|
404
583
|
}
|
|
405
584
|
}
|
|
406
585
|
}
|
|
407
|
-
return { actions, skipped };
|
|
586
|
+
return { actions, skipped, failed };
|
|
408
587
|
}
|
|
409
588
|
/** Per-file restore impact: only actions that would actually change the disk. */
|
|
410
589
|
async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
|
|
@@ -436,8 +615,9 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
436
615
|
const deleted = [];
|
|
437
616
|
const skipped = [];
|
|
438
617
|
const failed = [];
|
|
439
|
-
const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
|
|
618
|
+
const { actions, skipped: skippedPaths, failed: planFailed } = await this.planRestore(sessionId, targetSeq, probe);
|
|
440
619
|
skipped.push(...skippedPaths);
|
|
620
|
+
failed.push(...planFailed);
|
|
441
621
|
if (actions.length === 0) return { restored, deleted, skipped, failed };
|
|
442
622
|
const journal = await this.beginRestore(sessionId, targetSeq, actions, probe);
|
|
443
623
|
for (let i = 0; i < actions.length; i++) {
|
|
@@ -840,8 +1020,21 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
840
1020
|
* recycles terminal restore journals (see {@link pruneTerminalJournals}),
|
|
841
1021
|
* so the per-commit cap bounds BOTH the checkpoint entries and the journal
|
|
842
1022
|
* accumulation.
|
|
1023
|
+
*
|
|
1024
|
+
* Because dedup links reference prior entries, eviction is LINK-AWARE: before
|
|
1025
|
+
* deleting the oldest groups, any SURVIVING (kept-group) link whose `ref`
|
|
1026
|
+
* lands on a real snapshot inside a doomed group is MATERIALIZED (rewritten
|
|
1027
|
+
* as a real snapshot carrying the resolved content), so no kept link is left
|
|
1028
|
+
* dangling. Links form a linear predecessor chain, so materializing the first
|
|
1029
|
+
* link after each doomed real is enough — later links already point at that
|
|
1030
|
+
* materialized entry (or at other kept links), requiring no rewrite.
|
|
1031
|
+
*
|
|
1032
|
+
* `opts.crash` is the test-only seam: a crash fired inside a materialization
|
|
1033
|
+
* write (between its temp write and rename) leaves ONLY a `.tmp` — the doomed
|
|
1034
|
+
* real is still on disk and the kept link still resolves, so nothing dangles
|
|
1035
|
+
* and a later prune simply re-materializes.
|
|
843
1036
|
*/
|
|
844
|
-
async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
|
|
1037
|
+
async prune(sessionId, keep = MAX_ANCHOR_GROUPS, opts) {
|
|
845
1038
|
const sessionDir = this.sessionDir(sessionId);
|
|
846
1039
|
let names;
|
|
847
1040
|
try {
|
|
@@ -854,7 +1047,34 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
854
1047
|
const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
|
|
855
1048
|
const excess = seqs.length - keep;
|
|
856
1049
|
if (excess <= 0) return;
|
|
857
|
-
|
|
1050
|
+
const doomed = new Set(seqs.slice(0, excess));
|
|
1051
|
+
for (const seq of seqs.slice(excess)) {
|
|
1052
|
+
const files = await readdir(this.anchorDir(sessionId, seq)).catch(() => []);
|
|
1053
|
+
for (const file of files) {
|
|
1054
|
+
if (!file.endsWith(".json")) continue;
|
|
1055
|
+
const entry = await readEntry(join(this.anchorDir(sessionId, seq), file));
|
|
1056
|
+
if (entry === void 0 || !isLinkEntry(entry)) continue;
|
|
1057
|
+
if (!isSafeLinkRef(entry.ref)) continue;
|
|
1058
|
+
const slash = entry.ref.indexOf("/");
|
|
1059
|
+
const refAnchor = slash === -1 ? Number.NaN : Number(entry.ref.slice(0, slash));
|
|
1060
|
+
if (!Number.isSafeInteger(refAnchor) || !doomed.has(refAnchor)) continue;
|
|
1061
|
+
let before;
|
|
1062
|
+
try {
|
|
1063
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
1064
|
+
} catch {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
const real = {
|
|
1068
|
+
callId: entry.callId,
|
|
1069
|
+
anchorSeq: entry.anchorSeq,
|
|
1070
|
+
path: entry.path,
|
|
1071
|
+
before,
|
|
1072
|
+
time: entry.time
|
|
1073
|
+
};
|
|
1074
|
+
await writeJsonAtomic(join(this.anchorDir(sessionId, seq), file), real, () => opts?.crash?.("after-temp-write"));
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
for (const seq of doomed) {
|
|
858
1078
|
await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
|
|
859
1079
|
}
|
|
860
1080
|
}
|
|
@@ -889,6 +1109,77 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
889
1109
|
throw error;
|
|
890
1110
|
}
|
|
891
1111
|
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Cross-session retention sweep: remove WHOLE session directories whose
|
|
1114
|
+
* newest member stamp is older than `maxAgeDays` days of idle, keeping the
|
|
1115
|
+
* active session (`keepActiveId`) untouched. This is the anti-growth policy
|
|
1116
|
+
* for finished sessions (rewind only ever reads the active session, so a
|
|
1117
|
+
* finished session's backups are provably dead weight).
|
|
1118
|
+
*
|
|
1119
|
+
* SAFETY:
|
|
1120
|
+
* - Only whole session directories are removed (dedup refs are
|
|
1121
|
+
* session-relative, so there is no cross-session dangling to materialize);
|
|
1122
|
+
* - the active session is never targeted (`keepActiveId`), and everything
|
|
1123
|
+
* else is protected by its own mtime — a session that is still written to
|
|
1124
|
+
* keeps scrolling its newest member stamp forward, so it is never old
|
|
1125
|
+
* enough to be pruned;
|
|
1126
|
+
* - a non-positive `maxAgeDays` throws instead of degenerating into a
|
|
1127
|
+
* mass-destructive `cutoff` in the far future;
|
|
1128
|
+
* - the walk uses `lstat` (no symlink following) and skips dot-prefixed
|
|
1129
|
+
* temp left overs, so measurement stays inside the store root.
|
|
1130
|
+
*
|
|
1131
|
+
* `dryRun` computes and reports exactly what would be removed without
|
|
1132
|
+
* deleting anything — the `/snapshot-auto-cleanup run` preview.
|
|
1133
|
+
*/
|
|
1134
|
+
async pruneStale(opts) {
|
|
1135
|
+
const { keepActiveId, dryRun = false } = opts;
|
|
1136
|
+
const maxAgeDays = opts.maxAgeDays;
|
|
1137
|
+
if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) {
|
|
1138
|
+
throw new RangeError("pruneStale: maxAgeDays must be a positive finite number");
|
|
1139
|
+
}
|
|
1140
|
+
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
1141
|
+
let scanned = 0;
|
|
1142
|
+
let deleted = 0;
|
|
1143
|
+
let freedBytes = 0;
|
|
1144
|
+
let kept = 0;
|
|
1145
|
+
let skippedActive = 0;
|
|
1146
|
+
let remainingBytes = 0;
|
|
1147
|
+
const report = () => ({ scanned, deleted, freedBytes, kept, remainingBytes, skippedActive, dryRun });
|
|
1148
|
+
let names;
|
|
1149
|
+
try {
|
|
1150
|
+
names = await readdir(this.root);
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
if (error.code === "ENOENT") return report();
|
|
1153
|
+
throw error;
|
|
1154
|
+
}
|
|
1155
|
+
for (const name2 of names) {
|
|
1156
|
+
if (name2.startsWith(".")) continue;
|
|
1157
|
+
const full = join(this.root, name2);
|
|
1158
|
+
let st;
|
|
1159
|
+
try {
|
|
1160
|
+
st = await lstat(full);
|
|
1161
|
+
} catch {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (!st.isDirectory()) continue;
|
|
1165
|
+
scanned++;
|
|
1166
|
+
if (keepActiveId !== void 0 && safeSessionId(keepActiveId) === name2) {
|
|
1167
|
+
skippedActive++;
|
|
1168
|
+
remainingBytes += (await dirSizeAndLastActive(full)).size;
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
const { size, lastActiveMs } = await dirSizeAndLastActive(full);
|
|
1172
|
+
if (lastActiveMs < cutoffMs) {
|
|
1173
|
+
deleted++;
|
|
1174
|
+
freedBytes += size;
|
|
1175
|
+
if (!dryRun) await rm(full, { recursive: true, force: true });
|
|
1176
|
+
} else {
|
|
1177
|
+
kept++;
|
|
1178
|
+
remainingBytes += size;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return report();
|
|
1182
|
+
}
|
|
892
1183
|
/**
|
|
893
1184
|
* All distinct paths ever recorded for a session — the "tracked files"
|
|
894
1185
|
* set. Mirrors Claude Code's global `trackedFiles` collection (files stay
|
|
@@ -902,26 +1193,122 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
902
1193
|
}
|
|
903
1194
|
return paths;
|
|
904
1195
|
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Summarize a session's on-disk footprint for a clear dry-run: anchor-group
|
|
1198
|
+
* count, committed checkpoint-entry count, restore-journal count, and total
|
|
1199
|
+
* bytes. Walks with `lstat` (never follows a symlink, so a hostile symlink
|
|
1200
|
+
* cannot escape the store root or inflate the measurement) and skips
|
|
1201
|
+
* dot-prefixed temp leftovers and non-`.json` members — they are never
|
|
1202
|
+
* checkpoint entries.
|
|
1203
|
+
*/
|
|
1204
|
+
async sessionStats(sessionId) {
|
|
1205
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
1206
|
+
let names;
|
|
1207
|
+
try {
|
|
1208
|
+
names = await readdir(sessionDir);
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
if (error.code === "ENOENT") return { anchorGroups: 0, entries: 0, journals: 0, bytes: 0 };
|
|
1211
|
+
throw error;
|
|
1212
|
+
}
|
|
1213
|
+
let anchorGroups = 0;
|
|
1214
|
+
let entries = 0;
|
|
1215
|
+
let journals = 0;
|
|
1216
|
+
let bytes = 0;
|
|
1217
|
+
for (const name2 of names) {
|
|
1218
|
+
if (name2.startsWith(".")) continue;
|
|
1219
|
+
const full = join(sessionDir, name2);
|
|
1220
|
+
let st;
|
|
1221
|
+
try {
|
|
1222
|
+
st = await lstat(full);
|
|
1223
|
+
} catch {
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
if (st.isDirectory()) {
|
|
1227
|
+
if (!Number.isSafeInteger(Number(name2))) continue;
|
|
1228
|
+
anchorGroups++;
|
|
1229
|
+
let files;
|
|
1230
|
+
try {
|
|
1231
|
+
files = await readdir(full);
|
|
1232
|
+
} catch {
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
for (const file of files) {
|
|
1236
|
+
if (!file.endsWith(".json")) continue;
|
|
1237
|
+
entries++;
|
|
1238
|
+
const fileSt = await lstat(join(full, file)).catch(() => void 0);
|
|
1239
|
+
if (fileSt !== void 0) bytes += fileSt.size;
|
|
1240
|
+
}
|
|
1241
|
+
} else if (name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) && name2.endsWith(".json")) {
|
|
1242
|
+
journals++;
|
|
1243
|
+
bytes += st.size;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return { anchorGroups, entries, journals, bytes };
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Remove a session's ENTIRE snapshot directory — every anchor group, every
|
|
1250
|
+
* checkpoint entry, and every restore journal — and reset the store's
|
|
1251
|
+
* in-memory dedup state so the session starts recording fresh from the
|
|
1252
|
+
* current workspace state. This is the manual "get rid of this session's
|
|
1253
|
+
* records NOW" action on the ACTIVE session the user is driving (it is never
|
|
1254
|
+
* targetable by id; that is a directory-manipulation concern the user can do
|
|
1255
|
+
* directly).
|
|
1256
|
+
*
|
|
1257
|
+
* SEMANTICS — clearing is an explicit abandonment: issuing the command means
|
|
1258
|
+
* the user accepts that this session's snapshot archive goes away. It is
|
|
1259
|
+
* therefore NOT gated on the state of any restore journal. A clear and a
|
|
1260
|
+
* restore are both slash commands the host runs to completion for an agent,
|
|
1261
|
+
* so they never interleave — any non-terminal journal present on disk is a
|
|
1262
|
+
* stale orphan from a previous (crashed) process, and discarding it is the
|
|
1263
|
+
* correct, safe resolution of that abandoned restore.
|
|
1264
|
+
*
|
|
1265
|
+
* SAFETY (this module's real concern is the plugin's ongoing BEHAVIOR, not
|
|
1266
|
+
* losing snapshots):
|
|
1267
|
+
* - Only the session dir is removed; dedup refs are session-relative, so
|
|
1268
|
+
* there is no cross-session dangling to materialize (the same rationale as
|
|
1269
|
+
* {@link pruneStale}'s whole-dir removal).
|
|
1270
|
+
* - The in-memory dedup state (`lastEntry` / `seededSessions`) is ALWAYS
|
|
1271
|
+
* reset on an apply — even when the dir was already empty. A stale
|
|
1272
|
+
* in-memory entry (e.g. a session whose dir was removed out-of-band) would
|
|
1273
|
+
* otherwise link a later `recordEntry` to a deleted prior entry, leaving a
|
|
1274
|
+
* dangling ref that breaks restore resolution. This is the primary
|
|
1275
|
+
* correctness guarantee.
|
|
1276
|
+
*
|
|
1277
|
+
* `dryRun` computes the report without touching disk or memory.
|
|
1278
|
+
*/
|
|
1279
|
+
async clearSession(sessionId, opts) {
|
|
1280
|
+
const dryRun = opts?.dryRun ?? false;
|
|
1281
|
+
const stats = await this.sessionStats(sessionId);
|
|
1282
|
+
if (!dryRun) {
|
|
1283
|
+
if (stats.anchorGroups > 0 || stats.journals > 0) {
|
|
1284
|
+
await rm(this.sessionDir(sessionId), { recursive: true, force: true });
|
|
1285
|
+
}
|
|
1286
|
+
this.seededSessions.delete(sessionId);
|
|
1287
|
+
for (const key of this.lastEntry.keys()) {
|
|
1288
|
+
if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return { sessionId, ...stats, dryRun };
|
|
1292
|
+
}
|
|
905
1293
|
};
|
|
906
1294
|
function hashPath(path) {
|
|
907
1295
|
return createHash("sha256").update(path).digest("hex").slice(0, 8);
|
|
908
1296
|
}
|
|
909
|
-
async function reconcileTracked(store, sessionId, anchorSeq, tracked,
|
|
1297
|
+
async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = defaultProbe) {
|
|
910
1298
|
let recorded = 0;
|
|
911
1299
|
for (const path of tracked) {
|
|
912
1300
|
try {
|
|
913
1301
|
if (await probe.isLink(path)) continue;
|
|
914
1302
|
const current = await probe.readText(path);
|
|
915
1303
|
const state = current ?? null;
|
|
916
|
-
const
|
|
917
|
-
if (
|
|
1304
|
+
const last = await store.lastKnownContent(sessionId, path);
|
|
1305
|
+
if (last === void 0 || last !== state) {
|
|
918
1306
|
await store.recordEntry(sessionId, {
|
|
919
1307
|
callId: `recheck-${anchorSeq}-${hashPath(path)}`,
|
|
920
1308
|
anchorSeq,
|
|
921
1309
|
path,
|
|
922
1310
|
before: state
|
|
923
|
-
});
|
|
924
|
-
states.set(path, state);
|
|
1311
|
+
}, { dedup: false });
|
|
925
1312
|
recorded++;
|
|
926
1313
|
}
|
|
927
1314
|
} catch {
|
|
@@ -930,6 +1317,141 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, pr
|
|
|
930
1317
|
return recorded;
|
|
931
1318
|
}
|
|
932
1319
|
|
|
1320
|
+
// src/snapshot-cleanup.ts
|
|
1321
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1322
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
1323
|
+
import { resolveDshHome as resolveDshHome2 } from "@deepseek-ai/dsh-home-paths";
|
|
1324
|
+
var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
|
|
1325
|
+
var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
|
|
1326
|
+
var DEFAULT_MAX_AGE_DAYS = 30;
|
|
1327
|
+
var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
|
|
1328
|
+
var AUTO_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
1329
|
+
function resolveCleanupConfigPath(dshHome) {
|
|
1330
|
+
return process.env[CLEANUP_CONFIG_ENV] ?? join2(resolveDshHome2(dshHome), CLEANUP_CONFIG_FILENAME);
|
|
1331
|
+
}
|
|
1332
|
+
var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
|
|
1333
|
+
function resolveCleanupStatePath(dshHome) {
|
|
1334
|
+
return join2(dirname2(resolveCleanupConfigPath(dshHome)), STATE_FILENAME);
|
|
1335
|
+
}
|
|
1336
|
+
async function loadLastSweepAt(path) {
|
|
1337
|
+
try {
|
|
1338
|
+
const raw = JSON.parse(await readFile2(path, "utf8"));
|
|
1339
|
+
const value = raw["lastSweepAt"];
|
|
1340
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1341
|
+
} catch {
|
|
1342
|
+
return 0;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
async function saveLastSweepAt(path, ms) {
|
|
1346
|
+
const tmp = `${path}.tmp`;
|
|
1347
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1348
|
+
await writeFile2(tmp, JSON.stringify({ lastSweepAt: ms }), "utf8");
|
|
1349
|
+
await rename2(tmp, path);
|
|
1350
|
+
}
|
|
1351
|
+
async function runAutoCleanupCheck(deps, sessionId) {
|
|
1352
|
+
try {
|
|
1353
|
+
const loaded = await loadCleanupConfig(deps.configPath);
|
|
1354
|
+
if (!loaded.ok) {
|
|
1355
|
+
deps.log(`[dsh-rewind] snapshot cleanup config invalid; auto-cleanup skipped: ${loaded.error}`);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (!loaded.config.enabled) return;
|
|
1359
|
+
if (!shouldRunAutoSweep(await loadLastSweepAt(deps.statePath), Date.now())) return;
|
|
1360
|
+
await deps.pruner.pruneStale({ keepActiveId: sessionId, maxAgeDays: loaded.config.maxAgeDays });
|
|
1361
|
+
await saveLastSweepAt(deps.statePath, Date.now());
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
deps.log(`[dsh-rewind] snapshot auto-cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
function parseCleanupConfig(raw) {
|
|
1367
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1368
|
+
return { ok: false, error: "config must be a JSON object" };
|
|
1369
|
+
}
|
|
1370
|
+
const record = raw;
|
|
1371
|
+
let enabled = DEFAULT_CLEANUP_CONFIG.enabled;
|
|
1372
|
+
let maxAgeDays = DEFAULT_CLEANUP_CONFIG.maxAgeDays;
|
|
1373
|
+
if (record["enabled"] !== void 0) {
|
|
1374
|
+
if (typeof record["enabled"] !== "boolean") return { ok: false, error: '"enabled" must be a boolean' };
|
|
1375
|
+
enabled = record["enabled"];
|
|
1376
|
+
}
|
|
1377
|
+
if (record["maxAgeDays"] !== void 0) {
|
|
1378
|
+
const value = record["maxAgeDays"];
|
|
1379
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
1380
|
+
return { ok: false, error: '"maxAgeDays" must be a positive integer' };
|
|
1381
|
+
}
|
|
1382
|
+
maxAgeDays = value;
|
|
1383
|
+
}
|
|
1384
|
+
return { ok: true, config: { enabled, maxAgeDays } };
|
|
1385
|
+
}
|
|
1386
|
+
async function loadCleanupConfig(path) {
|
|
1387
|
+
let text;
|
|
1388
|
+
try {
|
|
1389
|
+
text = await readFile2(path, "utf8");
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
if (error.code === "ENOENT") {
|
|
1392
|
+
return { ok: true, config: { ...DEFAULT_CLEANUP_CONFIG }, fromFile: false };
|
|
1393
|
+
}
|
|
1394
|
+
return { ok: false, error: `config file unreadable: ${error instanceof Error ? error.message : String(error)}` };
|
|
1395
|
+
}
|
|
1396
|
+
let raw;
|
|
1397
|
+
try {
|
|
1398
|
+
raw = JSON.parse(text);
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
return { ok: false, error: `config file is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
1401
|
+
}
|
|
1402
|
+
const parsed = parseCleanupConfig(raw);
|
|
1403
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
1404
|
+
return { ok: true, config: parsed.config, fromFile: true };
|
|
1405
|
+
}
|
|
1406
|
+
async function saveCleanupConfig(path, config) {
|
|
1407
|
+
if (typeof config.enabled !== "boolean" || !Number.isInteger(config.maxAgeDays) || config.maxAgeDays <= 0) {
|
|
1408
|
+
throw new RangeError("invalid cleanup config: enabled must be a boolean and maxAgeDays a positive integer");
|
|
1409
|
+
}
|
|
1410
|
+
const tmp = `${path}.tmp`;
|
|
1411
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1412
|
+
await writeFile2(tmp, JSON.stringify(config, null, 2), "utf8");
|
|
1413
|
+
await rename2(tmp, path);
|
|
1414
|
+
}
|
|
1415
|
+
function parseCleanupCommand(rawInput) {
|
|
1416
|
+
const parts = rawInput.trim().split(/\s+/).filter(Boolean);
|
|
1417
|
+
if (parts.length === 0) return { action: "status" };
|
|
1418
|
+
switch (parts[0]) {
|
|
1419
|
+
case "status":
|
|
1420
|
+
return parts.length === 1 ? { action: "status" } : { error: "usage: /snapshot-auto-cleanup status" };
|
|
1421
|
+
case "on":
|
|
1422
|
+
return parts.length === 1 ? { action: "on" } : { error: "usage: /snapshot-auto-cleanup on" };
|
|
1423
|
+
case "off":
|
|
1424
|
+
return parts.length === 1 ? { action: "off" } : { error: "usage: /snapshot-auto-cleanup off" };
|
|
1425
|
+
case "max-age": {
|
|
1426
|
+
if (parts.length !== 2) return { error: "usage: /snapshot-auto-cleanup max-age <days>" };
|
|
1427
|
+
const days = Number(parts[1]);
|
|
1428
|
+
if (!Number.isInteger(days) || days <= 0) return { error: '"max-age" must be a positive integer (days)' };
|
|
1429
|
+
return { action: "max-age", value: days };
|
|
1430
|
+
}
|
|
1431
|
+
case "run-apply":
|
|
1432
|
+
return { error: 'the "run-apply" abbreviation was removed; use "run --apply"' };
|
|
1433
|
+
case "run": {
|
|
1434
|
+
let apply2 = false;
|
|
1435
|
+
let current = false;
|
|
1436
|
+
for (const rawFlag of parts.slice(1)) {
|
|
1437
|
+
if (rawFlag === "--apply") {
|
|
1438
|
+
apply2 = true;
|
|
1439
|
+
} else if (rawFlag === "--current") {
|
|
1440
|
+
current = true;
|
|
1441
|
+
} else {
|
|
1442
|
+
return { error: `unknown /snapshot-auto-cleanup run flag "${rawFlag}"` };
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return { action: "run", target: current ? "current" : "rules", apply: apply2 };
|
|
1446
|
+
}
|
|
1447
|
+
default:
|
|
1448
|
+
return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function shouldRunAutoSweep(lastAtMs, nowMs) {
|
|
1452
|
+
return nowMs - lastAtMs >= AUTO_SWEEP_INTERVAL_MS;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
933
1455
|
// src/index.ts
|
|
934
1456
|
var name = "dsh-rewind";
|
|
935
1457
|
var inject = ["commands", "tools"];
|
|
@@ -1237,13 +1759,125 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
|
1237
1759
|
}
|
|
1238
1760
|
return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
|
|
1239
1761
|
}
|
|
1762
|
+
var autoSweepChecked = false;
|
|
1763
|
+
async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome) {
|
|
1764
|
+
if (autoSweepChecked) return;
|
|
1765
|
+
autoSweepChecked = true;
|
|
1766
|
+
await runAutoCleanupCheck({
|
|
1767
|
+
pruner: store,
|
|
1768
|
+
configPath: resolveCleanupConfigPath(dshHome),
|
|
1769
|
+
statePath: resolveCleanupStatePath(dshHome),
|
|
1770
|
+
log: (msg) => ctx.logger.warn(msg)
|
|
1771
|
+
}, sessionId);
|
|
1772
|
+
}
|
|
1773
|
+
function formatCleanupReport(report) {
|
|
1774
|
+
const key = report.dryRun ? "cleanup.runDry" : "cleanup.runApply";
|
|
1775
|
+
const text = t(key, {
|
|
1776
|
+
deleted: report.deleted,
|
|
1777
|
+
freed: report.freedBytes,
|
|
1778
|
+
kept: report.kept,
|
|
1779
|
+
remaining: report.remainingBytes
|
|
1780
|
+
});
|
|
1781
|
+
return report.skippedActive > 0 ? `${text}
|
|
1782
|
+
${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
|
|
1783
|
+
}
|
|
1784
|
+
async function handleSnapshotCleanup(store, invocation, dshHome, trackedBySession) {
|
|
1785
|
+
const parsed = parseCleanupCommand(invocation.rawInput);
|
|
1786
|
+
if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
|
|
1787
|
+
const configPath = resolveCleanupConfigPath(dshHome);
|
|
1788
|
+
switch (parsed.action) {
|
|
1789
|
+
case "status": {
|
|
1790
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1791
|
+
if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
|
|
1792
|
+
return {
|
|
1793
|
+
kind: "success",
|
|
1794
|
+
text: t("cleanup.status", {
|
|
1795
|
+
state: t(loaded.config.enabled ? "cleanup.enabled" : "cleanup.disabled"),
|
|
1796
|
+
days: loaded.config.maxAgeDays,
|
|
1797
|
+
path: configPath,
|
|
1798
|
+
present: t(loaded.fromFile ? "cleanup.present" : "cleanup.absent")
|
|
1799
|
+
})
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
case "on":
|
|
1803
|
+
case "off": {
|
|
1804
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1805
|
+
const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, enabled: parsed.action === "on" };
|
|
1806
|
+
try {
|
|
1807
|
+
await saveCleanupConfig(configPath, next);
|
|
1808
|
+
} catch (error) {
|
|
1809
|
+
return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1810
|
+
}
|
|
1811
|
+
return { kind: "success", text: t(parsed.action === "on" ? "cleanup.onOk" : "cleanup.offOk") };
|
|
1812
|
+
}
|
|
1813
|
+
case "max-age": {
|
|
1814
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1815
|
+
const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, maxAgeDays: parsed.value };
|
|
1816
|
+
try {
|
|
1817
|
+
await saveCleanupConfig(configPath, next);
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1820
|
+
}
|
|
1821
|
+
return { kind: "success", text: t("cleanup.maxAgeOk", { days: parsed.value }) };
|
|
1822
|
+
}
|
|
1823
|
+
case "run": {
|
|
1824
|
+
const apply2 = parsed.apply;
|
|
1825
|
+
if (parsed.target === "current") {
|
|
1826
|
+
return handleClearCurrent(store, invocation, apply2, trackedBySession);
|
|
1827
|
+
}
|
|
1828
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1829
|
+
if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
|
|
1830
|
+
try {
|
|
1831
|
+
const report = await store.pruneStale({
|
|
1832
|
+
keepActiveId: invocation.agent.session.id,
|
|
1833
|
+
maxAgeDays: loaded.config.maxAgeDays,
|
|
1834
|
+
dryRun: !apply2
|
|
1835
|
+
});
|
|
1836
|
+
if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(dshHome), Date.now());
|
|
1837
|
+
return { kind: "success", text: formatCleanupReport(report) };
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
return { kind: "error", text: t("cleanup.runFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
function formatClearReport(report) {
|
|
1845
|
+
const key = report.dryRun ? "cleanup.clearDry" : "cleanup.clearApply";
|
|
1846
|
+
return t(key, {
|
|
1847
|
+
entries: report.entries,
|
|
1848
|
+
bytes: report.bytes
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
async function handleClearCurrent(store, invocation, apply2, trackedBySession) {
|
|
1852
|
+
const { agent } = invocation;
|
|
1853
|
+
const sessionId = agent.session.id;
|
|
1854
|
+
if (apply2) {
|
|
1855
|
+
if (agent.status !== "idle") {
|
|
1856
|
+
agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
1857
|
+
const stopped = await waitForAgentIdle(agent, invocation.signal);
|
|
1858
|
+
if (!stopped) {
|
|
1859
|
+
return { kind: "error", text: t("cleanup.clearActive", { sessionId }) };
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
if (invocation.signal.aborted) {
|
|
1863
|
+
return { kind: "error", text: t("cleanup.clearCancelled") };
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
try {
|
|
1867
|
+
const report = await store.clearSession(sessionId, { dryRun: !apply2 });
|
|
1868
|
+
if (!report.dryRun) trackedBySession.delete(sessionId);
|
|
1869
|
+
return { kind: "success", text: formatClearReport(report) };
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
return { kind: "error", text: t("cleanup.clearFailed", { detail: error instanceof Error ? error.message : String(error), sessionId }) };
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1240
1874
|
function apply(ctx, config) {
|
|
1241
|
-
const
|
|
1875
|
+
const dshHome = config?.dshHome;
|
|
1876
|
+
const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup, dshHome });
|
|
1242
1877
|
const pending = /* @__PURE__ */ new Map();
|
|
1243
1878
|
const anchorCache = /* @__PURE__ */ new WeakMap();
|
|
1244
1879
|
const inflight = /* @__PURE__ */ new Set();
|
|
1245
1880
|
const trackedBySession = /* @__PURE__ */ new Map();
|
|
1246
|
-
const statesBySession = /* @__PURE__ */ new Map();
|
|
1247
1881
|
let fsService;
|
|
1248
1882
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
1249
1883
|
const section = settingsCtx.settings.get(settingsNamespace("locale"));
|
|
@@ -1252,10 +1886,22 @@ function apply(ctx, config) {
|
|
|
1252
1886
|
}
|
|
1253
1887
|
});
|
|
1254
1888
|
ctx.effect(function* () {
|
|
1889
|
+
const rewindHandler = (invocation) => handleRewind(ctx, store, fsService, invocation, inflight);
|
|
1255
1890
|
yield ctx.commands.register({
|
|
1256
1891
|
name: "rewind",
|
|
1257
1892
|
description: t("command.description"),
|
|
1258
|
-
handler:
|
|
1893
|
+
handler: rewindHandler
|
|
1894
|
+
});
|
|
1895
|
+
yield ctx.commands.register({
|
|
1896
|
+
name: "undo",
|
|
1897
|
+
description: t("command.description"),
|
|
1898
|
+
handler: rewindHandler
|
|
1899
|
+
});
|
|
1900
|
+
yield ctx.commands.register({
|
|
1901
|
+
name: "snapshot-auto-cleanup",
|
|
1902
|
+
description: t("cleanup.description"),
|
|
1903
|
+
input: { hint: t("cleanup.inputHint") },
|
|
1904
|
+
handler: (invocation) => handleSnapshotCleanup(store, invocation, dshHome, trackedBySession)
|
|
1259
1905
|
});
|
|
1260
1906
|
}, "dsh-rewind command");
|
|
1261
1907
|
ctx.on("session/event", (session, event) => {
|
|
@@ -1265,18 +1911,14 @@ function apply(ctx, config) {
|
|
|
1265
1911
|
void (async () => {
|
|
1266
1912
|
try {
|
|
1267
1913
|
const sessionId = session.id;
|
|
1914
|
+
void maybeRunAutoCleanup(ctx, store, sessionId, dshHome);
|
|
1268
1915
|
let tracked = trackedBySession.get(sessionId);
|
|
1269
1916
|
if (tracked === void 0) {
|
|
1270
1917
|
tracked = await store.trackedPaths(sessionId);
|
|
1271
1918
|
trackedBySession.set(sessionId, tracked);
|
|
1272
1919
|
}
|
|
1273
1920
|
if (tracked.size === 0) return;
|
|
1274
|
-
|
|
1275
|
-
if (states === void 0) {
|
|
1276
|
-
states = /* @__PURE__ */ new Map();
|
|
1277
|
-
statesBySession.set(sessionId, states);
|
|
1278
|
-
}
|
|
1279
|
-
await reconcileTracked(store, sessionId, event.seq, tracked, states);
|
|
1921
|
+
await reconcileTracked(store, sessionId, event.seq, tracked);
|
|
1280
1922
|
} catch (error) {
|
|
1281
1923
|
ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1282
1924
|
}
|
|
@@ -1295,6 +1937,7 @@ function apply(ctx, config) {
|
|
|
1295
1937
|
});
|
|
1296
1938
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
1297
1939
|
try {
|
|
1940
|
+
void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id, dshHome);
|
|
1298
1941
|
await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
|
|
1299
1942
|
} catch (error) {
|
|
1300
1943
|
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|