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.
- package/CONTRIBUTING.md +4 -3
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/SECURITY.md +54 -23
- package/docs/architecture.md +12 -6
- package/docs/compat/audit.md +1 -1
- package/docs/compat/tracking-boundary.md +6 -6
- package/docs/compat/tracking-boundary.zh.md +2 -2
- package/docs/format.md +159 -53
- package/docs/snapshot-auto-cleanup.md +8 -10
- package/docs/snapshot-auto-cleanup.zh.md +2 -4
- package/lib/client.js +1 -1
- package/lib/index.js +1026 -223
- package/lib/types/locales.d.ts +1 -0
- package/lib/types/snapshot.d.ts +397 -83
- package/package.json +1 -1
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
* persists an intent journal (`
|
|
37
|
-
* dir), then marks each action done as it
|
|
38
|
-
* leaves the journal on disk; after a host
|
|
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
|
-
/**
|
|
75
|
-
readonly before:
|
|
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
|
|
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:
|
|
262
|
+
readonly before: ByteSource | null;
|
|
154
263
|
/**
|
|
155
|
-
* Pre-restore disk state ("rescue"):
|
|
156
|
-
* the restore started, or null when it was absent. Rollback
|
|
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:
|
|
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
|
-
|
|
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
|
|
210
|
-
* logic runs against a fake FS in tests; the production
|
|
211
|
-
* real file system with plain `node:fs` (see
|
|
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
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
313
|
-
*
|
|
314
|
-
* to the immediately-prior entry (linear chain). Seeded lazily
|
|
315
|
-
* from the bounded on-disk window, so dedup survives a host
|
|
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
|
-
/**
|
|
353
|
-
|
|
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
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
-
|
|
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
|
-
* -
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
447
|
-
* atomically. Returns the in-memory journal; a
|
|
448
|
-
* a journal-less restore (non-fatal, see
|
|
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
|
|
459
|
-
* error — so reconciliation can report corruption instead of
|
|
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
|
|
466
|
-
*
|
|
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
|
|
471
|
-
* service: the caller only ever hands it a path from `planRestore` — one
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
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
|
|
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
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
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
|
-
*
|
|
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.
|