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.
@@ -36,6 +36,10 @@
36
36
  *
37
37
  * @module dsh-rewind/snapshot
38
38
  */
39
+ import type { ByteSource, ClearSessionReport, CrashPoint, DeleteFile, DiskProbe, FileImpact, PendingBackup, PruneStaleReport, RestoreOutcome, RestoreReconcileReport, RestoreRunOptions, StoredEntry, UncoveredPath } from './snapshot-format.ts';
40
+ export { CURRENT_STORE_VERSION, UnknownStoreVersionError, isLinkEntry } from './snapshot-format.ts';
41
+ export type { ByteSource, CheckpointEntry, ClearSessionReport, CopyOutcome, CrashPoint, DeleteFile, DiskProbe, FileImpact, LinkEntry, PendingBackup, PlannedAction, PruneStaleReport, RestoreJournal, RestoreJournalAction, RestoreJournalState, RestoreOutcome, RestoreReconcileReport, RestoreRunOptions, StoredEntry, UncoveredPath, } from './snapshot-format.ts';
42
+ export { defaultProbe } from './snapshot-fs.ts';
39
43
  /**
40
44
  * A regular file larger than this is NOT backed up. This plugin is a
41
45
  * lightweight, conversation-scoped undo, not a backup system: copying a very
@@ -46,378 +50,6 @@
46
50
  export declare const DEFAULT_MAX_FILE_BYTES: number;
47
51
  /** Number of newest anchor groups (user messages) kept per session. */
48
52
  export declare const MAX_ANCHOR_GROUPS = 100;
49
- /**
50
- * Current on-disk store format version (the session's `store` marker value and
51
- * the `store` field of every entry this build writes). A value ABOVE this one
52
- * means the snapshots were written by a NEWER build: readers must then fail
53
- * closed (no file restore, nothing changed) instead of guessing what the extra
54
- * fields mean. A missing marker (or `1`) means the released v1 string format,
55
- * which is still read.
56
- */
57
- export declare const CURRENT_STORE_VERSION = 2;
58
- /**
59
- * Thrown when snapshots carry a store format newer than this build understands
60
- * (ADR-10: whole-operation fail-closed — never a partial restore, never a
61
- * clear). The session rewind itself does not depend on snapshots and still
62
- * works.
63
- */
64
- export declare class UnknownStoreVersionError extends Error {
65
- /** The version found on disk. */
66
- readonly version: number;
67
- /** Where it was found (a marker or an entry file). */
68
- readonly source: string;
69
- constructor(
70
- /** The version found on disk. */
71
- version: number,
72
- /** Where it was found (a marker or an entry file). */
73
- source: string);
74
- }
75
- /**
76
- * Recorded before-content — always bytes, never a decoded string:
77
- *
78
- * - `blob`: a raw byte copy inside the store (the format every new write uses).
79
- * - `text`: the exact UTF-8 bytes of a released-v1 string record (a v1 record
80
- * that was decoded from valid UTF-8 is byte-exact, so restoring it is safe).
81
- * - `lossyText`: a released-v1 string record that contains U+FFFD, i.e. one
82
- * the v1 build produced by a LOSSY decode of non-UTF-8 bytes. The original
83
- * bytes are unknowable, so it can be compared against the disk but must
84
- * never be written back as a restore target (that would destroy live data).
85
- */
86
- export type ByteSource = {
87
- readonly kind: 'blob';
88
- readonly path: string;
89
- } | {
90
- readonly kind: 'text';
91
- readonly bytes: Buffer;
92
- } | {
93
- readonly kind: 'lossyText';
94
- readonly text: string;
95
- };
96
- /** A staged raw byte copy waiting to be committed (`recordBackup`). */
97
- export interface PendingBackup {
98
- /** Absolute path of the staged file (inside the session's `.pending/`). */
99
- readonly file: string;
100
- /** Byte size of the staged content. */
101
- readonly size: number;
102
- /**
103
- * Permission bits of the source file (`stat().mode & 0o7777`), captured
104
- * best-effort. Restored alongside the content — never on its own, and never
105
- * as part of the change decision (ADR-9).
106
- */
107
- readonly mode?: number;
108
- }
109
- /** One committed before-backup, keyed by tool call. */
110
- export interface CheckpointEntry {
111
- readonly callId: string;
112
- /** Seq of the user message anchoring the turn in which the change happened. */
113
- readonly anchorSeq: number;
114
- /** Resolved display path (absolute) of the tracked file. */
115
- readonly path: string;
116
- /** Byte source of the content before the change; null when the file was created. */
117
- readonly before: ByteSource | null;
118
- /** Byte size of `before` (0 when the file was created). */
119
- readonly size: number;
120
- /**
121
- * Checkpoint-time location pin: the `realpath` of `dirname(path)` at the
122
- * moment the entry was committed. A restore refuses the path when its parent
123
- * directory no longer resolves there, because a repointed or moved ancestor
124
- * would otherwise redirect the write (or the unlink) outside the recorded
125
- * location — only the path's FINAL component is link-checked. Absent means
126
- * "no pin": a released-v1 entry, a v2 entry written before this field
127
- * existed, or a commit whose parent could not be resolved; the restore then
128
- * falls back to the final-component check alone.
129
- */
130
- readonly parent?: string;
131
- /**
132
- * Permission bits recorded at capture time, when known. Applied only when
133
- * the CONTENT is restored (a mode-only difference is never a reason to plan
134
- * a restore); a missing value means "leave the live mode alone".
135
- */
136
- readonly mode?: number;
137
- /**
138
- * Set when this entry's sidecar holds re-encoded LOSSY text (a released-v1
139
- * record that was decoded with replacement characters, e.g. after `prune`
140
- * materialized its link). The bytes are comparable but must never be written
141
- * back: a reader turns such an entry into a `lossyText` source again.
142
- */
143
- readonly lossy?: boolean;
144
- /** Epoch ms the entry was committed (stable ordering within a group). */
145
- readonly time: number;
146
- /**
147
- * Absolute file this entry was READ from (in-memory only, never serialized):
148
- * a dedup reference must name the file that actually exists, which for an
149
- * entry written by an older build is not necessarily the name the current
150
- * naming function would produce.
151
- */
152
- readonly file?: string;
153
- }
154
- /**
155
- * One in-place dedup link, keyed by tool call. When a tracked file is
156
- * recorded with a `before` content identical to the immediately-prior entry
157
- * for that path, the entry is stored as a LINK instead of a full copy: it
158
- * carries no content, only a `ref` naming the prior entry file
159
- * (`<anchorSeq>/<callId>.json`). The linear (predecessor-chained) ref makes
160
- * restore resolution and prune materialization rewrite-free.
161
- */
162
- export interface LinkEntry {
163
- readonly callId: string;
164
- readonly anchorSeq: number;
165
- readonly path: string;
166
- /** `<anchorSeq>/<callId>.json` of the immediately-prior entry for the path. */
167
- readonly ref: string;
168
- /**
169
- * Checkpoint-time location pin (see {@link CheckpointEntry.parent}); a link
170
- * records its own, so a materialized or resolved entry never loses the
171
- * location the path was committed under.
172
- */
173
- readonly parent?: string;
174
- readonly time: number;
175
- /** Absolute file this link was read from (in-memory only, never serialized). */
176
- readonly file?: string;
177
- }
178
- /** Any on-disk entry: a full before-backup or an in-place dedup link. */
179
- export type StoredEntry = CheckpointEntry | LinkEntry;
180
- /** True when an entry is a dedup link (carries `ref`, not `before`). */
181
- export declare function isLinkEntry(entry: StoredEntry): entry is LinkEntry;
182
- /** Per-file restore impact preview (served by the host preview route). */
183
- export interface FileImpact {
184
- readonly path: string;
185
- /** `restore` = write the before content back; `delete` = remove the file. */
186
- readonly action: 'restore' | 'delete';
187
- }
188
- /** A recorded path whose current on-disk size is over the backup cap. */
189
- export interface UncoveredPath {
190
- readonly path: string;
191
- /** Byte size observed on disk, when the probe could read one. */
192
- readonly size?: number;
193
- }
194
- /** Outcome of one restore pass. */
195
- export interface RestoreOutcome {
196
- readonly restored: readonly string[];
197
- readonly deleted: readonly string[];
198
- /** Symlinked or hard-linked paths left untouched. */
199
- readonly skipped: readonly string[];
200
- readonly failed: readonly {
201
- path: string;
202
- message: string;
203
- }[];
204
- }
205
- /** Deletes one file by its real path (node:fs, bypassing the fs service). */
206
- export type DeleteFile = (path: string) => Promise<void>;
207
- /**
208
- * Test-only fault injection: a crash point inside the write/restore paths.
209
- * The hook THROWS to simulate a host crash at the exact point; the throw
210
- * propagates out of the store method, leaving the journal on disk in its
211
- * current state. Production callers never pass it (undefined = no-op).
212
- */
213
- export type CrashPoint = 'before-action' | 'after-action' | 'after-temp-write';
214
- /** Options for the journaled restore paths; `crash` is the test-only seam. */
215
- export interface RestoreRunOptions {
216
- /**
217
- * Throws at the given point to simulate a host crash: `before-action`
218
- * (before an action's fs op), `after-action` (right after the fs op,
219
- * before its done-mark is persisted), `after-temp-write` (inside an atomic
220
- * commit, between the temp write and the rename). `index` is the action
221
- * index for the restore loops.
222
- */
223
- readonly crash?: (point: CrashPoint, index?: number) => void;
224
- }
225
- /**
226
- * Lifecycle of one restore operation journal. Terminal states are kept on
227
- * disk as a tiny audit trail and skipped by reconciliation.
228
- */
229
- export type RestoreJournalState = 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'recovery-required';
230
- /**
231
- * One journaled restore action — a mutable working record that the restore
232
- * loop updates (done/failed) as it applies the pass.
233
- */
234
- export interface RestoreJournalAction {
235
- readonly path: string;
236
- readonly action: 'restore' | 'delete';
237
- /** Target content for a restore; null for a delete. */
238
- readonly before: ByteSource | null;
239
- /**
240
- * Pre-restore disk state ("rescue"): a raw byte copy of what the file had
241
- * right before the restore started, or null when it was absent. Rollback
242
- * writes this back, so the pre-restore state is recoverable exactly.
243
- */
244
- readonly rescue: ByteSource | null;
245
- /** Set when the rescue capture failed: rollback then skips this path. */
246
- rescueError?: string;
247
- /** Permission bits the restored content should end up with, when recorded. */
248
- readonly mode?: number;
249
- /**
250
- * Checkpoint-time location pin copied from the entry the action was planned
251
- * from (see {@link CheckpointEntry.parent}): a continue or rollback after a
252
- * restart re-checks it before touching the path.
253
- */
254
- readonly parent?: string;
255
- /** Permission bits the file had BEFORE the restore, for a faithful rollback. */
256
- readonly rescueMode?: number;
257
- /** True once the action's fs op completed and was marked. */
258
- done: boolean;
259
- /** Per-action failure message; the restore pass never aborts. */
260
- failed?: string;
261
- }
262
- /** Durable journal for one attempted restore (written atomically). */
263
- export interface RestoreJournal {
264
- /** On-disk schema version: 2 = byte references, 1 = inline legacy strings. */
265
- readonly version: 1 | 2;
266
- readonly id: string;
267
- readonly sessionId: string;
268
- readonly targetSeq: number;
269
- readonly startedAt: number;
270
- finishedAt?: number;
271
- state: RestoreJournalState;
272
- readonly actions: RestoreJournalAction[];
273
- /** Set when a rollback pass failed partway (state becomes `recovery-required`). */
274
- rollbackError?: string;
275
- /**
276
- * Absolute file this journal was read from (in-memory only, never
277
- * serialized): a legacy `restore-journal-` file is updated IN PLACE so an
278
- * op that was interrupted before the upgrade never ends up with two
279
- * divergent versions on disk.
280
- */
281
- sourceFile?: string;
282
- }
283
- /**
284
- * Result of reconciling one interrupted restore journal against the real
285
- * disk. Path status is relative to the op's current goal: the restore target
286
- * for `running` journals, the pre-restore (rescue) state for
287
- * `rollback-running` / `recovery-required` journals — disambiguate with
288
- * {@link RestoreReconcileReport.journalState}.
289
- */
290
- export interface RestoreReconcileReport {
291
- readonly opId: string;
292
- /** `interrupted` = a crash left the op unfinished; `recovery-required` = a rollback could not complete. */
293
- readonly state: 'interrupted' | 'recovery-required';
294
- /** Raw journal state (`running` | `rollback-running` | `recovery-required`). */
295
- readonly journalState: RestoreJournalState;
296
- readonly targetSeq: number;
297
- readonly startedAt: number;
298
- /** Paths whose disk already matches the op's goal. */
299
- readonly restored: readonly string[];
300
- /** Paths still short of the op's goal (not yet applied / not yet rolled back). */
301
- readonly pending: readonly string[];
302
- /** Actions that failed during the pass (kept failed until a redo succeeds). */
303
- readonly failed: readonly {
304
- path: string;
305
- message: string;
306
- }[];
307
- readonly rollbackError?: string;
308
- /** Set when the journal file itself is corrupt: it cannot be reconciled. */
309
- readonly corrupt?: string;
310
- }
311
- /**
312
- * Current-on-disk state probe used by restore planning and reconciliation.
313
- * Injected so the logic runs against a fake FS in tests; the production
314
- * default reads the real file system with plain `node:fs` (see
315
- * {@link defaultProbe}) and compares byte streams, never whole files in memory.
316
- */
317
- export interface DiskProbe {
318
- /**
319
- * Compare the recorded content with the file currently at `path`.
320
- *
321
- * - `true` = the disk matches the record byte-for-byte (for a `null`
322
- * source: the path is absent).
323
- * - `false` = it differs — including "the record says the file did not
324
- * exist but it does" and "the record has content but the file is gone".
325
- * - `undefined` = the comparison could not be decided (IO/permission
326
- * failure). Callers stay conservative: a restore is still attempted and a
327
- * delete still attempted, so an unreadable file is never silently skipped.
328
- *
329
- * A `lossyText` source is compared with the same lossy decode the released
330
- * v1 build used (its original bytes cannot be recovered).
331
- */
332
- matches(source: ByteSource | null, path: string): Promise<boolean | undefined>;
333
- /**
334
- * Stage a raw byte copy of the file at `path` into `dest` (the store's
335
- * rescue area) without loading it into memory.
336
- */
337
- copy(path: string, dest: string): Promise<CopyOutcome>;
338
- /**
339
- * Byte size of the regular file at `path`, or `undefined` when absent or
340
- * unreadable. Consulted before any content comparison so an over-cap file is
341
- * skipped without being read. Optional; a missing accessor reads as "size
342
- * unknown", which never counts as "too big".
343
- */
344
- size?(path: string): Promise<number | undefined>;
345
- /** True when the path is a symlink or a hard link (never planned/restored). */
346
- isLink(path: string): Promise<boolean>;
347
- }
348
- /** Result of staging one on-disk byte copy. */
349
- export type CopyOutcome = {
350
- readonly kind: 'copied';
351
- readonly size: number;
352
- } | {
353
- readonly kind: 'absent';
354
- } | {
355
- readonly kind: 'failed';
356
- readonly message: string;
357
- };
358
- /** One restore action the planner derived from record + disk reconciliation. */
359
- export type PlannedAction = {
360
- readonly path: string;
361
- readonly action: 'restore';
362
- readonly before: ByteSource;
363
- readonly mode?: number;
364
- readonly parent?: string;
365
- } | {
366
- readonly path: string;
367
- readonly action: 'delete';
368
- readonly parent?: string;
369
- };
370
- /** Production probe: real byte comparisons via node:fs, links via lstat + nlink. */
371
- export declare const defaultProbe: DiskProbe;
372
- /**
373
- * Result of a stale-session cleanup sweep ({@link SnapshotStore.pruneStale}).
374
- *
375
- * The sweep is ANTI-DELETE: it only ever removes WHOLE session directories
376
- * that have been idle past `maxAgeDays`. `scanned` counts every session dir
377
- * evaluated; `kept` + `skippedActive` + `deleted` sum to it. `remainingBytes`
378
- * is the total of directories that SURVIVE the policy (when `dryRun` it is the
379
- * would-be total, not the current on-disk total), so it is comparable across
380
- * dry and real runs.
381
- */
382
- export interface PruneStaleReport {
383
- /** Number of session directories evaluated. */
384
- readonly scanned: number;
385
- /** Session directories removed (would-be count when `dryRun`). */
386
- readonly deleted: number;
387
- /** Bytes reclaimed (would-be bytes when `dryRun`). */
388
- readonly freedBytes: number;
389
- /** Session directories retained (not past the cutoff, not the active one). */
390
- readonly kept: number;
391
- /** Bytes across the retained + skipped-active directories. */
392
- readonly remainingBytes: number;
393
- /** Directories skipped because they are the active session. */
394
- readonly skippedActive: number;
395
- /** Whether nothing was really removed (the sweep only reported). */
396
- readonly dryRun: boolean;
397
- }
398
- /**
399
- * Result of a manual whole-session clear ({@link SnapshotStore.clearSession}).
400
- *
401
- * Unlike the age-based sweep, a clear removes EVERY snapshot of ONE session
402
- * (all anchor groups, all checkpoint entries, all restore journals) on demand —
403
- * the active session the user is driving, to drop the rewind overhead or to
404
- * archive a conversation immediately. `dryRun` reports what would be removed
405
- * without touching disk or memory.
406
- */
407
- export interface ClearSessionReport {
408
- /** The session whose records were (or would be) cleared. */
409
- readonly sessionId: string;
410
- /** Number of anchor-group (user-message) directories present. */
411
- readonly anchorGroups: number;
412
- /** Number of committed checkpoint entries (full backups + dedup links). */
413
- readonly entries: number;
414
- /** Number of restore-journal files (terminal + pending). */
415
- readonly journals: number;
416
- /** Bytes occupied by the session directory (the amount freed). */
417
- readonly bytes: number;
418
- /** Whether nothing was really removed (the clear only reported). */
419
- readonly dryRun: boolean;
420
- }
421
53
  /**
422
54
  * On-disk checkpoint store. Every write goes straight through `node:fs`, so a
423
55
  * restore reliably lands on the real file system.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "In-window conversation rewind with workspace file restore",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [