dsh-rewind-plugin 0.21.0 → 0.21.1
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/lib/client.js +1 -1
- package/lib/index.js +223 -192
- package/lib/types/snapshot-format.d.ts +479 -0
- package/lib/types/snapshot-fs.d.ts +85 -0
- package/lib/types/snapshot.d.ts +4 -372
- package/package.json +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk store format — the single source of truth for what a snapshot
|
|
3
|
+
* directory looks like.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is stateless and independent of the file-system service:
|
|
6
|
+
* the naming functions, the entry/journal JSON codecs, and the ref validators
|
|
7
|
+
* that keep a hostile or corrupt record inside the store. The byte format
|
|
8
|
+
* itself is specified in `docs/format.md`; the fail-closed store-version
|
|
9
|
+
* contract is enforced by `readEntry` here and by
|
|
10
|
+
* `SnapshotStore.assertKnownStoreVersion` in `snapshot.ts`.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-rewind/snapshot-format
|
|
13
|
+
*/
|
|
14
|
+
/** Suffix of the raw byte copy holding one entry's before-content. */
|
|
15
|
+
export declare const SIDECAR_SUFFIX = ".before";
|
|
16
|
+
/** Session-dir sub-directory holding captures that are staged but not committed. */
|
|
17
|
+
export declare const PENDING_DIR = ".pending";
|
|
18
|
+
/** Session-dir sub-directory holding one pre-restore ("rescue") copy per op. */
|
|
19
|
+
export declare const RESCUE_DIR = "rescue";
|
|
20
|
+
/** Journal-file prefix of the current format. */
|
|
21
|
+
export declare const JOURNAL_PREFIX = "journal-";
|
|
22
|
+
/** Journal-file prefix written by the released v1 build (read-only compatibility). */
|
|
23
|
+
export declare const LEGACY_JOURNAL_PREFIX = "restore-journal-";
|
|
24
|
+
/**
|
|
25
|
+
* Current on-disk store format version (the session's `store` marker value and
|
|
26
|
+
* the `store` field of every entry this build writes). A value ABOVE this one
|
|
27
|
+
* means the snapshots were written by a NEWER build: readers must then fail
|
|
28
|
+
* closed (no file restore, nothing changed) instead of guessing what the extra
|
|
29
|
+
* fields mean. A missing marker (or `1`) means the released v1 string format,
|
|
30
|
+
* which is still read.
|
|
31
|
+
*/
|
|
32
|
+
export declare const CURRENT_STORE_VERSION = 2;
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when snapshots carry a store format newer than this build understands
|
|
35
|
+
* (ADR-10: whole-operation fail-closed — never a partial restore, never a
|
|
36
|
+
* clear). The session rewind itself does not depend on snapshots and still
|
|
37
|
+
* works.
|
|
38
|
+
*/
|
|
39
|
+
export declare class UnknownStoreVersionError extends Error {
|
|
40
|
+
/** The version found on disk. */
|
|
41
|
+
readonly version: number;
|
|
42
|
+
/** Where it was found (a marker or an entry file). */
|
|
43
|
+
readonly source: string;
|
|
44
|
+
constructor(
|
|
45
|
+
/** The version found on disk. */
|
|
46
|
+
version: number,
|
|
47
|
+
/** Where it was found (a marker or an entry file). */
|
|
48
|
+
source: string);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Recorded before-content — always bytes, never a decoded string:
|
|
52
|
+
*
|
|
53
|
+
* - `blob`: a raw byte copy inside the store (the format every new write uses).
|
|
54
|
+
* - `text`: the exact UTF-8 bytes of a released-v1 string record (a v1 record
|
|
55
|
+
* that was decoded from valid UTF-8 is byte-exact, so restoring it is safe).
|
|
56
|
+
* - `lossyText`: a released-v1 string record that contains U+FFFD, i.e. one
|
|
57
|
+
* the v1 build produced by a LOSSY decode of non-UTF-8 bytes. The original
|
|
58
|
+
* bytes are unknowable, so it can be compared against the disk but must
|
|
59
|
+
* never be written back as a restore target (that would destroy live data).
|
|
60
|
+
*/
|
|
61
|
+
export type ByteSource = {
|
|
62
|
+
readonly kind: 'blob';
|
|
63
|
+
readonly path: string;
|
|
64
|
+
} | {
|
|
65
|
+
readonly kind: 'text';
|
|
66
|
+
readonly bytes: Buffer;
|
|
67
|
+
} | {
|
|
68
|
+
readonly kind: 'lossyText';
|
|
69
|
+
readonly text: string;
|
|
70
|
+
};
|
|
71
|
+
/** Classify a released-v1 string record (U+FFFD means the decode lost bytes). */
|
|
72
|
+
export declare function textSourceOf(text: string): ByteSource;
|
|
73
|
+
/** A staged raw byte copy waiting to be committed (`recordBackup`). */
|
|
74
|
+
export interface PendingBackup {
|
|
75
|
+
/** Absolute path of the staged file (inside the session's `.pending/`). */
|
|
76
|
+
readonly file: string;
|
|
77
|
+
/** Byte size of the staged content. */
|
|
78
|
+
readonly size: number;
|
|
79
|
+
/**
|
|
80
|
+
* Permission bits of the source file (`stat().mode & 0o7777`), captured
|
|
81
|
+
* best-effort. Restored alongside the content — never on its own, and never
|
|
82
|
+
* as part of the change decision (ADR-9).
|
|
83
|
+
*/
|
|
84
|
+
readonly mode?: number;
|
|
85
|
+
}
|
|
86
|
+
/** One committed before-backup, keyed by tool call. */
|
|
87
|
+
export interface CheckpointEntry {
|
|
88
|
+
readonly callId: string;
|
|
89
|
+
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
90
|
+
readonly anchorSeq: number;
|
|
91
|
+
/** Resolved display path (absolute) of the tracked file. */
|
|
92
|
+
readonly path: string;
|
|
93
|
+
/** Byte source of the content before the change; null when the file was created. */
|
|
94
|
+
readonly before: ByteSource | null;
|
|
95
|
+
/** Byte size of `before` (0 when the file was created). */
|
|
96
|
+
readonly size: number;
|
|
97
|
+
/**
|
|
98
|
+
* Checkpoint-time location pin: where `dirname(path)` resolved when the entry
|
|
99
|
+
* was captured — or, when that directory was already gone, where it would
|
|
100
|
+
* have resolved (nearest surviving ancestor plus the missing segments). A
|
|
101
|
+
* restore refuses the path when its parent directory no longer resolves there,
|
|
102
|
+
* because a repointed or moved ancestor would otherwise redirect the write
|
|
103
|
+
* (or the unlink) outside the recorded location; the path's FINAL component is
|
|
104
|
+
* checked separately (see {@link isUnsafeTarget}). Absent means "no pin": a
|
|
105
|
+
* released-v1 entry or a legacy v2 entry, whose restore falls back to the
|
|
106
|
+
* final-component check alone.
|
|
107
|
+
*/
|
|
108
|
+
readonly parent?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Permission bits recorded at capture time, when known. Applied only when
|
|
111
|
+
* the CONTENT is restored (a mode-only difference is never a reason to plan
|
|
112
|
+
* a restore); a missing value means "leave the live mode alone".
|
|
113
|
+
*/
|
|
114
|
+
readonly mode?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Set when this entry's sidecar holds re-encoded LOSSY text (a released-v1
|
|
117
|
+
* record that was decoded with replacement characters, e.g. after `prune`
|
|
118
|
+
* materialized its link). The bytes are comparable but must never be written
|
|
119
|
+
* back: a reader turns such an entry into a `lossyText` source again.
|
|
120
|
+
*/
|
|
121
|
+
readonly lossy?: boolean;
|
|
122
|
+
/** Epoch ms the entry was committed (stable ordering within a group). */
|
|
123
|
+
readonly time: number;
|
|
124
|
+
/**
|
|
125
|
+
* Absolute file this entry was READ from (in-memory only, never serialized):
|
|
126
|
+
* a dedup reference must name the file that actually exists, which for an
|
|
127
|
+
* entry written by an older build is not necessarily the name the current
|
|
128
|
+
* naming function would produce.
|
|
129
|
+
*/
|
|
130
|
+
readonly file?: string;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One in-place dedup link, keyed by tool call. When a tracked file is
|
|
134
|
+
* recorded with a `before` content identical to the immediately-prior entry
|
|
135
|
+
* for that path, the entry is stored as a LINK instead of a full copy: it
|
|
136
|
+
* carries no content, only a `ref` naming the prior entry file
|
|
137
|
+
* (`<anchorSeq>/<callId>.json`). The linear (predecessor-chained) ref makes
|
|
138
|
+
* restore resolution and prune materialization rewrite-free.
|
|
139
|
+
*/
|
|
140
|
+
export interface LinkEntry {
|
|
141
|
+
readonly callId: string;
|
|
142
|
+
readonly anchorSeq: number;
|
|
143
|
+
readonly path: string;
|
|
144
|
+
/** `<anchorSeq>/<callId>.json` of the immediately-prior entry for the path. */
|
|
145
|
+
readonly ref: string;
|
|
146
|
+
/**
|
|
147
|
+
* Checkpoint-time location pin (see {@link CheckpointEntry.parent}); a link
|
|
148
|
+
* records its own, so a materialized or resolved entry never loses the
|
|
149
|
+
* location the path was committed under.
|
|
150
|
+
*/
|
|
151
|
+
readonly parent?: string;
|
|
152
|
+
readonly time: number;
|
|
153
|
+
/** Absolute file this link was read from (in-memory only, never serialized). */
|
|
154
|
+
readonly file?: string;
|
|
155
|
+
}
|
|
156
|
+
/** Any on-disk entry: a full before-backup or an in-place dedup link. */
|
|
157
|
+
export type StoredEntry = CheckpointEntry | LinkEntry;
|
|
158
|
+
/** True when an entry is a dedup link (carries `ref`, not `before`). */
|
|
159
|
+
export declare function isLinkEntry(entry: StoredEntry): entry is LinkEntry;
|
|
160
|
+
/** Per-file restore impact preview (served by the host preview route). */
|
|
161
|
+
export interface FileImpact {
|
|
162
|
+
readonly path: string;
|
|
163
|
+
/** `restore` = write the before content back; `delete` = remove the file. */
|
|
164
|
+
readonly action: 'restore' | 'delete';
|
|
165
|
+
}
|
|
166
|
+
/** A recorded path whose current on-disk size is over the backup cap. */
|
|
167
|
+
export interface UncoveredPath {
|
|
168
|
+
readonly path: string;
|
|
169
|
+
/** Byte size observed on disk, when the probe could read one. */
|
|
170
|
+
readonly size?: number;
|
|
171
|
+
}
|
|
172
|
+
/** Outcome of one restore pass. */
|
|
173
|
+
export interface RestoreOutcome {
|
|
174
|
+
readonly restored: readonly string[];
|
|
175
|
+
readonly deleted: readonly string[];
|
|
176
|
+
/** Symlinked or hard-linked paths left untouched. */
|
|
177
|
+
readonly skipped: readonly string[];
|
|
178
|
+
readonly failed: readonly {
|
|
179
|
+
path: string;
|
|
180
|
+
message: string;
|
|
181
|
+
}[];
|
|
182
|
+
}
|
|
183
|
+
/** Deletes one file by its real path (node:fs, bypassing the fs service). */
|
|
184
|
+
export type DeleteFile = (path: string) => Promise<void>;
|
|
185
|
+
/**
|
|
186
|
+
* Test-only fault injection: a crash point inside the write/restore paths.
|
|
187
|
+
* The hook THROWS to simulate a host crash at the exact point; the throw
|
|
188
|
+
* propagates out of the store method, leaving the journal on disk in its
|
|
189
|
+
* current state. Production callers never pass it (undefined = no-op).
|
|
190
|
+
*/
|
|
191
|
+
export type CrashPoint = 'before-action' | 'after-action' | 'after-temp-write';
|
|
192
|
+
/** Options for the journaled restore paths; `crash` is the test-only seam. */
|
|
193
|
+
export interface RestoreRunOptions {
|
|
194
|
+
/**
|
|
195
|
+
* Throws at the given point to simulate a host crash: `before-action`
|
|
196
|
+
* (before an action's fs op), `after-action` (right after the fs op,
|
|
197
|
+
* before its done-mark is persisted), `after-temp-write` (inside an atomic
|
|
198
|
+
* commit, between the temp write and the rename). `index` is the action
|
|
199
|
+
* index for the restore loops.
|
|
200
|
+
*/
|
|
201
|
+
readonly crash?: (point: CrashPoint, index?: number) => void;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Lifecycle of one restore operation journal. Terminal states are kept on
|
|
205
|
+
* disk as a tiny audit trail and skipped by reconciliation.
|
|
206
|
+
*/
|
|
207
|
+
export type RestoreJournalState = 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'recovery-required';
|
|
208
|
+
/**
|
|
209
|
+
* One journaled restore action — a mutable working record that the restore
|
|
210
|
+
* loop updates (done/failed) as it applies the pass.
|
|
211
|
+
*/
|
|
212
|
+
export interface RestoreJournalAction {
|
|
213
|
+
readonly path: string;
|
|
214
|
+
readonly action: 'restore' | 'delete';
|
|
215
|
+
/** Target content for a restore; null for a delete. */
|
|
216
|
+
readonly before: ByteSource | null;
|
|
217
|
+
/**
|
|
218
|
+
* Pre-restore disk state ("rescue"): a raw byte copy of what the file had
|
|
219
|
+
* right before the restore started, or null when it was absent. Rollback
|
|
220
|
+
* writes this back, so the pre-restore state is recoverable exactly.
|
|
221
|
+
*/
|
|
222
|
+
readonly rescue: ByteSource | null;
|
|
223
|
+
/** Set when the rescue capture failed: rollback then skips this path. */
|
|
224
|
+
rescueError?: string;
|
|
225
|
+
/** Permission bits the restored content should end up with, when recorded. */
|
|
226
|
+
readonly mode?: number;
|
|
227
|
+
/**
|
|
228
|
+
* Checkpoint-time location pin copied from the entry the action was planned
|
|
229
|
+
* from (see {@link CheckpointEntry.parent}): a continue or rollback after a
|
|
230
|
+
* restart re-checks it before touching the path.
|
|
231
|
+
*/
|
|
232
|
+
readonly parent?: string;
|
|
233
|
+
/** Permission bits the file had BEFORE the restore, for a faithful rollback. */
|
|
234
|
+
readonly rescueMode?: number;
|
|
235
|
+
/** True once the action's fs op completed and was marked. */
|
|
236
|
+
done: boolean;
|
|
237
|
+
/** Per-action failure message; the restore pass never aborts. */
|
|
238
|
+
failed?: string;
|
|
239
|
+
}
|
|
240
|
+
/** Durable journal for one attempted restore (written atomically). */
|
|
241
|
+
export interface RestoreJournal {
|
|
242
|
+
/** On-disk schema version: 2 = byte references, 1 = inline legacy strings. */
|
|
243
|
+
readonly version: 1 | 2;
|
|
244
|
+
readonly id: string;
|
|
245
|
+
readonly sessionId: string;
|
|
246
|
+
readonly targetSeq: number;
|
|
247
|
+
readonly startedAt: number;
|
|
248
|
+
finishedAt?: number;
|
|
249
|
+
state: RestoreJournalState;
|
|
250
|
+
readonly actions: RestoreJournalAction[];
|
|
251
|
+
/** Set when a rollback pass failed partway (state becomes `recovery-required`). */
|
|
252
|
+
rollbackError?: string;
|
|
253
|
+
/**
|
|
254
|
+
* Absolute file this journal was read from (in-memory only, never
|
|
255
|
+
* serialized): a legacy `restore-journal-` file is updated IN PLACE so an
|
|
256
|
+
* op that was interrupted before the upgrade never ends up with two
|
|
257
|
+
* divergent versions on disk.
|
|
258
|
+
*/
|
|
259
|
+
sourceFile?: string;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Result of reconciling one interrupted restore journal against the real
|
|
263
|
+
* disk. Path status is relative to the op's current goal: the restore target
|
|
264
|
+
* for `running` journals, the pre-restore (rescue) state for
|
|
265
|
+
* `rollback-running` / `recovery-required` journals — disambiguate with
|
|
266
|
+
* {@link RestoreReconcileReport.journalState}.
|
|
267
|
+
*/
|
|
268
|
+
export interface RestoreReconcileReport {
|
|
269
|
+
readonly opId: string;
|
|
270
|
+
/** `interrupted` = a crash left the op unfinished; `recovery-required` = a rollback could not complete. */
|
|
271
|
+
readonly state: 'interrupted' | 'recovery-required';
|
|
272
|
+
/** Raw journal state (`running` | `rollback-running` | `recovery-required`). */
|
|
273
|
+
readonly journalState: RestoreJournalState;
|
|
274
|
+
readonly targetSeq: number;
|
|
275
|
+
readonly startedAt: number;
|
|
276
|
+
/** Paths whose disk already matches the op's goal. */
|
|
277
|
+
readonly restored: readonly string[];
|
|
278
|
+
/** Paths still short of the op's goal (not yet applied / not yet rolled back). */
|
|
279
|
+
readonly pending: readonly string[];
|
|
280
|
+
/** Actions that failed during the pass (kept failed until a redo succeeds). */
|
|
281
|
+
readonly failed: readonly {
|
|
282
|
+
path: string;
|
|
283
|
+
message: string;
|
|
284
|
+
}[];
|
|
285
|
+
readonly rollbackError?: string;
|
|
286
|
+
/** Set when the journal file itself is corrupt: it cannot be reconciled. */
|
|
287
|
+
readonly corrupt?: string;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Current-on-disk state probe used by restore planning and reconciliation.
|
|
291
|
+
* Injected so the logic runs against a fake FS in tests; the production
|
|
292
|
+
* default reads the real file system with plain `node:fs` (see
|
|
293
|
+
* {@link defaultProbe}) and compares byte streams, never whole files in memory.
|
|
294
|
+
*/
|
|
295
|
+
export interface DiskProbe {
|
|
296
|
+
/**
|
|
297
|
+
* Compare the recorded content with the file currently at `path`.
|
|
298
|
+
*
|
|
299
|
+
* - `true` = the disk matches the record byte-for-byte (for a `null`
|
|
300
|
+
* source: the path is absent).
|
|
301
|
+
* - `false` = it differs — including "the record says the file did not
|
|
302
|
+
* exist but it does" and "the record has content but the file is gone".
|
|
303
|
+
* - `undefined` = the comparison could not be decided (IO/permission
|
|
304
|
+
* failure). Callers stay conservative: a restore is still attempted and a
|
|
305
|
+
* delete still attempted, so an unreadable file is never silently skipped.
|
|
306
|
+
*
|
|
307
|
+
* A `lossyText` source is compared with the same lossy decode the released
|
|
308
|
+
* v1 build used (its original bytes cannot be recovered).
|
|
309
|
+
*/
|
|
310
|
+
matches(source: ByteSource | null, path: string): Promise<boolean | undefined>;
|
|
311
|
+
/**
|
|
312
|
+
* Stage a raw byte copy of the file at `path` into `dest` (the store's
|
|
313
|
+
* rescue area) without loading it into memory.
|
|
314
|
+
*/
|
|
315
|
+
copy(path: string, dest: string): Promise<CopyOutcome>;
|
|
316
|
+
/**
|
|
317
|
+
* Byte size of the regular file at `path`, or `undefined` when absent or
|
|
318
|
+
* unreadable. Consulted before any content comparison so an over-cap file is
|
|
319
|
+
* skipped without being read. Optional; a missing accessor reads as "size
|
|
320
|
+
* unknown", which never counts as "too big".
|
|
321
|
+
*/
|
|
322
|
+
size?(path: string): Promise<number | undefined>;
|
|
323
|
+
/**
|
|
324
|
+
* True when the path's final component is not a single regular file: a
|
|
325
|
+
* symlink, a hard link (`nlink > 1`), a directory, a FIFO or a device. The
|
|
326
|
+
* planner and the apply pass both refuse one — a link redirects the write,
|
|
327
|
+
* a hard link shares the inode with other names, and a FIFO or device is
|
|
328
|
+
* neither restorable nor safe to open.
|
|
329
|
+
*/
|
|
330
|
+
isUnsafeTarget(path: string): Promise<boolean>;
|
|
331
|
+
}
|
|
332
|
+
/** Result of staging one on-disk byte copy. */
|
|
333
|
+
export type CopyOutcome = {
|
|
334
|
+
readonly kind: 'copied';
|
|
335
|
+
readonly size: number;
|
|
336
|
+
} | {
|
|
337
|
+
readonly kind: 'absent';
|
|
338
|
+
} | {
|
|
339
|
+
readonly kind: 'failed';
|
|
340
|
+
readonly message: string;
|
|
341
|
+
};
|
|
342
|
+
/** One restore action the planner derived from record + disk reconciliation. */
|
|
343
|
+
export type PlannedAction = {
|
|
344
|
+
readonly path: string;
|
|
345
|
+
readonly action: 'restore';
|
|
346
|
+
readonly before: ByteSource;
|
|
347
|
+
readonly mode?: number;
|
|
348
|
+
readonly parent?: string;
|
|
349
|
+
} | {
|
|
350
|
+
readonly path: string;
|
|
351
|
+
readonly action: 'delete';
|
|
352
|
+
readonly parent?: string;
|
|
353
|
+
};
|
|
354
|
+
/** Sanitize a call id into a safe file name. */
|
|
355
|
+
export declare function safeFileId(callId: string): string;
|
|
356
|
+
/**
|
|
357
|
+
* Sanitize a session id into a safe path segment. Real ids are harness-minted
|
|
358
|
+
* UUIDs (a no-op here), but a hostile or malformed id must never traverse out
|
|
359
|
+
* of the snapshot root — `.` and `..` are the only bare values the charset
|
|
360
|
+
* permits that would alias the root or its parent.
|
|
361
|
+
*/
|
|
362
|
+
export declare function safeSessionId(sessionId: string): string;
|
|
363
|
+
/**
|
|
364
|
+
* Structural validation of a parsed journal. Unlike checkpoint entries (whose
|
|
365
|
+
* corruption is silently skipped), a corrupt journal is reported
|
|
366
|
+
* fail-loud by `reconcileRestores` — silently dropping it would silently
|
|
367
|
+
* erase the ability to recover the interrupted restore.
|
|
368
|
+
*
|
|
369
|
+
* Accepts both formats: `version: 2` (byte references), the released
|
|
370
|
+
* `version: 1` (inline strings) and the legacy prefix that carried no
|
|
371
|
+
* `version` at all — but never best-effort-coerces a malformed action.
|
|
372
|
+
*/
|
|
373
|
+
export declare function isRestoreJournal(value: unknown): value is RestoreJournal;
|
|
374
|
+
/**
|
|
375
|
+
* Format a store-relative filesystem path as a serialized ref. Refs are always
|
|
376
|
+
* POSIX-separated (`13/call.before`) even on Windows, where `relative()`
|
|
377
|
+
* returns backslashes; the journal and link validators only accept `/`.
|
|
378
|
+
*/
|
|
379
|
+
export declare function sessionRelativeRef(sessionDir: string, file: string): string;
|
|
380
|
+
/** Serialized (on-disk) form of a journal action. */
|
|
381
|
+
export declare function journalToJson(journal: RestoreJournal, sessionDir: string): Record<string, unknown>;
|
|
382
|
+
/** Parse a validated journal's raw shape into the in-memory form. */
|
|
383
|
+
export declare function journalFromJson(raw: Record<string, unknown>, sessionDir: string): RestoreJournal | undefined;
|
|
384
|
+
/** True when a session-dir member is a restore journal (either prefix). */
|
|
385
|
+
export declare function isJournalName(name: string): boolean;
|
|
386
|
+
/** The op id encoded in a journal file name. */
|
|
387
|
+
export declare function journalOpIdOf(name: string): string;
|
|
388
|
+
/** The entry file name one call id maps to (the single naming function). */
|
|
389
|
+
export declare function entryFileName(callId: string): string;
|
|
390
|
+
/** Stable 8-hex digest of an arbitrary string (ids, paths, capture keys). */
|
|
391
|
+
export declare function shortHash(value: string): string;
|
|
392
|
+
/** The sidecar file name of one entry file (`<base>.json` → `<base>.before`). */
|
|
393
|
+
export declare function sidecarName(entryFile: string): string;
|
|
394
|
+
/** Anchor seq encoded in a link `ref` (`<anchorSeq>/<file>.json`). */
|
|
395
|
+
export declare function refAnchorOf(ref: string): number;
|
|
396
|
+
/** Serialize one in-memory entry for disk (the current byte format). */
|
|
397
|
+
export declare function entryToJson(entry: CheckpointEntry): Record<string, unknown>;
|
|
398
|
+
/** Serialize one in-memory dedup link for disk (the current byte format). */
|
|
399
|
+
export declare function linkToJson(link: LinkEntry): Record<string, unknown>;
|
|
400
|
+
/**
|
|
401
|
+
* Read one committed entry, or undefined when missing/corrupt. Accepts both
|
|
402
|
+
* formats: the current byte format (`store: 2`, `file`, `blob` + sidecar) and
|
|
403
|
+
* the released v1 format (`anchorSeq` + inline `before` string).
|
|
404
|
+
*
|
|
405
|
+
* The byte format deliberately does NOT carry `anchorSeq` (it equals the
|
|
406
|
+
* parent directory) and does NOT reuse the v1 key names — a downgraded v1
|
|
407
|
+
* build rejects such an entry instead of reading it as "created", which is
|
|
408
|
+
* what keeps a downgrade from deleting workspace files. See
|
|
409
|
+
* `tests/downgrade-safety.test.ts`.
|
|
410
|
+
*/
|
|
411
|
+
export declare function readEntry(file: string, anchorSeq: number): Promise<StoredEntry | undefined>;
|
|
412
|
+
/**
|
|
413
|
+
* True when a dedup-link `ref` is a SAFE relative reference to a checkpoint
|
|
414
|
+
* entry — `<digits>/<callId>.json`, a single level below the session dir, with
|
|
415
|
+
* no traversal or absolute segment. The plugin always writes refs this way
|
|
416
|
+
* ({@link SnapshotStore.entryRefOf}); this validates a `ref` read back from
|
|
417
|
+
* disk so a hostile or corrupt ref can never escape the store root when a
|
|
418
|
+
* restore resolution or prune materialization follows it (mirrors the
|
|
419
|
+
* `safeSessionId` / `safeFileId` containment guarantee).
|
|
420
|
+
*/
|
|
421
|
+
export declare function isSafeLinkRef(ref: string): boolean;
|
|
422
|
+
/**
|
|
423
|
+
* True when a journal's byte reference is SAFE and relative to the session
|
|
424
|
+
* dir: either `<digits>/<name>.before` (an entry sidecar) or
|
|
425
|
+
* `rescue/<opId>/<index>.before` (a rescue copy). Validated on both write and
|
|
426
|
+
* read, so a corrupt or hostile journal can never point a restore or a
|
|
427
|
+
* rollback outside the store (`..`, absolute paths and unknown roots are
|
|
428
|
+
* rejected).
|
|
429
|
+
*/
|
|
430
|
+
export declare function isSafeBackupRef(ref: string): boolean;
|
|
431
|
+
/**
|
|
432
|
+
* Result of a stale-session cleanup sweep ({@link SnapshotStore.pruneStale}).
|
|
433
|
+
*
|
|
434
|
+
* The sweep is ANTI-DELETE: it only ever removes WHOLE session directories
|
|
435
|
+
* that have been idle past `maxAgeDays`. `scanned` counts every session dir
|
|
436
|
+
* evaluated; `kept` + `skippedActive` + `deleted` sum to it. `remainingBytes`
|
|
437
|
+
* is the total of directories that SURVIVE the policy (when `dryRun` it is the
|
|
438
|
+
* would-be total, not the current on-disk total), so it is comparable across
|
|
439
|
+
* dry and real runs.
|
|
440
|
+
*/
|
|
441
|
+
export interface PruneStaleReport {
|
|
442
|
+
/** Number of session directories evaluated. */
|
|
443
|
+
readonly scanned: number;
|
|
444
|
+
/** Session directories removed (would-be count when `dryRun`). */
|
|
445
|
+
readonly deleted: number;
|
|
446
|
+
/** Bytes reclaimed (would-be bytes when `dryRun`). */
|
|
447
|
+
readonly freedBytes: number;
|
|
448
|
+
/** Session directories retained (not past the cutoff, not the active one). */
|
|
449
|
+
readonly kept: number;
|
|
450
|
+
/** Bytes across the retained + skipped-active directories. */
|
|
451
|
+
readonly remainingBytes: number;
|
|
452
|
+
/** Directories skipped because they are the active session. */
|
|
453
|
+
readonly skippedActive: number;
|
|
454
|
+
/** Whether nothing was really removed (the sweep only reported). */
|
|
455
|
+
readonly dryRun: boolean;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Result of a manual whole-session clear ({@link SnapshotStore.clearSession}).
|
|
459
|
+
*
|
|
460
|
+
* Unlike the age-based sweep, a clear removes EVERY snapshot of ONE session
|
|
461
|
+
* (all anchor groups, all checkpoint entries, all restore journals) on demand —
|
|
462
|
+
* the active session the user is driving, to drop the rewind overhead or to
|
|
463
|
+
* archive a conversation immediately. `dryRun` reports what would be removed
|
|
464
|
+
* without touching disk or memory.
|
|
465
|
+
*/
|
|
466
|
+
export interface ClearSessionReport {
|
|
467
|
+
/** The session whose records were (or would be) cleared. */
|
|
468
|
+
readonly sessionId: string;
|
|
469
|
+
/** Number of anchor-group (user-message) directories present. */
|
|
470
|
+
readonly anchorGroups: number;
|
|
471
|
+
/** Number of committed checkpoint entries (full backups + dedup links). */
|
|
472
|
+
readonly entries: number;
|
|
473
|
+
/** Number of restore-journal files (terminal + pending). */
|
|
474
|
+
readonly journals: number;
|
|
475
|
+
/** Bytes occupied by the session directory (the amount freed). */
|
|
476
|
+
readonly bytes: number;
|
|
477
|
+
/** Whether nothing was really removed (the clear only reported). */
|
|
478
|
+
readonly dryRun: boolean;
|
|
479
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem primitives for the checkpoint store.
|
|
3
|
+
*
|
|
4
|
+
* Every operation that can touch the real file system lives here: atomic JSON
|
|
5
|
+
* writes (temp + rename), streaming byte comparisons (never a whole file in
|
|
6
|
+
* memory), the on-disk probe that classifies links and non-regular targets,
|
|
7
|
+
* and the containment checks that keep a restore inside the location it
|
|
8
|
+
* recorded. Nothing here holds state; the store passes in what it needs.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-rewind/snapshot-fs
|
|
11
|
+
*/
|
|
12
|
+
import { type DiskProbe } from './snapshot-format.ts';
|
|
13
|
+
/** Streaming byte equality of two files (never loads either one whole). */
|
|
14
|
+
export declare function sameFileBytes(aPath: string, bPath: string): Promise<boolean>;
|
|
15
|
+
/** Streaming byte equality of a file against an in-memory buffer. */
|
|
16
|
+
export declare function sameFileBuffer(path: string, bytes: Buffer): Promise<boolean>;
|
|
17
|
+
/** True when the error means "the path does not exist". */
|
|
18
|
+
export declare function isEnoent(error: unknown): boolean;
|
|
19
|
+
/** Production probe: real byte comparisons via node:fs, links via lstat + nlink. */
|
|
20
|
+
export declare const defaultProbe: DiskProbe;
|
|
21
|
+
/**
|
|
22
|
+
* Atomic JSON file write: serialize to a sibling temp file, then rename over
|
|
23
|
+
* the target. A crash between the two steps leaves only the temp — never a
|
|
24
|
+
* readable half-written target — and rename is atomic, so readers always see
|
|
25
|
+
* either the old file or the complete new one. The temp name is deterministic
|
|
26
|
+
* (`<target>.tmp`): a crash-leftover temp is overwritten by the next write of
|
|
27
|
+
* the same target and is never picked up by readers (it does not end in
|
|
28
|
+
* `.json`). `afterTempWrite` is the test-only crash seam between the steps.
|
|
29
|
+
*/
|
|
30
|
+
export declare function writeJsonAtomic(file: string, data: unknown, afterTempWrite?: () => void): Promise<void>;
|
|
31
|
+
/** Recursive byte total of a directory tree (never follows symlinks). */
|
|
32
|
+
export declare function dirBytes(dir: string): Promise<number>;
|
|
33
|
+
/**
|
|
34
|
+
* True when the path is not a single regular file: a symlink, a hard link
|
|
35
|
+
* (`nlink > 1`), a directory, a FIFO or a device. None is ever written,
|
|
36
|
+
* created or read through on restore — a symlink redirects the operation, a
|
|
37
|
+
* hard link clobbers every other name of the inode (e.g. pnpm-installed
|
|
38
|
+
* files), and a FIFO or device either blocks the read or streams forever.
|
|
39
|
+
* Mirrors Claude Code's "symlinked and hard-linked paths not restored", plus
|
|
40
|
+
* the non-regular targets its restore guard also refuses.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isUnsafeTarget(path: string): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Where `path`'s directory resolved when the entry was captured — the location
|
|
45
|
+
* pin. A directory that does not exist (or does not exist any more) pins where
|
|
46
|
+
* it WOULD resolve: the `realpath` of its nearest surviving ancestor plus the
|
|
47
|
+
* missing segments. A link created there later therefore cannot pass as the
|
|
48
|
+
* same location, which is what makes an unpinned fallback unreachable for a
|
|
49
|
+
* record this build writes. Undefined only when nothing at all resolves.
|
|
50
|
+
*/
|
|
51
|
+
export declare function locationPin(path: string): Promise<string | undefined>;
|
|
52
|
+
/**
|
|
53
|
+
* True when `path`'s parent directory still resolves where it did at checkpoint
|
|
54
|
+
* time (`recorded`, the entry's location pin). Only the path's FINAL component
|
|
55
|
+
* is checked for links and non-regular files (see {@link isUnsafeTarget});
|
|
56
|
+
* without this a repointed ancestor
|
|
57
|
+
* directory would redirect a restore — or, worse, an unlink — outside the
|
|
58
|
+
* recorded location.
|
|
59
|
+
*
|
|
60
|
+
* - no pin (a legacy entry, or a commit that could not resolve its parent) →
|
|
61
|
+
* true: the released behavior is preserved;
|
|
62
|
+
* - the parent resolves to the pinned location → true. A STABLE symlinked
|
|
63
|
+
* ancestor resolves identically on both sides (the pin is a `realpath`), so
|
|
64
|
+
* a symlinked workspace root is never a false skip;
|
|
65
|
+
* - the parent resolves elsewhere → false;
|
|
66
|
+
* - the parent chain is gone → true only while the nearest surviving ancestor
|
|
67
|
+
* still resolves INSIDE the pinned location, which keeps "recreate a deleted
|
|
68
|
+
* parent directory" working without following a repointed ancestor;
|
|
69
|
+
* - anything undecidable (a link loop, an unreadable component) → false:
|
|
70
|
+
* refuse rather than write into a location that cannot be verified.
|
|
71
|
+
*/
|
|
72
|
+
export declare function parentStillMatches(path: string, recorded: string | undefined): Promise<boolean>;
|
|
73
|
+
/**
|
|
74
|
+
* Walk one directory tree and compute the total size (regular files only) and
|
|
75
|
+
* the newest stamp (max `lstat.mtimeMs` over every member, directories
|
|
76
|
+
* included). `lstat` never follows a symlink, so a hostile symlink inside the
|
|
77
|
+
* store cannot escape the root or inflate the measurement; a symlink is
|
|
78
|
+
* counted as one file's own metadata and not descended into. Directory members
|
|
79
|
+
* beginning with `.` (atomic-write temp leftovers, editor droppings) are
|
|
80
|
+
* skipped — they are never checkpoint entries.
|
|
81
|
+
*/
|
|
82
|
+
export declare function dirSizeAndLastActive(dir: string): Promise<{
|
|
83
|
+
size: number;
|
|
84
|
+
lastActiveMs: number;
|
|
85
|
+
}>;
|