dsh-rewind-plugin 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +72 -0
- package/README.en.md +37 -39
- package/README.md +43 -39
- package/SECURITY.md +154 -0
- package/docs/README.md +29 -0
- package/docs/architecture.md +128 -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 +150 -0
- package/docs/harness-reference.md +7 -3
- package/docs/release/release.md +69 -0
- package/docs/{release.md → release/release.zh.md} +6 -52
- package/docs/snapshot-auto-cleanup.md +71 -0
- package/docs/snapshot-auto-cleanup.zh.md +42 -0
- package/lib/index.js +520 -37
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/locales.d.ts +17 -0
- package/lib/types/snapshot-cleanup.d.ts +125 -0
- package/lib/types/snapshot.d.ts +146 -11
- package/package.json +4 -1
- package/docs/troubleshooting.md +0 -44
- /package/docs/{compat-audit.md → compat/audit.md} +0 -0
- /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
|
@@ -30,7 +30,24 @@ var en = {
|
|
|
30
30
|
"success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
|
|
31
31
|
"noUserMessages": "This session has no rewindable user messages yet.",
|
|
32
32
|
"chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
|
|
33
|
-
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
|
|
33
|
+
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
|
|
34
|
+
"cleanup.description": "Manage automatic cleanup of session snapshot backups",
|
|
35
|
+
"cleanup.inputHint": "on | off | max-age <days> | run [--apply] | status",
|
|
36
|
+
"cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s). Config: {path} ({present}).",
|
|
37
|
+
"cleanup.enabled": "enabled",
|
|
38
|
+
"cleanup.disabled": "disabled",
|
|
39
|
+
"cleanup.present": "file present",
|
|
40
|
+
"cleanup.absent": "no file \u2014 defaults",
|
|
41
|
+
"cleanup.onOk": "Auto-cleanup enabled.",
|
|
42
|
+
"cleanup.offOk": "Auto-cleanup disabled \u2014 all snapshots kept.",
|
|
43
|
+
"cleanup.maxAgeOk": "Auto-cleanup max age set to {days} day(s).",
|
|
44
|
+
"cleanup.cfgInvalid": "Snapshot cleanup config invalid: {detail}. Using the safe default (disabled).",
|
|
45
|
+
"cleanup.saveFailed": "Could not save cleanup config: {detail}.",
|
|
46
|
+
"cleanup.runDry": "Dry-run: would remove {deleted} session snapshot backup(s). Re-run with --apply to delete.",
|
|
47
|
+
"cleanup.runApply": "Removed {deleted} session snapshot backup(s), freeing {freed} bytes; {kept} kept, {remaining} bytes remain.",
|
|
48
|
+
"cleanup.runFailed": "Cleanup failed: {detail}.",
|
|
49
|
+
"cleanup.skipped": "({skipped} active session(s) skipped.)",
|
|
50
|
+
"cleanup.usage": "Usage:\n /snapshot-auto-cleanup show status\n /snapshot-auto-cleanup on|off enable/disable auto-cleanup\n /snapshot-auto-cleanup max-age <days> set the idle cutoff\n /snapshot-auto-cleanup run [--apply] dry-run, or execute with --apply"
|
|
34
51
|
};
|
|
35
52
|
var zh = {
|
|
36
53
|
"usage.title": "\u7528\u6CD5\uFF1A",
|
|
@@ -58,7 +75,24 @@ var zh = {
|
|
|
58
75
|
"success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
|
|
59
76
|
"noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
60
77
|
"chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
|
|
61
|
-
"command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09"
|
|
78
|
+
"command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
|
|
79
|
+
"cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
|
|
80
|
+
"cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] | status",
|
|
81
|
+
"cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002\u914D\u7F6E\uFF1A{path}\uFF08{present}\uFF09\u3002",
|
|
82
|
+
"cleanup.enabled": "\u5DF2\u5F00\u542F",
|
|
83
|
+
"cleanup.disabled": "\u5DF2\u5173\u95ED",
|
|
84
|
+
"cleanup.present": "\u5B58\u5728\u914D\u7F6E\u6587\u4EF6",
|
|
85
|
+
"cleanup.absent": "\u65E0\u6587\u4EF6\u2014\u2014\u4F7F\u7528\u9ED8\u8BA4\u503C",
|
|
86
|
+
"cleanup.onOk": "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406\u3002",
|
|
87
|
+
"cleanup.offOk": "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406\u2014\u2014\u4FDD\u7559\u5168\u90E8\u5FEB\u7167\u3002",
|
|
88
|
+
"cleanup.maxAgeOk": "\u5DF2\u5C06\u81EA\u52A8\u6E05\u7406\u7684\u6700\u5927\u4FDD\u7559\u5929\u6570\u8BBE\u4E3A {days} \u5929\u3002",
|
|
89
|
+
"cleanup.cfgInvalid": "\u5FEB\u7167\u6E05\u7406\u914D\u7F6E\u65E0\u6548\uFF1A{detail}\u3002\u5DF2\u4F7F\u7528\u5B89\u5168\u9ED8\u8BA4\u503C\uFF08\u5173\u95ED\uFF09\u3002",
|
|
90
|
+
"cleanup.saveFailed": "\u65E0\u6CD5\u4FDD\u5B58\u6E05\u7406\u914D\u7F6E\uFF1A{detail}\u3002",
|
|
91
|
+
"cleanup.runDry": "\u9884\u6F14\uFF1A\u5C06\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\u3002",
|
|
92
|
+
"cleanup.runApply": "\u5DF2\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\uFF0C\u91CA\u653E {freed} \u5B57\u8282\uFF1B\u4FDD\u7559 {kept} \u4E2A\uFF0C\u5269\u4F59 {remaining} \u5B57\u8282\u3002",
|
|
93
|
+
"cleanup.runFailed": "\u6E05\u7406\u5931\u8D25\uFF1A{detail}\u3002",
|
|
94
|
+
"cleanup.skipped": "\uFF08\u8DF3\u8FC7\u4E86 {skipped} \u4E2A\u6D3B\u52A8\u4F1A\u8BDD\u3002\uFF09",
|
|
95
|
+
"cleanup.usage": "\u7528\u6CD5\uFF1A\n /snapshot-auto-cleanup \u67E5\u770B\u72B6\u6001\n /snapshot-auto-cleanup on|off \u5F00\u542F/\u5173\u95ED\u81EA\u52A8\u6E05\u7406\n /snapshot-auto-cleanup max-age <\u5929\u6570> \u8BBE\u7F6E\u5931\u6D3B\u9608\u503C\uFF08\u5929\uFF09\n /snapshot-auto-cleanup run [--apply] \u9884\u6F14\uFF0C\u6216\u52A0 --apply \u6267\u884C"
|
|
62
96
|
};
|
|
63
97
|
var HOST_DICTS = { en, zh };
|
|
64
98
|
function translate(lang, key, params = {}) {
|
|
@@ -201,6 +235,9 @@ import { homedir } from "node:os";
|
|
|
201
235
|
var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
|
|
202
236
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
203
237
|
var MAX_ANCHOR_GROUPS = 100;
|
|
238
|
+
function isLinkEntry(entry) {
|
|
239
|
+
return "ref" in entry;
|
|
240
|
+
}
|
|
204
241
|
var defaultProbe = {
|
|
205
242
|
async readText(path) {
|
|
206
243
|
try {
|
|
@@ -242,13 +279,19 @@ async function readEntry(file) {
|
|
|
242
279
|
try {
|
|
243
280
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
244
281
|
if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
|
|
245
|
-
|
|
282
|
+
const base = {
|
|
246
283
|
callId: String(parsed.callId ?? ""),
|
|
247
284
|
anchorSeq: parsed.anchorSeq,
|
|
248
285
|
path: parsed.path,
|
|
249
|
-
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
250
286
|
time: typeof parsed.time === "number" ? parsed.time : 0
|
|
251
287
|
};
|
|
288
|
+
if (typeof parsed.ref === "string") {
|
|
289
|
+
return { ...base, ref: parsed.ref };
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
...base,
|
|
293
|
+
before: typeof parsed.before === "string" ? parsed.before : null
|
|
294
|
+
};
|
|
252
295
|
} catch {
|
|
253
296
|
return void 0;
|
|
254
297
|
}
|
|
@@ -261,9 +304,42 @@ async function isLinkPath(path) {
|
|
|
261
304
|
return false;
|
|
262
305
|
}
|
|
263
306
|
}
|
|
307
|
+
function isSafeLinkRef(ref) {
|
|
308
|
+
return /^[0-9]+\/[a-zA-Z0-9._-]+\.json$/.test(ref);
|
|
309
|
+
}
|
|
310
|
+
async function dirSizeAndLastActive(dir) {
|
|
311
|
+
let size = 0;
|
|
312
|
+
let lastActiveMs = 0;
|
|
313
|
+
const visit = async (current) => {
|
|
314
|
+
let st;
|
|
315
|
+
try {
|
|
316
|
+
st = await lstat(current);
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (st.mtimeMs > lastActiveMs) lastActiveMs = st.mtimeMs;
|
|
321
|
+
if (!st.isDirectory()) {
|
|
322
|
+
size += st.size;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
let names;
|
|
326
|
+
try {
|
|
327
|
+
names = await readdir(current);
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const name2 of names) {
|
|
332
|
+
if (name2.startsWith(".")) continue;
|
|
333
|
+
await visit(join(current, name2));
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
await visit(dir);
|
|
337
|
+
return { size, lastActiveMs };
|
|
338
|
+
}
|
|
264
339
|
var SnapshotStore = class _SnapshotStore {
|
|
265
|
-
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
|
|
340
|
+
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT, opts) {
|
|
266
341
|
this.root = root;
|
|
342
|
+
this.dedup = opts?.dedup ?? true;
|
|
267
343
|
}
|
|
268
344
|
root;
|
|
269
345
|
/** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
|
|
@@ -282,6 +358,18 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
282
358
|
* it, and even then the in-process order still holds.
|
|
283
359
|
*/
|
|
284
360
|
lastEntryTime = 0;
|
|
361
|
+
/** Store options; `dedup` toggles in-place content dedup (default on). */
|
|
362
|
+
dedup;
|
|
363
|
+
/**
|
|
364
|
+
* In-memory per-path "most recent entry" for content dedup, keyed by
|
|
365
|
+
* `<sessionId>\0<path>`. Each value holds the entry's effective `before`
|
|
366
|
+
* content and its own file ref, so a new record with the same content links
|
|
367
|
+
* to the immediately-prior entry (linear chain). Seeded lazily per session
|
|
368
|
+
* from the bounded on-disk window, so dedup survives a host restart.
|
|
369
|
+
*/
|
|
370
|
+
lastEntry = /* @__PURE__ */ new Map();
|
|
371
|
+
/** Sessions whose dedup state has been seeded from disk this process. */
|
|
372
|
+
seededSessions = /* @__PURE__ */ new Set();
|
|
285
373
|
/** Absolute path of one session's snapshot directory (id sanitized). */
|
|
286
374
|
sessionDir(sessionId) {
|
|
287
375
|
return join(this.root, safeSessionId(sessionId));
|
|
@@ -290,26 +378,91 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
290
378
|
anchorDir(sessionId, anchorSeq) {
|
|
291
379
|
return join(this.sessionDir(sessionId), String(anchorSeq));
|
|
292
380
|
}
|
|
293
|
-
/**
|
|
381
|
+
/** Absolute file ref (relative to the session dir) of an entry. */
|
|
382
|
+
entryRefOf(sessionId, callId, anchorSeq) {
|
|
383
|
+
return `${anchorSeq}/${safeFileId(callId)}.json`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Seed a session's dedup state from the existing (bounded) on-disk window:
|
|
387
|
+
* scan entries newest-first and record the most recent entry per path. This
|
|
388
|
+
* makes content dedup survive a host restart within the session window. A
|
|
389
|
+
* no-op after the first seed (or when `dedup` is disabled).
|
|
390
|
+
*/
|
|
391
|
+
async ensureDedupSeeded(sessionId) {
|
|
392
|
+
if (!this.dedup || this.seededSessions.has(sessionId)) return;
|
|
393
|
+
this.seededSessions.add(sessionId);
|
|
394
|
+
try {
|
|
395
|
+
for (const entry of await this.entriesAfter(sessionId, 0)) {
|
|
396
|
+
const key = `${sessionId}\0${entry.path}`;
|
|
397
|
+
if (this.lastEntry.has(key)) continue;
|
|
398
|
+
const content = await this.resolveBefore(sessionId, entry);
|
|
399
|
+
this.lastEntry.set(key, { content, ref: this.entryRefOf(sessionId, entry.callId, entry.anchorSeq) });
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
this.seededSessions.delete(sessionId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Resolve an entry's effective `before` content, following a link chain to
|
|
407
|
+
* its terminal real snapshot. Refs are strictly backward in
|
|
408
|
+
* `(anchorSeq, time)`, so the chain is acyclic and finite. A dangling or
|
|
409
|
+
* cyclic link throws — callers fail per-file (never silently dropping the
|
|
410
|
+
* path from a restore).
|
|
411
|
+
*/
|
|
412
|
+
async resolveBefore(sessionId, entry, seen = /* @__PURE__ */ new Set()) {
|
|
413
|
+
if (!isLinkEntry(entry)) return entry.before;
|
|
414
|
+
const key = `${entry.anchorSeq}:${entry.callId}`;
|
|
415
|
+
if (seen.has(key)) throw new Error(`link cycle at ${entry.path} (${key})`);
|
|
416
|
+
seen.add(key);
|
|
417
|
+
if (!isSafeLinkRef(entry.ref)) throw new Error(`unsafe link ref ${entry.ref} for ${entry.path}`);
|
|
418
|
+
const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref));
|
|
419
|
+
if (referenced === void 0) throw new Error(`dangling link ${entry.ref} for ${entry.path}`);
|
|
420
|
+
return this.resolveBefore(sessionId, referenced, seen);
|
|
421
|
+
}
|
|
422
|
+
/** Commit one before-backup (or an in-place dedup link) under its anchor. */
|
|
294
423
|
async recordEntry(sessionId, entry, opts) {
|
|
295
424
|
const time = Math.max(Date.now(), this.lastEntryTime + 1);
|
|
296
425
|
this.lastEntryTime = time;
|
|
426
|
+
await this.ensureDedupSeeded(sessionId);
|
|
297
427
|
const dir = this.anchorDir(sessionId, entry.anchorSeq);
|
|
298
428
|
await mkdir(dir, { recursive: true });
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
429
|
+
const file = join(dir, `${safeFileId(entry.callId)}.json`);
|
|
430
|
+
const selfRef = this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
|
|
431
|
+
const key = `${sessionId}\0${entry.path}`;
|
|
432
|
+
const prior = this.lastEntry.get(key);
|
|
433
|
+
if (this.dedup && opts?.dedup !== false && prior !== void 0 && prior.content === entry.before) {
|
|
434
|
+
const committed = {
|
|
435
|
+
callId: entry.callId,
|
|
436
|
+
anchorSeq: entry.anchorSeq,
|
|
437
|
+
path: entry.path,
|
|
438
|
+
ref: prior.ref,
|
|
439
|
+
time
|
|
440
|
+
};
|
|
441
|
+
await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
|
|
442
|
+
this.lastEntry.set(key, { content: prior.content, ref: selfRef });
|
|
443
|
+
} else {
|
|
444
|
+
const committed = { ...entry, time };
|
|
445
|
+
await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
|
|
446
|
+
this.lastEntry.set(key, { content: entry.before, ref: selfRef });
|
|
447
|
+
}
|
|
307
448
|
const now = Date.now();
|
|
308
449
|
if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
|
|
309
450
|
this.lastPruneAt = now;
|
|
310
451
|
await this.prune(sessionId);
|
|
311
452
|
}
|
|
312
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* The effective content recorded by the path's MOST RECENT entry, or
|
|
456
|
+
* undefined when the path has never been recorded (a fresh tracking sight).
|
|
457
|
+
* This is the single in-memory "last known state" the boundary uses to
|
|
458
|
+
* decide whether a tracked file changed — the same source `recordEntry`
|
|
459
|
+
* dedups against, so there is one content copy and one comparison per
|
|
460
|
+
* decision, not two. Seeding is idempotent (once per session from disk).
|
|
461
|
+
*/
|
|
462
|
+
async lastKnownContent(sessionId, path) {
|
|
463
|
+
await this.ensureDedupSeeded(sessionId);
|
|
464
|
+
return this.lastEntry.get(`${sessionId}\0${path}`)?.content;
|
|
465
|
+
}
|
|
313
466
|
/**
|
|
314
467
|
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
315
468
|
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
@@ -379,32 +532,46 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
379
532
|
* @param sessionId - session whose snapshot store to plan against.
|
|
380
533
|
* @param targetSeq - rewind target; entries anchored at/after it apply.
|
|
381
534
|
* @param probe - current-disk state probe (defaults to the real FS).
|
|
382
|
-
* @returns the planned actions
|
|
535
|
+
* @returns the planned actions, the link paths skipped, and per-file failures.
|
|
383
536
|
*/
|
|
384
537
|
async planRestore(sessionId, targetSeq, probe) {
|
|
385
538
|
const actions = [];
|
|
386
539
|
const skipped = [];
|
|
540
|
+
const failed = [];
|
|
387
541
|
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
388
542
|
try {
|
|
389
543
|
if (await probe.isLink(entry.path)) {
|
|
390
544
|
skipped.push(entry.path);
|
|
391
545
|
continue;
|
|
392
546
|
}
|
|
547
|
+
let before;
|
|
548
|
+
try {
|
|
549
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
393
554
|
const current = await probe.readText(entry.path);
|
|
394
|
-
if (
|
|
555
|
+
if (before === null) {
|
|
395
556
|
if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
|
|
396
|
-
} else if (current !==
|
|
397
|
-
actions.push({ path: entry.path, action: "restore", before
|
|
557
|
+
} else if (current !== before) {
|
|
558
|
+
actions.push({ path: entry.path, action: "restore", before });
|
|
398
559
|
}
|
|
399
560
|
} catch (error) {
|
|
400
|
-
|
|
561
|
+
let before;
|
|
562
|
+
try {
|
|
563
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
564
|
+
} catch {
|
|
565
|
+
before = null;
|
|
566
|
+
}
|
|
567
|
+
if (before === null) {
|
|
401
568
|
actions.push({ path: entry.path, action: "delete" });
|
|
402
569
|
} else {
|
|
403
|
-
actions.push({ path: entry.path, action: "restore", before
|
|
570
|
+
actions.push({ path: entry.path, action: "restore", before });
|
|
404
571
|
}
|
|
405
572
|
}
|
|
406
573
|
}
|
|
407
|
-
return { actions, skipped };
|
|
574
|
+
return { actions, skipped, failed };
|
|
408
575
|
}
|
|
409
576
|
/** Per-file restore impact: only actions that would actually change the disk. */
|
|
410
577
|
async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
|
|
@@ -436,8 +603,9 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
436
603
|
const deleted = [];
|
|
437
604
|
const skipped = [];
|
|
438
605
|
const failed = [];
|
|
439
|
-
const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
|
|
606
|
+
const { actions, skipped: skippedPaths, failed: planFailed } = await this.planRestore(sessionId, targetSeq, probe);
|
|
440
607
|
skipped.push(...skippedPaths);
|
|
608
|
+
failed.push(...planFailed);
|
|
441
609
|
if (actions.length === 0) return { restored, deleted, skipped, failed };
|
|
442
610
|
const journal = await this.beginRestore(sessionId, targetSeq, actions, probe);
|
|
443
611
|
for (let i = 0; i < actions.length; i++) {
|
|
@@ -840,8 +1008,21 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
840
1008
|
* recycles terminal restore journals (see {@link pruneTerminalJournals}),
|
|
841
1009
|
* so the per-commit cap bounds BOTH the checkpoint entries and the journal
|
|
842
1010
|
* accumulation.
|
|
1011
|
+
*
|
|
1012
|
+
* Because dedup links reference prior entries, eviction is LINK-AWARE: before
|
|
1013
|
+
* deleting the oldest groups, any SURVIVING (kept-group) link whose `ref`
|
|
1014
|
+
* lands on a real snapshot inside a doomed group is MATERIALIZED (rewritten
|
|
1015
|
+
* as a real snapshot carrying the resolved content), so no kept link is left
|
|
1016
|
+
* dangling. Links form a linear predecessor chain, so materializing the first
|
|
1017
|
+
* link after each doomed real is enough — later links already point at that
|
|
1018
|
+
* materialized entry (or at other kept links), requiring no rewrite.
|
|
1019
|
+
*
|
|
1020
|
+
* `opts.crash` is the test-only seam: a crash fired inside a materialization
|
|
1021
|
+
* write (between its temp write and rename) leaves ONLY a `.tmp` — the doomed
|
|
1022
|
+
* real is still on disk and the kept link still resolves, so nothing dangles
|
|
1023
|
+
* and a later prune simply re-materializes.
|
|
843
1024
|
*/
|
|
844
|
-
async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
|
|
1025
|
+
async prune(sessionId, keep = MAX_ANCHOR_GROUPS, opts) {
|
|
845
1026
|
const sessionDir = this.sessionDir(sessionId);
|
|
846
1027
|
let names;
|
|
847
1028
|
try {
|
|
@@ -854,7 +1035,34 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
854
1035
|
const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
|
|
855
1036
|
const excess = seqs.length - keep;
|
|
856
1037
|
if (excess <= 0) return;
|
|
857
|
-
|
|
1038
|
+
const doomed = new Set(seqs.slice(0, excess));
|
|
1039
|
+
for (const seq of seqs.slice(excess)) {
|
|
1040
|
+
const files = await readdir(this.anchorDir(sessionId, seq)).catch(() => []);
|
|
1041
|
+
for (const file of files) {
|
|
1042
|
+
if (!file.endsWith(".json")) continue;
|
|
1043
|
+
const entry = await readEntry(join(this.anchorDir(sessionId, seq), file));
|
|
1044
|
+
if (entry === void 0 || !isLinkEntry(entry)) continue;
|
|
1045
|
+
if (!isSafeLinkRef(entry.ref)) continue;
|
|
1046
|
+
const slash = entry.ref.indexOf("/");
|
|
1047
|
+
const refAnchor = slash === -1 ? Number.NaN : Number(entry.ref.slice(0, slash));
|
|
1048
|
+
if (!Number.isSafeInteger(refAnchor) || !doomed.has(refAnchor)) continue;
|
|
1049
|
+
let before;
|
|
1050
|
+
try {
|
|
1051
|
+
before = await this.resolveBefore(sessionId, entry);
|
|
1052
|
+
} catch {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const real = {
|
|
1056
|
+
callId: entry.callId,
|
|
1057
|
+
anchorSeq: entry.anchorSeq,
|
|
1058
|
+
path: entry.path,
|
|
1059
|
+
before,
|
|
1060
|
+
time: entry.time
|
|
1061
|
+
};
|
|
1062
|
+
await writeJsonAtomic(join(this.anchorDir(sessionId, seq), file), real, () => opts?.crash?.("after-temp-write"));
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
for (const seq of doomed) {
|
|
858
1066
|
await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
|
|
859
1067
|
}
|
|
860
1068
|
}
|
|
@@ -889,6 +1097,77 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
889
1097
|
throw error;
|
|
890
1098
|
}
|
|
891
1099
|
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Cross-session retention sweep: remove WHOLE session directories whose
|
|
1102
|
+
* newest member stamp is older than `maxAgeDays` days of idle, keeping the
|
|
1103
|
+
* active session (`keepActiveId`) untouched. This is the anti-growth policy
|
|
1104
|
+
* for finished sessions (rewind only ever reads the active session, so a
|
|
1105
|
+
* finished session's backups are provably dead weight).
|
|
1106
|
+
*
|
|
1107
|
+
* SAFETY:
|
|
1108
|
+
* - Only whole session directories are removed (dedup refs are
|
|
1109
|
+
* session-relative, so there is no cross-session dangling to materialize);
|
|
1110
|
+
* - the active session is never targeted (`keepActiveId`), and everything
|
|
1111
|
+
* else is protected by its own mtime — a session that is still written to
|
|
1112
|
+
* keeps scrolling its newest member stamp forward, so it is never old
|
|
1113
|
+
* enough to be pruned;
|
|
1114
|
+
* - a non-positive `maxAgeDays` throws instead of degenerating into a
|
|
1115
|
+
* mass-destructive `cutoff` in the far future;
|
|
1116
|
+
* - the walk uses `lstat` (no symlink following) and skips dot-prefixed
|
|
1117
|
+
* temp left overs, so measurement stays inside the store root.
|
|
1118
|
+
*
|
|
1119
|
+
* `dryRun` computes and reports exactly what would be removed without
|
|
1120
|
+
* deleting anything — the `/snapshot-auto-cleanup run` preview.
|
|
1121
|
+
*/
|
|
1122
|
+
async pruneStale(opts) {
|
|
1123
|
+
const { keepActiveId, dryRun = false } = opts;
|
|
1124
|
+
const maxAgeDays = opts.maxAgeDays;
|
|
1125
|
+
if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) {
|
|
1126
|
+
throw new RangeError("pruneStale: maxAgeDays must be a positive finite number");
|
|
1127
|
+
}
|
|
1128
|
+
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
1129
|
+
let scanned = 0;
|
|
1130
|
+
let deleted = 0;
|
|
1131
|
+
let freedBytes = 0;
|
|
1132
|
+
let kept = 0;
|
|
1133
|
+
let skippedActive = 0;
|
|
1134
|
+
let remainingBytes = 0;
|
|
1135
|
+
const report = () => ({ scanned, deleted, freedBytes, kept, remainingBytes, skippedActive, dryRun });
|
|
1136
|
+
let names;
|
|
1137
|
+
try {
|
|
1138
|
+
names = await readdir(this.root);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (error.code === "ENOENT") return report();
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
for (const name2 of names) {
|
|
1144
|
+
if (name2.startsWith(".")) continue;
|
|
1145
|
+
const full = join(this.root, name2);
|
|
1146
|
+
let st;
|
|
1147
|
+
try {
|
|
1148
|
+
st = await lstat(full);
|
|
1149
|
+
} catch {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (!st.isDirectory()) continue;
|
|
1153
|
+
scanned++;
|
|
1154
|
+
if (keepActiveId !== void 0 && safeSessionId(keepActiveId) === name2) {
|
|
1155
|
+
skippedActive++;
|
|
1156
|
+
remainingBytes += (await dirSizeAndLastActive(full)).size;
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const { size, lastActiveMs } = await dirSizeAndLastActive(full);
|
|
1160
|
+
if (lastActiveMs < cutoffMs) {
|
|
1161
|
+
deleted++;
|
|
1162
|
+
freedBytes += size;
|
|
1163
|
+
if (!dryRun) await rm(full, { recursive: true, force: true });
|
|
1164
|
+
} else {
|
|
1165
|
+
kept++;
|
|
1166
|
+
remainingBytes += size;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return report();
|
|
1170
|
+
}
|
|
892
1171
|
/**
|
|
893
1172
|
* All distinct paths ever recorded for a session — the "tracked files"
|
|
894
1173
|
* set. Mirrors Claude Code's global `trackedFiles` collection (files stay
|
|
@@ -906,22 +1185,21 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
906
1185
|
function hashPath(path) {
|
|
907
1186
|
return createHash("sha256").update(path).digest("hex").slice(0, 8);
|
|
908
1187
|
}
|
|
909
|
-
async function reconcileTracked(store, sessionId, anchorSeq, tracked,
|
|
1188
|
+
async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = defaultProbe) {
|
|
910
1189
|
let recorded = 0;
|
|
911
1190
|
for (const path of tracked) {
|
|
912
1191
|
try {
|
|
913
1192
|
if (await probe.isLink(path)) continue;
|
|
914
1193
|
const current = await probe.readText(path);
|
|
915
1194
|
const state = current ?? null;
|
|
916
|
-
const
|
|
917
|
-
if (
|
|
1195
|
+
const last = await store.lastKnownContent(sessionId, path);
|
|
1196
|
+
if (last === void 0 || last !== state) {
|
|
918
1197
|
await store.recordEntry(sessionId, {
|
|
919
1198
|
callId: `recheck-${anchorSeq}-${hashPath(path)}`,
|
|
920
1199
|
anchorSeq,
|
|
921
1200
|
path,
|
|
922
1201
|
before: state
|
|
923
|
-
});
|
|
924
|
-
states.set(path, state);
|
|
1202
|
+
}, { dedup: false });
|
|
925
1203
|
recorded++;
|
|
926
1204
|
}
|
|
927
1205
|
} catch {
|
|
@@ -930,6 +1208,130 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, pr
|
|
|
930
1208
|
return recorded;
|
|
931
1209
|
}
|
|
932
1210
|
|
|
1211
|
+
// src/snapshot-cleanup.ts
|
|
1212
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1213
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
1214
|
+
import { homedir as homedir2 } from "node:os";
|
|
1215
|
+
var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
|
|
1216
|
+
var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
|
|
1217
|
+
var DEFAULT_MAX_AGE_DAYS = 30;
|
|
1218
|
+
var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
|
|
1219
|
+
var AUTO_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
1220
|
+
function resolveCleanupConfigPath() {
|
|
1221
|
+
return process.env[CLEANUP_CONFIG_ENV] ?? join2(homedir2(), ".dsh", CLEANUP_CONFIG_FILENAME);
|
|
1222
|
+
}
|
|
1223
|
+
var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
|
|
1224
|
+
function resolveCleanupStatePath() {
|
|
1225
|
+
return join2(dirname2(resolveCleanupConfigPath()), STATE_FILENAME);
|
|
1226
|
+
}
|
|
1227
|
+
async function loadLastSweepAt(path) {
|
|
1228
|
+
try {
|
|
1229
|
+
const raw = JSON.parse(await readFile2(path, "utf8"));
|
|
1230
|
+
const value = raw["lastSweepAt"];
|
|
1231
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1232
|
+
} catch {
|
|
1233
|
+
return 0;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
async function saveLastSweepAt(path, ms) {
|
|
1237
|
+
const tmp = `${path}.tmp`;
|
|
1238
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1239
|
+
await writeFile2(tmp, JSON.stringify({ lastSweepAt: ms }), "utf8");
|
|
1240
|
+
await rename2(tmp, path);
|
|
1241
|
+
}
|
|
1242
|
+
async function runAutoCleanupCheck(deps, sessionId) {
|
|
1243
|
+
try {
|
|
1244
|
+
const loaded = await loadCleanupConfig(deps.configPath);
|
|
1245
|
+
if (!loaded.ok) {
|
|
1246
|
+
deps.log(`[dsh-rewind] snapshot cleanup config invalid; auto-cleanup skipped: ${loaded.error}`);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (!loaded.config.enabled) return;
|
|
1250
|
+
if (!shouldRunAutoSweep(await loadLastSweepAt(deps.statePath), Date.now())) return;
|
|
1251
|
+
await deps.pruner.pruneStale({ keepActiveId: sessionId, maxAgeDays: loaded.config.maxAgeDays });
|
|
1252
|
+
await saveLastSweepAt(deps.statePath, Date.now());
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
deps.log(`[dsh-rewind] snapshot auto-cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
function parseCleanupConfig(raw) {
|
|
1258
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1259
|
+
return { ok: false, error: "config must be a JSON object" };
|
|
1260
|
+
}
|
|
1261
|
+
const record = raw;
|
|
1262
|
+
let enabled = DEFAULT_CLEANUP_CONFIG.enabled;
|
|
1263
|
+
let maxAgeDays = DEFAULT_CLEANUP_CONFIG.maxAgeDays;
|
|
1264
|
+
if (record["enabled"] !== void 0) {
|
|
1265
|
+
if (typeof record["enabled"] !== "boolean") return { ok: false, error: '"enabled" must be a boolean' };
|
|
1266
|
+
enabled = record["enabled"];
|
|
1267
|
+
}
|
|
1268
|
+
if (record["maxAgeDays"] !== void 0) {
|
|
1269
|
+
const value = record["maxAgeDays"];
|
|
1270
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
1271
|
+
return { ok: false, error: '"maxAgeDays" must be a positive integer' };
|
|
1272
|
+
}
|
|
1273
|
+
maxAgeDays = value;
|
|
1274
|
+
}
|
|
1275
|
+
return { ok: true, config: { enabled, maxAgeDays } };
|
|
1276
|
+
}
|
|
1277
|
+
async function loadCleanupConfig(path) {
|
|
1278
|
+
let text;
|
|
1279
|
+
try {
|
|
1280
|
+
text = await readFile2(path, "utf8");
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
if (error.code === "ENOENT") {
|
|
1283
|
+
return { ok: true, config: { ...DEFAULT_CLEANUP_CONFIG }, fromFile: false };
|
|
1284
|
+
}
|
|
1285
|
+
return { ok: false, error: `config file unreadable: ${error instanceof Error ? error.message : String(error)}` };
|
|
1286
|
+
}
|
|
1287
|
+
let raw;
|
|
1288
|
+
try {
|
|
1289
|
+
raw = JSON.parse(text);
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
return { ok: false, error: `config file is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
1292
|
+
}
|
|
1293
|
+
const parsed = parseCleanupConfig(raw);
|
|
1294
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
1295
|
+
return { ok: true, config: parsed.config, fromFile: true };
|
|
1296
|
+
}
|
|
1297
|
+
async function saveCleanupConfig(path, config) {
|
|
1298
|
+
if (typeof config.enabled !== "boolean" || !Number.isInteger(config.maxAgeDays) || config.maxAgeDays <= 0) {
|
|
1299
|
+
throw new RangeError("invalid cleanup config: enabled must be a boolean and maxAgeDays a positive integer");
|
|
1300
|
+
}
|
|
1301
|
+
const tmp = `${path}.tmp`;
|
|
1302
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1303
|
+
await writeFile2(tmp, JSON.stringify(config, null, 2), "utf8");
|
|
1304
|
+
await rename2(tmp, path);
|
|
1305
|
+
}
|
|
1306
|
+
function parseCleanupCommand(rawInput) {
|
|
1307
|
+
const parts = rawInput.trim().split(/\s+/).filter(Boolean);
|
|
1308
|
+
if (parts.length === 0) return { action: "status" };
|
|
1309
|
+
switch (parts[0]) {
|
|
1310
|
+
case "status":
|
|
1311
|
+
return parts.length === 1 ? { action: "status" } : { error: "usage: /snapshot-auto-cleanup status" };
|
|
1312
|
+
case "on":
|
|
1313
|
+
return parts.length === 1 ? { action: "on" } : { error: "usage: /snapshot-auto-cleanup on" };
|
|
1314
|
+
case "off":
|
|
1315
|
+
return parts.length === 1 ? { action: "off" } : { error: "usage: /snapshot-auto-cleanup off" };
|
|
1316
|
+
case "max-age": {
|
|
1317
|
+
if (parts.length !== 2) return { error: "usage: /snapshot-auto-cleanup max-age <days>" };
|
|
1318
|
+
const days = Number(parts[1]);
|
|
1319
|
+
if (!Number.isInteger(days) || days <= 0) return { error: '"max-age" must be a positive integer (days)' };
|
|
1320
|
+
return { action: "max-age", value: days };
|
|
1321
|
+
}
|
|
1322
|
+
case "run": {
|
|
1323
|
+
if (parts.length === 1) return { action: "run" };
|
|
1324
|
+
if (parts.length === 2 && parts[1] === "--apply") return { action: "run-apply" };
|
|
1325
|
+
return { error: "usage: /snapshot-auto-cleanup run [--apply]" };
|
|
1326
|
+
}
|
|
1327
|
+
default:
|
|
1328
|
+
return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
function shouldRunAutoSweep(lastAtMs, nowMs) {
|
|
1332
|
+
return nowMs - lastAtMs >= AUTO_SWEEP_INTERVAL_MS;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
933
1335
|
// src/index.ts
|
|
934
1336
|
var name = "dsh-rewind";
|
|
935
1337
|
var inject = ["commands", "tools"];
|
|
@@ -1237,13 +1639,91 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
|
1237
1639
|
}
|
|
1238
1640
|
return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
|
|
1239
1641
|
}
|
|
1642
|
+
var autoSweepChecked = false;
|
|
1643
|
+
async function maybeRunAutoCleanup(ctx, store, sessionId) {
|
|
1644
|
+
if (autoSweepChecked) return;
|
|
1645
|
+
autoSweepChecked = true;
|
|
1646
|
+
await runAutoCleanupCheck({
|
|
1647
|
+
pruner: store,
|
|
1648
|
+
configPath: resolveCleanupConfigPath(),
|
|
1649
|
+
statePath: resolveCleanupStatePath(),
|
|
1650
|
+
log: (msg) => ctx.logger.warn(msg)
|
|
1651
|
+
}, sessionId);
|
|
1652
|
+
}
|
|
1653
|
+
function formatCleanupReport(report) {
|
|
1654
|
+
const key = report.dryRun ? "cleanup.runDry" : "cleanup.runApply";
|
|
1655
|
+
const text = t(key, {
|
|
1656
|
+
deleted: report.deleted,
|
|
1657
|
+
freed: report.freedBytes,
|
|
1658
|
+
kept: report.kept,
|
|
1659
|
+
remaining: report.remainingBytes
|
|
1660
|
+
});
|
|
1661
|
+
return report.skippedActive > 0 ? `${text}
|
|
1662
|
+
${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
|
|
1663
|
+
}
|
|
1664
|
+
async function handleSnapshotCleanup(store, invocation) {
|
|
1665
|
+
const parsed = parseCleanupCommand(invocation.rawInput);
|
|
1666
|
+
if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
|
|
1667
|
+
const configPath = resolveCleanupConfigPath();
|
|
1668
|
+
switch (parsed.action) {
|
|
1669
|
+
case "status": {
|
|
1670
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1671
|
+
if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
|
|
1672
|
+
return {
|
|
1673
|
+
kind: "success",
|
|
1674
|
+
text: t("cleanup.status", {
|
|
1675
|
+
state: t(loaded.config.enabled ? "cleanup.enabled" : "cleanup.disabled"),
|
|
1676
|
+
days: loaded.config.maxAgeDays,
|
|
1677
|
+
path: configPath,
|
|
1678
|
+
present: t(loaded.fromFile ? "cleanup.present" : "cleanup.absent")
|
|
1679
|
+
})
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
case "on":
|
|
1683
|
+
case "off": {
|
|
1684
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1685
|
+
const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, enabled: parsed.action === "on" };
|
|
1686
|
+
try {
|
|
1687
|
+
await saveCleanupConfig(configPath, next);
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1690
|
+
}
|
|
1691
|
+
return { kind: "success", text: t(parsed.action === "on" ? "cleanup.onOk" : "cleanup.offOk") };
|
|
1692
|
+
}
|
|
1693
|
+
case "max-age": {
|
|
1694
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1695
|
+
const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, maxAgeDays: parsed.value };
|
|
1696
|
+
try {
|
|
1697
|
+
await saveCleanupConfig(configPath, next);
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1700
|
+
}
|
|
1701
|
+
return { kind: "success", text: t("cleanup.maxAgeOk", { days: parsed.value }) };
|
|
1702
|
+
}
|
|
1703
|
+
case "run":
|
|
1704
|
+
case "run-apply": {
|
|
1705
|
+
const loaded = await loadCleanupConfig(configPath);
|
|
1706
|
+
if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
|
|
1707
|
+
try {
|
|
1708
|
+
const report = await store.pruneStale({
|
|
1709
|
+
keepActiveId: invocation.agent.session.id,
|
|
1710
|
+
maxAgeDays: loaded.config.maxAgeDays,
|
|
1711
|
+
dryRun: parsed.action === "run"
|
|
1712
|
+
});
|
|
1713
|
+
if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(), Date.now());
|
|
1714
|
+
return { kind: "success", text: formatCleanupReport(report) };
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
return { kind: "error", text: t("cleanup.runFailed", { detail: error instanceof Error ? error.message : String(error) }) };
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1240
1721
|
function apply(ctx, config) {
|
|
1241
|
-
const store = new SnapshotStore(config?.snapshotDir);
|
|
1722
|
+
const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup });
|
|
1242
1723
|
const pending = /* @__PURE__ */ new Map();
|
|
1243
1724
|
const anchorCache = /* @__PURE__ */ new WeakMap();
|
|
1244
1725
|
const inflight = /* @__PURE__ */ new Set();
|
|
1245
1726
|
const trackedBySession = /* @__PURE__ */ new Map();
|
|
1246
|
-
const statesBySession = /* @__PURE__ */ new Map();
|
|
1247
1727
|
let fsService;
|
|
1248
1728
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
1249
1729
|
const section = settingsCtx.settings.get(settingsNamespace("locale"));
|
|
@@ -1257,6 +1737,12 @@ function apply(ctx, config) {
|
|
|
1257
1737
|
description: t("command.description"),
|
|
1258
1738
|
handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
|
|
1259
1739
|
});
|
|
1740
|
+
yield ctx.commands.register({
|
|
1741
|
+
name: "snapshot-auto-cleanup",
|
|
1742
|
+
description: t("cleanup.description"),
|
|
1743
|
+
input: { hint: t("cleanup.inputHint") },
|
|
1744
|
+
handler: (invocation) => handleSnapshotCleanup(store, invocation)
|
|
1745
|
+
});
|
|
1260
1746
|
}, "dsh-rewind command");
|
|
1261
1747
|
ctx.on("session/event", (session, event) => {
|
|
1262
1748
|
if (event.type !== "user/message") return;
|
|
@@ -1265,18 +1751,14 @@ function apply(ctx, config) {
|
|
|
1265
1751
|
void (async () => {
|
|
1266
1752
|
try {
|
|
1267
1753
|
const sessionId = session.id;
|
|
1754
|
+
void maybeRunAutoCleanup(ctx, store, sessionId);
|
|
1268
1755
|
let tracked = trackedBySession.get(sessionId);
|
|
1269
1756
|
if (tracked === void 0) {
|
|
1270
1757
|
tracked = await store.trackedPaths(sessionId);
|
|
1271
1758
|
trackedBySession.set(sessionId, tracked);
|
|
1272
1759
|
}
|
|
1273
1760
|
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);
|
|
1761
|
+
await reconcileTracked(store, sessionId, event.seq, tracked);
|
|
1280
1762
|
} catch (error) {
|
|
1281
1763
|
ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1282
1764
|
}
|
|
@@ -1295,6 +1777,7 @@ function apply(ctx, config) {
|
|
|
1295
1777
|
});
|
|
1296
1778
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
1297
1779
|
try {
|
|
1780
|
+
void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id);
|
|
1298
1781
|
await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
|
|
1299
1782
|
} catch (error) {
|
|
1300
1783
|
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|