dsh-rewind-plugin 0.11.0 → 0.12.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.
@@ -13,8 +13,10 @@
13
13
  * around-dispatch stage, so an approval `ask` short-circuit cannot skip it
14
14
  * and a denied call never records.
15
15
  * - The entry is committed to disk at `tools/post-execute` under the turn's
16
- * anchor seq: `<root>/<sessionId>/<anchorSeq>/<callId>.json`, carrying the
17
- * path and the before content (`before: null` = the file was created).
16
+ * anchor seq: `<root>/<sessionId>/<anchorSeq>/<callId>.json` carries the
17
+ * metadata, and the before content lives beside it as a RAW BYTE sidecar
18
+ * (`<callId>.before`, copied with `copyFile`). Content never travels through
19
+ * a JS string, so binary and non-UTF-8 files round-trip byte-exactly.
18
20
  * - Because entries live on disk under the dsh data directory, they survive a
19
21
  * host restart, are bounded (the newest 100 anchor groups per session are
20
22
  * kept), and restores read/write the real file system with plain `node:fs`
@@ -26,17 +28,18 @@
26
28
  * file (e.g. `.env`) is a model-permission concern (see `SECURITY.md`).
27
29
  *
28
30
  * Crash safety (this module's own engineering asset):
29
- * - Checkpoint commits are ATOMIC: the entry JSON is written to a sibling
30
- * temp file and renamed over the target, so a host crash mid-write can
31
- * never leave a readable half-written entry at worst an inert `.tmp`
32
- * leftover that the next commit of the same file overwrites and that no
33
- * reader ever picks up.
31
+ * - Checkpoint commits are ATOMIC: the sidecar is fully written first, then
32
+ * the entry JSON is written to a sibling temp file and renamed over the
33
+ * target, so a host crash mid-write can never leave a readable entry
34
+ * without its bytes at worst an unreferenced orphan sidecar, or an inert
35
+ * `.tmp` leftover that the next commit of the same file overwrites and that
36
+ * no reader ever picks up.
34
37
  * - Every restore pass is JOURNALED. Before mutating anything the store
35
- * captures the pre-restore ("rescue") state of each planned path and
36
- * persists an intent journal (`restore-journal-<op>.json` in the session
37
- * dir), then marks each action done as it is applied. A crash at any point
38
- * leaves the journal on disk; after a host restart
39
- * `reconcileRestores(sessionId)` re-derives from the REAL disk which
38
+ * captures the pre-restore ("rescue") state of each planned path as a raw
39
+ * byte copy and persists an intent journal (`journal-<op>.json` in the
40
+ * session dir) holding only references, then marks each action done as it
41
+ * is applied. A crash at any point leaves the journal on disk; after a host
42
+ * restart `reconcileRestores(sessionId)` re-derives from the REAL disk which
40
43
  * paths already match the target and which are still pending (reporting
41
44
  * "restored up to where, what changed"), auto-heals journals whose goal is
42
45
  * already reached, and `continueRestore` / `rollbackRestore` finish the
@@ -51,6 +54,15 @@
51
54
  * creation. Symlinked and hard-linked paths are skipped and reported, never
52
55
  * written through.
53
56
  *
57
+ * Format compatibility: entries written before this module stored bytes
58
+ * (released v1: `{callId, anchorSeq, path, before: string | null}` plus the
59
+ * `restore-journal-` prefix) are still READ — their string content is the
60
+ * exact UTF-8 bytes it always was, except for records that were decoded
61
+ * lossily (they contain U+FFFD: comparable, but never written back). New
62
+ * writes are always the byte format; the marker contract that keeps a
63
+ * downgraded v1 build from touching the workspace lives in
64
+ * `tests/downgrade-safety.test.ts`.
65
+ *
54
66
  * @module dsh-rewind/snapshot
55
67
  */
56
68
  /**
@@ -64,6 +76,66 @@ export declare const DEFAULT_SNAPSHOT_ROOT: string;
64
76
  export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
65
77
  /** Number of newest anchor groups (user messages) kept per session. */
66
78
  export declare const MAX_ANCHOR_GROUPS = 100;
79
+ /**
80
+ * Current on-disk store format version (the session's `store` marker value and
81
+ * the `store` field of every entry this build writes). A value ABOVE this one
82
+ * means the snapshots were written by a NEWER build: readers must then fail
83
+ * closed (no file restore, nothing changed) instead of guessing what the extra
84
+ * fields mean. A missing marker (or `1`) means the released v1 string format,
85
+ * which is still read.
86
+ */
87
+ export declare const CURRENT_STORE_VERSION = 2;
88
+ /**
89
+ * Thrown when snapshots carry a store format newer than this build understands
90
+ * (ADR-10: whole-operation fail-closed — never a partial restore, never a
91
+ * clear). The session rewind itself does not depend on snapshots and still
92
+ * works.
93
+ */
94
+ export declare class UnknownStoreVersionError extends Error {
95
+ /** The version found on disk. */
96
+ readonly version: number;
97
+ /** Where it was found (a marker or an entry file). */
98
+ readonly source: string;
99
+ constructor(
100
+ /** The version found on disk. */
101
+ version: number,
102
+ /** Where it was found (a marker or an entry file). */
103
+ source: string);
104
+ }
105
+ /**
106
+ * Recorded before-content — always bytes, never a decoded string:
107
+ *
108
+ * - `blob`: a raw byte copy inside the store (the format every new write uses).
109
+ * - `text`: the exact UTF-8 bytes of a released-v1 string record (a v1 record
110
+ * that was decoded from valid UTF-8 is byte-exact, so restoring it is safe).
111
+ * - `lossyText`: a released-v1 string record that contains U+FFFD, i.e. one
112
+ * the v1 build produced by a LOSSY decode of non-UTF-8 bytes. The original
113
+ * bytes are unknowable, so it can be compared against the disk but must
114
+ * never be written back as a restore target (that would destroy live data).
115
+ */
116
+ export type ByteSource = {
117
+ readonly kind: 'blob';
118
+ readonly path: string;
119
+ } | {
120
+ readonly kind: 'text';
121
+ readonly bytes: Buffer;
122
+ } | {
123
+ readonly kind: 'lossyText';
124
+ readonly text: string;
125
+ };
126
+ /** A staged raw byte copy waiting to be committed (`recordBackup`). */
127
+ export interface PendingBackup {
128
+ /** Absolute path of the staged file (inside the session's `.pending/`). */
129
+ readonly file: string;
130
+ /** Byte size of the staged content. */
131
+ readonly size: number;
132
+ /**
133
+ * Permission bits of the source file (`stat().mode & 0o7777`), captured
134
+ * best-effort. Restored alongside the content — never on its own, and never
135
+ * as part of the change decision (ADR-9).
136
+ */
137
+ readonly mode?: number;
138
+ }
67
139
  /** One committed before-backup, keyed by tool call. */
68
140
  export interface CheckpointEntry {
69
141
  readonly callId: string;
@@ -71,22 +143,51 @@ export interface CheckpointEntry {
71
143
  readonly anchorSeq: number;
72
144
  /** Resolved display path (absolute) of the tracked file. */
73
145
  readonly path: string;
74
- /** Full content before the change; null when the file was created. */
75
- readonly before: string | null;
146
+ /** Byte source of the content before the change; null when the file was created. */
147
+ readonly before: ByteSource | null;
148
+ /** Byte size of `before` (0 when the file was created). */
149
+ readonly size: number;
150
+ /**
151
+ * Checkpoint-time location pin: the `realpath` of `dirname(path)` at the
152
+ * moment the entry was committed. A restore refuses the path when its parent
153
+ * directory no longer resolves there, because a repointed or moved ancestor
154
+ * would otherwise redirect the write (or the unlink) outside the recorded
155
+ * location — only the path's FINAL component is link-checked. Absent means
156
+ * "no pin": a released-v1 entry, a v2 entry written before this field
157
+ * existed, or a commit whose parent could not be resolved; the restore then
158
+ * falls back to the final-component check alone.
159
+ */
160
+ readonly parent?: string;
161
+ /**
162
+ * Permission bits recorded at capture time, when known. Applied only when
163
+ * the CONTENT is restored (a mode-only difference is never a reason to plan
164
+ * a restore); a missing value means "leave the live mode alone".
165
+ */
166
+ readonly mode?: number;
167
+ /**
168
+ * Set when this entry's sidecar holds re-encoded LOSSY text (a released-v1
169
+ * record that was decoded with replacement characters, e.g. after `prune`
170
+ * materialized its link). The bytes are comparable but must never be written
171
+ * back: a reader turns such an entry into a `lossyText` source again.
172
+ */
173
+ readonly lossy?: boolean;
76
174
  /** Epoch ms the entry was committed (stable ordering within a group). */
77
175
  readonly time: number;
176
+ /**
177
+ * Absolute file this entry was READ from (in-memory only, never serialized):
178
+ * a dedup reference must name the file that actually exists, which for an
179
+ * entry written by an older build is not necessarily the name the current
180
+ * naming function would produce.
181
+ */
182
+ readonly file?: string;
78
183
  }
79
184
  /**
80
185
  * One in-place dedup link, keyed by tool call. When a tracked file is
81
186
  * recorded with a `before` content identical to the immediately-prior entry
82
187
  * for that path, the entry is stored as a LINK instead of a full copy: it
83
- * carries no `before`, only a `ref` naming the prior entry file
188
+ * carries no content, only a `ref` naming the prior entry file
84
189
  * (`<anchorSeq>/<callId>.json`). The linear (predecessor-chained) ref makes
85
190
  * restore resolution and prune materialization rewrite-free.
86
- *
87
- * The real-entry format ({@link CheckpointEntry}) is unchanged so existing
88
- * data reads identically; links are a NEW entry kind only the current build
89
- * understands (old-build reads of links are explicitly out of scope).
90
191
  */
91
192
  export interface LinkEntry {
92
193
  readonly callId: string;
@@ -94,7 +195,15 @@ export interface LinkEntry {
94
195
  readonly path: string;
95
196
  /** `<anchorSeq>/<callId>.json` of the immediately-prior entry for the path. */
96
197
  readonly ref: string;
198
+ /**
199
+ * Checkpoint-time location pin (see {@link CheckpointEntry.parent}); a link
200
+ * records its own, so a materialized or resolved entry never loses the
201
+ * location the path was committed under.
202
+ */
203
+ readonly parent?: string;
97
204
  readonly time: number;
205
+ /** Absolute file this link was read from (in-memory only, never serialized). */
206
+ readonly file?: string;
98
207
  }
99
208
  /** Any on-disk entry: a full before-backup or an in-place dedup link. */
100
209
  export type StoredEntry = CheckpointEntry | LinkEntry;
@@ -150,15 +259,25 @@ export interface RestoreJournalAction {
150
259
  readonly path: string;
151
260
  readonly action: 'restore' | 'delete';
152
261
  /** Target content for a restore; null for a delete. */
153
- readonly before: string | null;
262
+ readonly before: ByteSource | null;
154
263
  /**
155
- * Pre-restore disk state ("rescue"): the content the file had right before
156
- * the restore started, or null when it was absent. Rollback writes this
157
- * back, so the pre-restore state is recoverable exactly.
264
+ * Pre-restore disk state ("rescue"): a raw byte copy of what the file had
265
+ * right before the restore started, or null when it was absent. Rollback
266
+ * writes this back, so the pre-restore state is recoverable exactly.
158
267
  */
159
- readonly rescue: string | null;
268
+ readonly rescue: ByteSource | null;
160
269
  /** Set when the rescue capture failed: rollback then skips this path. */
161
270
  rescueError?: string;
271
+ /** Permission bits the restored content should end up with, when recorded. */
272
+ readonly mode?: number;
273
+ /**
274
+ * Checkpoint-time location pin copied from the entry the action was planned
275
+ * from (see {@link CheckpointEntry.parent}): a continue or rollback after a
276
+ * restart re-checks it before touching the path.
277
+ */
278
+ readonly parent?: string;
279
+ /** Permission bits the file had BEFORE the restore, for a faithful rollback. */
280
+ readonly rescueMode?: number;
162
281
  /** True once the action's fs op completed and was marked. */
163
282
  done: boolean;
164
283
  /** Per-action failure message; the restore pass never aborts. */
@@ -166,7 +285,8 @@ export interface RestoreJournalAction {
166
285
  }
167
286
  /** Durable journal for one attempted restore (written atomically). */
168
287
  export interface RestoreJournal {
169
- readonly version: 1;
288
+ /** On-disk schema version: 2 = byte references, 1 = inline legacy strings. */
289
+ readonly version: 1 | 2;
170
290
  readonly id: string;
171
291
  readonly sessionId: string;
172
292
  readonly targetSeq: number;
@@ -176,6 +296,13 @@ export interface RestoreJournal {
176
296
  readonly actions: RestoreJournalAction[];
177
297
  /** Set when a rollback pass failed partway (state becomes `recovery-required`). */
178
298
  rollbackError?: string;
299
+ /**
300
+ * Absolute file this journal was read from (in-memory only, never
301
+ * serialized): a legacy `restore-journal-` file is updated IN PLACE so an
302
+ * op that was interrupted before the upgrade never ends up with two
303
+ * divergent versions on disk.
304
+ */
305
+ sourceFile?: string;
179
306
  }
180
307
  /**
181
308
  * Result of reconciling one interrupted restore journal against the real
@@ -206,32 +333,58 @@ export interface RestoreReconcileReport {
206
333
  readonly corrupt?: string;
207
334
  }
208
335
  /**
209
- * Current-on-disk state probe used by restore planning. Injected so the plan
210
- * logic runs against a fake FS in tests; the production default reads the
211
- * real file system with plain `node:fs` (see {@link defaultProbe}).
336
+ * Current-on-disk state probe used by restore planning and reconciliation.
337
+ * Injected so the logic runs against a fake FS in tests; the production
338
+ * default reads the real file system with plain `node:fs` (see
339
+ * {@link defaultProbe}) and compares byte streams, never whole files in memory.
212
340
  */
213
341
  export interface DiskProbe {
214
342
  /**
215
- * Full text of the file, or undefined when the file does not exist.
216
- * Any thrown error is treated as a probe failure: restore planning then
217
- * conservatively treats the file as DIFFERING from its record (a restore
218
- * still attempts the write / a delete still attempts the unlink), so an
219
- * unreadable file is never silently skipped.
343
+ * Compare the recorded content with the file currently at `path`.
344
+ *
345
+ * - `true` = the disk matches the record byte-for-byte (for a `null`
346
+ * source: the path is absent).
347
+ * - `false` = it differs — including "the record says the file did not
348
+ * exist but it does" and "the record has content but the file is gone".
349
+ * - `undefined` = the comparison could not be decided (IO/permission
350
+ * failure). Callers stay conservative: a restore is still attempted and a
351
+ * delete still attempted, so an unreadable file is never silently skipped.
352
+ *
353
+ * A `lossyText` source is compared with the same lossy decode the released
354
+ * v1 build used (its original bytes cannot be recovered).
355
+ */
356
+ matches(source: ByteSource | null, path: string): Promise<boolean | undefined>;
357
+ /**
358
+ * Stage a raw byte copy of the file at `path` into `dest` (the store's
359
+ * rescue area) without loading it into memory.
220
360
  */
221
- readText(path: string): Promise<string | undefined>;
361
+ copy(path: string, dest: string): Promise<CopyOutcome>;
222
362
  /** True when the path is a symlink or a hard link (never planned/restored). */
223
363
  isLink(path: string): Promise<boolean>;
224
364
  }
365
+ /** Result of staging one on-disk byte copy. */
366
+ export type CopyOutcome = {
367
+ readonly kind: 'copied';
368
+ readonly size: number;
369
+ } | {
370
+ readonly kind: 'absent';
371
+ } | {
372
+ readonly kind: 'failed';
373
+ readonly message: string;
374
+ };
225
375
  /** One restore action the planner derived from record + disk reconciliation. */
226
376
  export type PlannedAction = {
227
377
  readonly path: string;
228
378
  readonly action: 'restore';
229
- readonly before: string;
379
+ readonly before: ByteSource;
380
+ readonly mode?: number;
381
+ readonly parent?: string;
230
382
  } | {
231
383
  readonly path: string;
232
384
  readonly action: 'delete';
385
+ readonly parent?: string;
233
386
  };
234
- /** Production probe: real reads via node:fs, links detected by lstat + nlink. */
387
+ /** Production probe: real byte comparisons via node:fs, links via lstat + nlink. */
235
388
  export declare const defaultProbe: DiskProbe;
236
389
  /**
237
390
  * Result of a stale-session cleanup sweep ({@link SnapshotStore.pruneStale}).
@@ -289,6 +442,10 @@ export interface ClearSessionReport {
289
442
  export declare class SnapshotStore {
290
443
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
291
444
  private static readonly PRUNE_INTERVAL_MS;
445
+ /** Session-format-version marker file inside the session dir. Non-`.json`, so it never counts as a checkpoint entry. */
446
+ private static readonly FORMAT_FILE;
447
+ /** Plugin STORE-format marker file inside the session dir (non-`.json`, same reasoning). */
448
+ private static readonly STORE_FILE;
292
449
  private lastPruneAt;
293
450
  /**
294
451
  * Monotonic entry clock. Date.now() has 1ms precision, so back-to-back
@@ -309,14 +466,18 @@ export declare class SnapshotStore {
309
466
  readonly root: string;
310
467
  /**
311
468
  * In-memory per-path "most recent entry" for content dedup, keyed by
312
- * `<sessionId>\0<path>`. Each value holds the entry's effective `before`
313
- * content and its own file ref, so a new record with the same content links
314
- * to the immediately-prior entry (linear chain). Seeded lazily per session
315
- * from the bounded on-disk window, so dedup survives a host restart.
469
+ * `<sessionId>\0<path>`. Each value holds the entry's effective byte source
470
+ * (a handle, not a copy) and its own file ref, so a new record with the same
471
+ * content links to the immediately-prior entry (linear chain). Seeded lazily
472
+ * per session from the bounded on-disk window, so dedup survives a host
473
+ * restart. A handle whose bytes vanished (pruned out of band) is treated as
474
+ * "never recorded" — dedup then stores MORE, never less.
316
475
  */
317
476
  private readonly lastEntry;
318
477
  /** Sessions whose dedup state has been seeded from disk this process. */
319
478
  private readonly seededSessions;
479
+ /** Sessions whose store-format marker this process has already stamped. */
480
+ private readonly storeStamped;
320
481
  /**
321
482
  * Session-format version snapshots are anchored under, stamped into each
322
483
  * session's `format` marker when an entry is recorded. `null` until the host
@@ -334,6 +495,30 @@ export declare class SnapshotStore {
334
495
  anchorDir(sessionId: string, anchorSeq: number): string;
335
496
  /** Absolute file ref (relative to the session dir) of an entry. */
336
497
  private entryRefOf;
498
+ /**
499
+ * The session-relative ref of an entry READ from disk: the file that really
500
+ * holds it. A v1 entry keeps its released name, so recomputing the name from
501
+ * the call id would produce a dangling reference.
502
+ */
503
+ private refOfRead;
504
+ /** Drop every in-memory trace of one session (its directory is gone). */
505
+ private forgetSession;
506
+ /**
507
+ * Forget in-memory state for sessions whose directory no longer exists —
508
+ * after a sweep, or after the user removed a session dir out of band. A
509
+ * stale handle is SAFE (dedup and the boundary both fail toward storing
510
+ * more), but keeping it means the store holds state for a session it deleted
511
+ * and skips re-stamping that session's `format`/`store` markers.
512
+ */
513
+ private forgetMissingSessions;
514
+ /**
515
+ * Stage a capture slot for one tool call: create the session's `.pending/`
516
+ * area and return the absolute path the caller copies the before-bytes into
517
+ * (never through memory). The slot lives inside the session dir so the
518
+ * commit can `rename` it into the anchor group atomically; a slot that is
519
+ * never committed is either unlinked by its caller or collected by `prune`.
520
+ */
521
+ stageCapture(sessionId: string, key: string): Promise<string>;
337
522
  /**
338
523
  * Seed a session's dedup state from the existing (bounded) on-disk window:
339
524
  * scan entries newest-first and record the most recent entry per path. This
@@ -349,20 +534,76 @@ export declare class SnapshotStore {
349
534
  * path from a restore).
350
535
  */
351
536
  private resolveBefore;
352
- /** Commit one before-backup (or an in-place dedup link) under its anchor. */
353
- recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>, opts?: {
537
+ /**
538
+ * Validate a real entry's byte source against the store's own files: a
539
+ * sidecar that is missing, not a regular file, or a different size than the
540
+ * metadata records is an INTEGRITY failure (thrown), never a silent skip and
541
+ * never a fallback to "the file was created" — a restore must not delete a
542
+ * file whose backup it cannot read.
543
+ */
544
+ private validatedSource;
545
+ /**
546
+ * True when two recorded byte sources are the same content. Comparison is
547
+ * STREAMING (size first, then chunks) so large files never enter memory.
548
+ * Any unreadable handle — or any legacy lossy source, whose original bytes
549
+ * are unknowable — answers `false`: dedup must fail toward storing more,
550
+ * never toward claiming "unchanged".
551
+ */
552
+ private sourcesMatch;
553
+ /**
554
+ * Write raw bytes to a sidecar path atomically (temp + rename): a crash
555
+ * between the steps leaves only a `.tmp` that no reader picks up.
556
+ */
557
+ private writeSidecar;
558
+ /**
559
+ * Place one entry's sidecar next to its entry file: MOVE a staged capture
560
+ * (same filesystem, atomic) or write the bytes from a source. Returns the
561
+ * blob source and its size, or null for a created file. The sidecar is
562
+ * always complete before the entry JSON is written.
563
+ */
564
+ private placeSidecar;
565
+ /**
566
+ * Commit one entry (a full before-backup or an in-place dedup link) under
567
+ * its anchor group.
568
+ */
569
+ private commit;
570
+ /**
571
+ * Commit one before-backup whose content the caller already holds as raw
572
+ * text (the boundary-friendly API: tests, synthetic records). The bytes are
573
+ * encoded UTF-8, exactly as the released v1 build did for text content.
574
+ */
575
+ recordEntry(sessionId: string, entry: {
576
+ readonly callId: string;
577
+ readonly anchorSeq: number;
578
+ readonly path: string;
579
+ readonly before: string | null;
580
+ }, opts?: {
354
581
  readonly dedup?: boolean;
355
582
  readonly crash?: (point: CrashPoint) => void;
356
583
  }): Promise<void>;
357
584
  /**
358
- * The effective content recorded by the path's MOST RECENT entry, or
359
- * undefined when the path has never been recorded (a fresh tracking sight).
360
- * This is the single in-memory "last known state" the boundary uses to
361
- * decide whether a tracked file changed — the same source `recordEntry`
362
- * dedups against, so there is one content copy and one comparison per
363
- * decision, not two. Seeding is idempotent (once per session from disk).
585
+ * Commit one before-backup whose content is an existing byte file (the
586
+ * capture and boundary paths): `backup.file` is MOVED into the anchor group
587
+ * (same filesystem, so this is atomic), or `null` when the file did not
588
+ * exist a creation.
364
589
  */
365
- lastKnownContent(sessionId: string, path: string): Promise<string | null | undefined>;
590
+ recordBackup(sessionId: string, entry: {
591
+ readonly callId: string;
592
+ readonly anchorSeq: number;
593
+ readonly path: string;
594
+ }, backup: PendingBackup | null, opts?: {
595
+ readonly dedup?: boolean;
596
+ readonly crash?: (point: CrashPoint) => void;
597
+ }): Promise<void>;
598
+ /**
599
+ * The byte source recorded by the path's MOST RECENT entry, or undefined
600
+ * when the path has never been recorded (a fresh tracking sight). This is
601
+ * the single in-memory "last known state" the boundary compares the disk
602
+ * against — the same source `recordEntry` dedups against, so there is one
603
+ * handle and one comparison per decision, not two. Seeding is idempotent
604
+ * (once per session from disk).
605
+ */
606
+ lastKnownContent(sessionId: string, path: string): Promise<ByteSource | null | undefined>;
366
607
  /**
367
608
  * All committed entries anchored at or after `targetSeq`, newest first (for
368
609
  * preview ordering). The boundary is inclusive: rewinding to a message also
@@ -390,9 +631,15 @@ export declare class SnapshotStore {
390
631
  * `delete` ONLY when the file currently exists; an already-absent file
391
632
  * is a no-op — this kills the "ghost impact" of replaying an entry a
392
633
  * previous rewind already consumed.
393
- * - `before === 'X'` plans a `restore` ONLY when the current content
394
- * differs from X (or the file is missing); identical content is a no-op
395
- * this keeps repeated rewinds idempotent.
634
+ * - a recorded byte source plans a `restore` ONLY when the current bytes
635
+ * differ from it (or the file is missing); identical bytes are a no-op
636
+ * this keeps repeated rewinds idempotent.
637
+ * - A released-v1 record that lost bytes to a lossy decode (`lossyText`) is
638
+ * compared with the same lossy decode but NEVER written back: a skip is
639
+ * reported instead of destroying live bytes with U+FFFD content.
640
+ * - An unreadable / unresolvable record is a per-file FAILURE, never a
641
+ * delete: planning a delete for a file we cannot restore is the one
642
+ * mistake that loses data.
396
643
  * - Symlinked / hard-linked paths are never planned (they are reported as
397
644
  * skipped by the restore pass, never written through).
398
645
  * - A probe failure (e.g. a permission error reading the file) plans the
@@ -428,24 +675,31 @@ export declare class SnapshotStore {
428
675
  * restore (it degrades to a journal-less pass).
429
676
  */
430
677
  restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile, probe?: DiskProbe, opts?: RestoreRunOptions): Promise<RestoreOutcome>;
431
- /** Prefix of one restore-op journal file inside the session dir. */
432
- private static readonly JOURNAL_PREFIX;
433
- /** Session-format-version marker file inside the session dir. Non-`.json`, so it never counts as a checkpoint entry. */
434
- private static readonly FORMAT_FILE;
435
- /** Absolute path of one restore-op journal file. */
678
+ /** Absolute path of one restore-op journal file (the current prefix). */
436
679
  private journalPath;
680
+ /**
681
+ * Locate an existing journal file for an op: the current prefix first, then
682
+ * the prefix the released v1 build wrote (a restore interrupted before the
683
+ * upgrade must still be continuable / rollbackable).
684
+ */
685
+ private findJournalFile;
437
686
  /**
438
687
  * Best-effort journal persist: journal IO failures are non-fatal by design —
439
688
  * a restore must never fail because its audit journal could not be written.
440
689
  * reconcileRestores() re-derives the true state from the disk, so a missing
441
690
  * or stale journal only loses the trail, never the recovery ability.
691
+ *
692
+ * A journal read back from a legacy file is rewritten IN PLACE (same file),
693
+ * so a redo / rollback of a pre-upgrade op never leaves two divergent
694
+ * versions of the same op on disk.
442
695
  */
443
696
  private saveJournal;
444
697
  /**
445
698
  * Journal one restore pass before mutating anything: capture the rescue
446
- * (pre-restore) state of every planned path and persist the intent
447
- * atomically. Returns the in-memory journal; a persist failure degrades to
448
- * a journal-less restore (non-fatal, see {@link saveJournal}).
699
+ * (pre-restore) state of every planned path as a raw byte copy and persist
700
+ * the intent (references only) atomically. Returns the in-memory journal; a
701
+ * persist failure degrades to a journal-less restore (non-fatal, see
702
+ * {@link saveJournal}).
449
703
  */
450
704
  private beginRestore;
451
705
  /**
@@ -455,23 +709,32 @@ export declare class SnapshotStore {
455
709
  */
456
710
  private readJournal;
457
711
  /**
458
- * Every journal file of a session — valid ones plus corrupt ones with their
459
- * error — so reconciliation can report corruption instead of dropping it.
712
+ * Every journal file of a session (both prefixes) — valid ones plus corrupt
713
+ * ones with their error — so reconciliation can report corruption instead of
714
+ * dropping it.
460
715
  */
461
716
  private listJournals;
462
717
  /**
463
718
  * Execute ONE fs mutation with exactly the pre-journal semantics: a delete
464
719
  * runs through the injected deleteFile (ENOENT tolerated — the file is
465
- * already absent, i.e. the target state is reached), a restore is a plain
466
- * writeFile with a recursive mkdir of the parent. Returns how the outcome
467
- * should record it.
720
+ * already absent, i.e. the target state is reached), a restore copies the
721
+ * recorded bytes back over the file (creating the parent if needed).
468
722
  *
469
723
  * This is the only place the store writes restored content to the real FS,
470
- * and it is deliberately a raw `writeFile`/`unlink` rather than the fs
471
- * service: the caller only ever hands it a path from `planRestore` — one the
472
- * session's own write-class tool call recorded and resolved (never a
724
+ * and it is deliberately raw `copyFile`/`writeFile`/`unlink` rather than the
725
+ * fs service: the caller only ever hands it a path from `planRestore` — one
726
+ * the session's own write-class tool call recorded and resolved (never a
473
727
  * symlink/hard link) and only when it differs from the live disk. So no
474
728
  * arbitrary path, no model input, never automatic.
729
+ *
730
+ * The write is IN PLACE (no temp + rename): it keeps the file's inode and
731
+ * thus its xattrs/ACL, and crash safety is provided by the journal plus disk
732
+ * reconciliation instead (a half-written file simply does not match the
733
+ * goal, so a redo rewrites it).
734
+ *
735
+ * Permissions are best-effort (ADR-9/R3): the mode is only ever applied as
736
+ * part of a CONTENT restore (never as a reason to plan one), and a chmod
737
+ * failure never fails the restore.
475
738
  */
476
739
  private applyActionToDisk;
477
740
  /**
@@ -483,6 +746,13 @@ export declare class SnapshotStore {
483
746
  * terminal state and not reported. A corrupt journal is reported
484
747
  * `recovery-required` — never silently dropped.
485
748
  *
749
+ * Deliberately NOT gated on the session's `store` marker: a journal is fully
750
+ * self-describing (`version` plus byte references), and refusing to finish an
751
+ * interrupted op merely because the SESSION marker looks newer would strand a
752
+ * half-restored workspace — the outcome the legacy-journal support exists to
753
+ * prevent. A reference the newer build moved shows up as a per-file failure,
754
+ * never as a silent write.
755
+ *
486
756
  * @param sessionId - session whose journals to reconcile.
487
757
  * @param probe - current-disk state probe (defaults to the real FS).
488
758
  * @returns one report per non-terminal journal still needing attention.
@@ -498,7 +768,7 @@ export declare class SnapshotStore {
498
768
  */
499
769
  private reconcileJournal;
500
770
  /**
501
- * 补做 (redo) an interrupted restore: finish the op by applying every action
771
+ * Continue (redo) an interrupted restore: finish the op by applying every action
502
772
  * whose disk state does not yet match its goal — the restore target for
503
773
  * `running` journals. Actions are decided by the REAL disk (the same "disk
504
774
  * is truth" rule as reconciliation), so a crash between an fs op and its
@@ -509,7 +779,7 @@ export declare class SnapshotStore {
509
779
  */
510
780
  continueRestore(sessionId: string, opId: string, deleteFile: DeleteFile, probe?: DiskProbe, opts?: RestoreRunOptions): Promise<RestoreOutcome>;
511
781
  /**
512
- * 回滚 (roll back) an interrupted restore: undo every action whose disk
782
+ * Roll back an interrupted restore: undo every action whose disk
513
783
  * state does not match its rescue (pre-restore) record, returning the
514
784
  * workspace to the exact state it had before the restore started. Decided
515
785
  * by the REAL disk, so actions the crash left applied-but-unmarked are
@@ -543,13 +813,28 @@ export declare class SnapshotStore {
543
813
  prune(sessionId: string, keep?: number, opts?: {
544
814
  readonly crash?: (point: CrashPoint) => void;
545
815
  }): Promise<void>;
816
+ /**
817
+ * Collect staged captures that were never committed and are older than
818
+ * {@link PENDING_MAX_AGE_MS}: a crash between `tools/execute` and
819
+ * `tools/post-execute` can leak one, and the process that would have
820
+ * unlinked it is gone.
821
+ */
822
+ private prunePendingCaptures;
823
+ /**
824
+ * Anchor groups a NON-TERMINAL journal still depends on — the groups holding
825
+ * the sidecars its actions restore from. `prune` must not evict them while
826
+ * the op can still be finished. Rescue copies live under `rescue/`, never in
827
+ * an anchor group, so only `before` references matter; a group is pinned only
828
+ * for a well-formed, safe reference (a corrupt journal pins nothing).
829
+ */
830
+ private pinnedAnchors;
546
831
  /**
547
832
  * Recycle terminal restore journals (`completed` / `rolled-back`): once an
548
- * op finished, its journal's before + rescue content is dead weight that
549
- * would otherwise accumulate without bound (one journal per both-mode
550
- * rewind). Non-terminal journals (crashed ops awaiting reconcile /
551
- * continue / rollback) and unclassifiable (corrupt) ones are ALWAYS kept —
552
- * a recovery record that cannot be classified is never destroyed.
833
+ * op finished, its journal and its rescue bytes are dead weight that would
834
+ * otherwise accumulate without bound (one journal per both-mode rewind).
835
+ * Non-terminal journals (crashed ops awaiting reconcile / continue /
836
+ * rollback) and unclassifiable (corrupt) ones are ALWAYS kept — a recovery
837
+ * record that cannot be classified is never destroyed.
553
838
  */
554
839
  private pruneTerminalJournals;
555
840
  /** True when a path exists on disk (used by tests and diagnostics). */
@@ -571,7 +856,9 @@ export declare class SnapshotStore {
571
856
  * - a non-positive `maxAgeDays` throws instead of degenerating into a
572
857
  * mass-destructive `cutoff` in the far future;
573
858
  * - the walk uses `lstat` (no symlink following) and skips dot-prefixed
574
- * temp left overs, so measurement stays inside the store root.
859
+ * temp left overs except the real `.pending/` area, whose staged bytes
860
+ * are content and whose freshness is activity — so measurement stays
861
+ * inside the store root.
575
862
  *
576
863
  * `dryRun` computes and reports exactly what would be removed without
577
864
  * deleting anything — the `/snapshot-auto-cleanup run` preview.
@@ -590,11 +877,16 @@ export declare class SnapshotStore {
590
877
  trackedPaths(sessionId: string): Promise<Set<string>>;
591
878
  /**
592
879
  * Summarize a session's on-disk footprint for a clear dry-run: anchor-group
593
- * count, committed checkpoint-entry count, restore-journal count, and total
594
- * bytes. Walks with `lstat` (never follows a symlink, so a hostile symlink
595
- * cannot escape the store root or inflate the measurement) and skips
596
- * dot-prefixed temp leftovers and non-`.json` members they are never
597
- * checkpoint entries.
880
+ * count, committed checkpoint-entry count (one per `.json` in an anchor
881
+ * group), restore-journal count (both journal prefixes), and the total bytes
882
+ * the session dir occupies entry JSONs, raw byte sidecars, `rescue/**` and
883
+ * the staged `.pending/**` copies alike, so the number matches what a `du` of
884
+ * that directory reports.
885
+ *
886
+ * Walks with `lstat` (never follows a symlink, so a hostile symlink cannot
887
+ * escape the store root or inflate the measurement) and skips dot-prefixed
888
+ * temp leftovers (the one exception is `.pending/`, whose staged bytes are
889
+ * real store content).
598
890
  */
599
891
  private sessionStats;
600
892
  /**
@@ -654,6 +946,28 @@ export declare class SnapshotStore {
654
946
  * session that actually records a snapshot.
655
947
  */
656
948
  setFormatVersion(sessionVersion: number): void;
949
+ /**
950
+ * Read the plugin's STORE-format marker for a session, or null when there is
951
+ * none (a released-v1 dir, or a session that never recorded a snapshot). The
952
+ * marker is a quick session-level signal; every entry and journal is also
953
+ * self-describing (`store` / `version`), so a missing marker never changes
954
+ * how an entry is read.
955
+ */
956
+ readStoreVersion(sessionId: string): Promise<number | null>;
957
+ /**
958
+ * Stamp the store-format marker (atomically, like `format`). Written
959
+ * alongside every byte-format entry, so a session that only ever holds the
960
+ * released string format keeps no marker and is read as v1.
961
+ */
962
+ markStoreVersion(sessionId: string, storeVersion: number): Promise<void>;
963
+ /**
964
+ * Refuse to plan against (or write into) a session whose store format is
965
+ * NEWER than this build understands (ADR-10): the caller reports it and
966
+ * changes nothing — no partial restore, no clear, no v2 entry written into a
967
+ * v3 store. Checked before any entry is read, so the marker alone is enough
968
+ * to fail closed.
969
+ */
970
+ assertKnownStoreVersion(sessionId: string): Promise<void>;
657
971
  /**
658
972
  * Session-format-version guard: clear a session's snapshot dir when the
659
973
  * format its snapshots were anchored under differs from the current session
@@ -696,8 +1010,8 @@ export declare class SnapshotStore {
696
1010
  * from the recent record, so the link decision would never apply there.
697
1011
  *
698
1012
  * Symlinked / hard-linked paths are never re-checked (restores skip them).
699
- * A probe failure skips the file with a warning-level no-op; it never
700
- * aborts the boundary pass.
1013
+ * A probe failure skips the file with a warning-level no-op; it never aborts
1014
+ * the boundary pass, and it never records the path as absent.
701
1015
  *
702
1016
  * @param store - the session's snapshot store.
703
1017
  * @param sessionId - session whose tracked files to re-check.