@anionex/dsh-turn-rewind 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +182 -0
  3. package/README.zh.md +180 -0
  4. package/SECURITY.md +53 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/FORMAT.md +101 -0
  7. package/docs/IMPLEMENTATION_PLAN.md +56 -0
  8. package/docs/assets/turn-rewind-action.png +0 -0
  9. package/docs/assets/turn-rewind-dialog.png +0 -0
  10. package/lib/client.js +473 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/engine.js +616 -0
  13. package/lib/errors.js +20 -0
  14. package/lib/git.js +155 -0
  15. package/lib/index.js +71 -0
  16. package/lib/path-utils.js +262 -0
  17. package/lib/rewind-host.js +421 -0
  18. package/lib/snapshot.js +183 -0
  19. package/lib/store.js +391 -0
  20. package/lib/types/client/index.d.ts +81 -0
  21. package/lib/types/engine.d.ts +90 -0
  22. package/lib/types/errors.d.ts +15 -0
  23. package/lib/types/git.d.ts +14 -0
  24. package/lib/types/index.d.ts +43 -0
  25. package/lib/types/path-utils.d.ts +34 -0
  26. package/lib/types/rewind-host.d.ts +137 -0
  27. package/lib/types/snapshot.d.ts +35 -0
  28. package/lib/types/store.d.ts +39 -0
  29. package/lib/types/types.d.ts +177 -0
  30. package/lib/types/validate.d.ts +11 -0
  31. package/lib/types.js +2 -0
  32. package/lib/validate.js +242 -0
  33. package/package.json +91 -0
  34. package/src/client/index.tsx +648 -0
  35. package/src/client/platform.d.ts +27 -0
  36. package/src/engine.ts +770 -0
  37. package/src/errors.ts +23 -0
  38. package/src/git.ts +178 -0
  39. package/src/index.ts +100 -0
  40. package/src/path-utils.ts +255 -0
  41. package/src/rewind-host.ts +584 -0
  42. package/src/snapshot.ts +229 -0
  43. package/src/store.ts +383 -0
  44. package/src/types.ts +195 -0
  45. package/src/validate.ts +245 -0
package/lib/engine.js ADDED
@@ -0,0 +1,616 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { lstat, realpath } from 'node:fs/promises';
5
+ import { ChangeLedgerError, errorMessage } from './errors.js';
6
+ import { discoverRepository, sameRepositoryFence } from './git.js';
7
+ import { ensureSafeParents, expandHome, isNodeError, isWithin, pruneEmptyParents, removeRestoreTarget, replaceRegularFile, replaceSymbolicLink, resolveWorkspacePath, validateRelativePath, } from './path-utils.js';
8
+ import { captureStableTree, diffTrees, entriesEqual } from './snapshot.js';
9
+ import { LedgerStore } from './store.js';
10
+ import { LEDGER_FORMAT_VERSION, } from './types.js';
11
+ const DEFAULTS = {
12
+ maxRestorePoints: 50,
13
+ maxTurnCheckpointsPerSession: 30,
14
+ maxFiles: 20_000,
15
+ maxFileBytes: 16 * 1024 * 1024,
16
+ maxSnapshotBytes: 512 * 1024 * 1024,
17
+ planTtlMs: 15 * 60 * 1_000,
18
+ staleLockMs: 30_000,
19
+ };
20
+ /** Persistent workspace change-set engine, independent of the DSH tool adapter. */
21
+ export class ChangeLedgerEngine {
22
+ config;
23
+ store;
24
+ plans = new Map();
25
+ activePlans = new Set();
26
+ ready;
27
+ /** Build an engine and start crash-journal reconciliation. */
28
+ constructor(config = {}) {
29
+ this.config = resolveConfig(config);
30
+ this.store = new LedgerStore(this.config);
31
+ this.ready = this.store.initialize();
32
+ }
33
+ /** Wait for startup reconciliation and return the number of interrupted journals found. */
34
+ async initialize() {
35
+ return this.ready;
36
+ }
37
+ /** Create a durable restore point for the current Git worktree. */
38
+ async create(options) {
39
+ await this.ready;
40
+ const source = await discoverRepository(options.cwd, options.signal);
41
+ await this.assertStorageSeparated(source.state.root);
42
+ const release = await this.store.acquire(source.state.root);
43
+ try {
44
+ const label = normalizeLabel(options.label);
45
+ const manifest = await this.createLocked({
46
+ cwd: source.state.root,
47
+ kind: 'user',
48
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
49
+ ...(label === undefined ? {} : { label }),
50
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
51
+ });
52
+ return summarize(manifest);
53
+ }
54
+ finally {
55
+ await release();
56
+ }
57
+ }
58
+ /** Capture project files before one DSH turn begins its first step. */
59
+ async createTurnCheckpoint(options) {
60
+ await this.ready;
61
+ if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
62
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer');
63
+ }
64
+ if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
65
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
66
+ }
67
+ const source = await discoverRepository(options.cwd, options.signal);
68
+ await this.assertStorageSeparated(source.state.root);
69
+ const release = await this.store.acquire(source.state.root);
70
+ try {
71
+ const existing = await this.store.listManifests(source.state.root);
72
+ const duplicate = existing.find(manifest => manifest.kind === 'turn'
73
+ && manifest.sessionId === options.sessionId
74
+ && manifest.turn === options.turn
75
+ && manifest.turnStartSeq === options.turnStartSeq);
76
+ if (duplicate !== undefined)
77
+ return summarize(duplicate);
78
+ const manifest = await this.createLocked({
79
+ cwd: source.state.root,
80
+ kind: 'turn',
81
+ sessionId: options.sessionId,
82
+ label: `Before turn ${String(options.turn)} checkpoint`,
83
+ turn: options.turn,
84
+ turnStartSeq: options.turnStartSeq,
85
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
86
+ });
87
+ const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
88
+ .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id));
89
+ for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
90
+ if (await this.store.isReferencedByRecovery(source.state.root, stale.id))
91
+ continue;
92
+ await this.store.deleteManifest(source.state.root, stale.id);
93
+ }
94
+ await this.store.collectGarbage(source.state.root);
95
+ return summarize(manifest);
96
+ }
97
+ finally {
98
+ await release();
99
+ }
100
+ }
101
+ /** Find the prompt-anchored checkpoint captured before one session turn. */
102
+ async findTurnCheckpoint(options) {
103
+ await this.ready;
104
+ const source = await discoverRepository(options.cwd, options.signal);
105
+ await this.assertStorageSeparated(source.state.root);
106
+ const manifest = (await this.store.listManifests(source.state.root)).find(point => point.kind === 'turn'
107
+ && point.sessionId === options.sessionId
108
+ && point.turn === options.turn
109
+ && point.turnStartSeq !== undefined);
110
+ return manifest === undefined ? undefined : summarize(manifest);
111
+ }
112
+ /** List restore points for the current worktree. */
113
+ async list(options) {
114
+ await this.ready;
115
+ const source = await discoverRepository(options.cwd, options.signal);
116
+ await this.assertStorageSeparated(source.state.root);
117
+ const manifests = await this.store.listManifests(source.state.root);
118
+ return manifests
119
+ .filter((manifest) => manifest.kind === 'user'
120
+ || (manifest.kind === 'rescue' && options.includeRescue === true)
121
+ || (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
122
+ .map(summarize);
123
+ }
124
+ /** Compare one restore point with the current worktree. */
125
+ async inspect(options) {
126
+ await this.ready;
127
+ const source = await discoverRepository(options.cwd, options.signal);
128
+ await this.assertStorageSeparated(source.state.root);
129
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
130
+ const current = await captureStableTree({ cwd: source.state.root, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) });
131
+ return {
132
+ restorePoint: summarize(manifest),
133
+ currentTreeHash: current.treeHash,
134
+ currentRepository: current.source.state,
135
+ ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
136
+ ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
137
+ ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
138
+ headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
139
+ operationChanged: manifest.repository.operation !== current.source.state.operation,
140
+ changes: diffTrees(manifest.entries, current.entries),
141
+ };
142
+ }
143
+ /** Produce an expiring, exact confirmation plan for a restore. */
144
+ async planRestore(options) {
145
+ await this.ready;
146
+ this.expirePlans();
147
+ const source = await discoverRepository(options.cwd, options.signal);
148
+ await this.assertStorageSeparated(source.state.root);
149
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
150
+ const current = await captureStableTree({ cwd: source.state.root, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) });
151
+ if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
152
+ throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again');
153
+ }
154
+ if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
155
+ throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again');
156
+ }
157
+ assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true);
158
+ const changes = diffTrees(manifest.entries, current.entries);
159
+ if (changes.length === 0) {
160
+ throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`);
161
+ }
162
+ const selected = selectChanges(changes, options.paths);
163
+ await assertNoUnmanagedRestoreConflicts(source.state.root, manifest.entries, current.entries, selected.map((change) => change.path));
164
+ const expected = Object.create(null);
165
+ for (const change of selected)
166
+ expected[change.path] = current.entries[change.path] ?? null;
167
+ const now = Date.now();
168
+ const plan = {
169
+ id: makeId('plan'),
170
+ restorePointId: manifest.id,
171
+ workspace: manifest.workspace,
172
+ repository: current.source.state,
173
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
174
+ createdAt: now,
175
+ expiresAt: now + this.config.planTtlMs,
176
+ confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
177
+ allowHeadChange: options.allowHeadChange === true,
178
+ paths: selected.map((change) => change.path),
179
+ changes: selected,
180
+ expected,
181
+ };
182
+ this.plans.set(plan.id, plan);
183
+ return clonePlan(plan);
184
+ }
185
+ /** Apply one approved restore plan, creating a durable rescue point first. */
186
+ async applyRestore(options) {
187
+ await this.ready;
188
+ this.expirePlans();
189
+ const plan = this.plans.get(options.planId);
190
+ if (plan === undefined)
191
+ throw new ChangeLedgerError('PLAN_NOT_FOUND', `restore plan ${options.planId} is absent or expired`);
192
+ if (plan.confirmation !== options.confirmation) {
193
+ throw new ChangeLedgerError('CONFIRMATION_MISMATCH', 'confirmation does not exactly match the restore plan');
194
+ }
195
+ if (plan.sessionId !== undefined && plan.sessionId !== options.sessionId) {
196
+ throw new ChangeLedgerError('SESSION_MISMATCH', 'restore plan belongs to a different DSH session');
197
+ }
198
+ if (this.activePlans.has(plan.id)) {
199
+ throw new ChangeLedgerError('PLAN_IN_PROGRESS', `restore plan ${plan.id} is already being applied`);
200
+ }
201
+ this.activePlans.add(plan.id);
202
+ try {
203
+ await this.assertStorageSeparated(plan.workspace);
204
+ const release = await this.store.acquire(plan.workspace);
205
+ try {
206
+ const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId);
207
+ const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) });
208
+ assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange);
209
+ assertPlanRepositoryFresh(plan.repository, current.source.state);
210
+ assertPlanFresh(plan, current.entries);
211
+ await assertNoUnmanagedRestoreConflicts(plan.workspace, manifest.entries, current.entries, plan.paths);
212
+ const rescue = await this.createLocked({
213
+ cwd: plan.workspace,
214
+ kind: 'rescue',
215
+ label: `Before restoring ${manifest.id}`,
216
+ parentRestorePoint: manifest.id,
217
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
218
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
219
+ });
220
+ try {
221
+ assertPlanFresh(plan, rescue.entries);
222
+ await assertNoUnmanagedRestoreConflicts(plan.workspace, manifest.entries, rescue.entries, plan.paths);
223
+ }
224
+ catch (error) {
225
+ await this.store.deleteManifest(plan.workspace, rescue.id);
226
+ await this.store.collectGarbage(plan.workspace);
227
+ throw error;
228
+ }
229
+ const operation = {
230
+ version: LEDGER_FORMAT_VERSION,
231
+ id: makeId('op'),
232
+ workspace: plan.workspace,
233
+ restorePointId: manifest.id,
234
+ rescuePointId: rescue.id,
235
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
236
+ paths: plan.paths,
237
+ startedAt: Date.now(),
238
+ state: 'running',
239
+ };
240
+ await this.store.writeOperation(operation);
241
+ try {
242
+ await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal);
243
+ await this.verifyPaths(plan.workspace, manifest.entries, plan.paths, options.signal);
244
+ }
245
+ catch (error) {
246
+ const primaryError = errorMessage(error);
247
+ let journalWarning;
248
+ try {
249
+ await this.store.writeOperation({ ...operation, state: 'rollback-running', error: primaryError });
250
+ }
251
+ catch (journalError) {
252
+ journalWarning = `could not persist rollback-running state: ${errorMessage(journalError)}`;
253
+ }
254
+ let rollbackFailure;
255
+ try {
256
+ await this.restorePaths(plan.workspace, rescue.entries, plan.paths);
257
+ await this.verifyPaths(plan.workspace, rescue.entries, plan.paths);
258
+ }
259
+ catch (rollbackError) {
260
+ rollbackFailure = rollbackError;
261
+ }
262
+ if (rollbackFailure === undefined) {
263
+ let terminalJournalWarning;
264
+ try {
265
+ await this.store.writeOperation({
266
+ ...operation,
267
+ state: 'rolled-back',
268
+ error: primaryError,
269
+ finishedAt: Date.now(),
270
+ });
271
+ }
272
+ catch (journalError) {
273
+ terminalJournalWarning = `could not persist rolled-back state: ${errorMessage(journalError)}`;
274
+ }
275
+ const warnings = [journalWarning, terminalJournalWarning].filter((value) => value !== undefined);
276
+ const warningText = warnings.length === 0 ? '' : `; journal warning: ${warnings.join('; ')}`;
277
+ throw new ChangeLedgerError('RESTORE_FAILED_ROLLED_BACK', `restore failed and the pre-restore state was recovered from ${rescue.id}: ${primaryError}${warningText}`, { cause: error });
278
+ }
279
+ const rollbackMessage = errorMessage(rollbackFailure);
280
+ let recoveryJournalWarning;
281
+ try {
282
+ await this.store.writeOperation({
283
+ ...operation,
284
+ state: 'recovery-required',
285
+ error: primaryError,
286
+ rollbackError: rollbackMessage,
287
+ });
288
+ }
289
+ catch (journalError) {
290
+ recoveryJournalWarning = `could not persist recovery-required state: ${errorMessage(journalError)}`;
291
+ }
292
+ const warnings = [journalWarning, recoveryJournalWarning].filter((value) => value !== undefined);
293
+ const warningText = warnings.length === 0 ? '' : `; journal warning: ${warnings.join('; ')}`;
294
+ throw new ChangeLedgerError('RECOVERY_REQUIRED', `restore and automatic rollback both failed; operation ${operation.id} can be recovered from rescue point ${rescue.id}: ${primaryError}; rollback: ${rollbackMessage}${warningText}`, { cause: error });
295
+ }
296
+ const finishedAt = Date.now();
297
+ await this.store.writeOperation({ ...operation, state: 'completed', finishedAt });
298
+ await this.store.writeManifest({
299
+ ...manifest,
300
+ restoreCount: manifest.restoreCount + 1,
301
+ lastRestoredAt: finishedAt,
302
+ });
303
+ this.plans.delete(plan.id);
304
+ return {
305
+ operationId: operation.id,
306
+ restorePointId: manifest.id,
307
+ rescuePointId: rescue.id,
308
+ restoredPaths: plan.paths,
309
+ };
310
+ }
311
+ finally {
312
+ await release();
313
+ }
314
+ }
315
+ finally {
316
+ this.activePlans.delete(plan.id);
317
+ }
318
+ }
319
+ /** Delete one restore point and collect unreferenced blobs. */
320
+ async delete(options) {
321
+ await this.ready;
322
+ if (options.confirmation !== `DELETE ${options.restorePointId}`) {
323
+ throw new ChangeLedgerError('CONFIRMATION_MISMATCH', `confirmation must exactly equal "DELETE ${options.restorePointId}"`);
324
+ }
325
+ const source = await discoverRepository(options.cwd, options.signal);
326
+ await this.assertStorageSeparated(source.state.root);
327
+ const release = await this.store.acquire(source.state.root);
328
+ try {
329
+ await this.store.readManifest(source.state.root, options.restorePointId);
330
+ if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
331
+ throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal');
332
+ }
333
+ await this.store.deleteManifest(source.state.root, options.restorePointId);
334
+ const gc = await this.store.collectGarbage(source.state.root);
335
+ return { restorePointId: options.restorePointId, ...gc };
336
+ }
337
+ finally {
338
+ await release();
339
+ }
340
+ }
341
+ /** List restore operations that were interrupted or require manual recovery. */
342
+ async listRecovery(options) {
343
+ await this.ready;
344
+ const source = await discoverRepository(options.cwd, options.signal);
345
+ await this.assertStorageSeparated(source.state.root);
346
+ return (await this.store.listOperations(source.state.root))
347
+ .filter((operation) => operation.state === 'interrupted' || operation.state === 'recovery-required')
348
+ .map((operation) => ({
349
+ operationId: operation.id,
350
+ restorePointId: operation.restorePointId,
351
+ rescuePointId: operation.rescuePointId,
352
+ state: operation.state,
353
+ paths: operation.paths,
354
+ startedAt: operation.startedAt,
355
+ ...(operation.error === undefined ? {} : { error: operation.error }),
356
+ ...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
357
+ }));
358
+ }
359
+ async createLocked(options) {
360
+ const existing = await this.store.listManifests(options.cwd);
361
+ const durableUserPoints = existing.filter(point => point.kind !== 'turn');
362
+ if (options.kind !== 'turn' && durableUserPoints.length >= this.config.maxRestorePoints) {
363
+ throw new ChangeLedgerError('RESTORE_POINT_LIMIT', `workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`);
364
+ }
365
+ try {
366
+ const tree = await captureStableTree({
367
+ cwd: options.cwd,
368
+ config: this.config,
369
+ store: this.store,
370
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
371
+ });
372
+ const manifest = {
373
+ version: LEDGER_FORMAT_VERSION,
374
+ id: makeId('rp'),
375
+ kind: options.kind,
376
+ workspace: tree.source.state.root,
377
+ repository: tree.source.state,
378
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
379
+ ...(options.label === undefined ? {} : { label: options.label }),
380
+ ...(options.parentRestorePoint === undefined ? {} : { parentRestorePoint: options.parentRestorePoint }),
381
+ ...(options.turn === undefined ? {} : { turn: options.turn }),
382
+ ...(options.turnStartSeq === undefined ? {} : { turnStartSeq: options.turnStartSeq }),
383
+ ...(options.turnEndSeq === undefined ? {} : { turnEndSeq: options.turnEndSeq }),
384
+ createdAt: Date.now(),
385
+ treeHash: tree.treeHash,
386
+ fileCount: tree.fileCount,
387
+ totalBytes: tree.totalBytes,
388
+ entries: tree.entries,
389
+ restoreCount: 0,
390
+ };
391
+ await this.store.collectGarbage(options.cwd, Object.values(manifest.entries)
392
+ .filter((entry) => entry.kind === 'file')
393
+ .map((entry) => entry.blob));
394
+ await this.store.writeManifest(manifest);
395
+ return manifest;
396
+ }
397
+ catch (error) {
398
+ try {
399
+ await this.store.collectGarbage(options.cwd);
400
+ }
401
+ catch (cleanupError) {
402
+ throw new ChangeLedgerError('SNAPSHOT_CLEANUP_FAILED', `snapshot failed (${errorMessage(error)}) and unreferenced-blob cleanup also failed (${errorMessage(cleanupError)})`, { cause: error });
403
+ }
404
+ throw error;
405
+ }
406
+ }
407
+ async restorePaths(workspace, desiredEntries, paths, signal) {
408
+ const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst);
409
+ for (const path of deletions) {
410
+ throwIfAborted(signal);
411
+ const target = resolveWorkspacePath(workspace, path);
412
+ await removeRestoreTarget(target);
413
+ await pruneEmptyParents(workspace, target);
414
+ }
415
+ const restorations = paths.filter((path) => desiredEntries[path] !== undefined).sort(compareShallowestFirst);
416
+ for (const path of restorations) {
417
+ throwIfAborted(signal);
418
+ const entry = desiredEntries[path];
419
+ if (entry === undefined)
420
+ continue;
421
+ const target = resolveWorkspacePath(workspace, path);
422
+ await ensureSafeParents(workspace, target);
423
+ try {
424
+ const info = await lstat(target);
425
+ if (info.isDirectory() && !info.isSymbolicLink())
426
+ await removeRestoreTarget(target);
427
+ }
428
+ catch (error) {
429
+ if (!isNodeError(error, 'ENOENT'))
430
+ throw error;
431
+ }
432
+ if (entry.kind === 'file') {
433
+ const content = await this.store.readBlob(workspace, entry.blob);
434
+ if (content.length !== entry.size) {
435
+ throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`);
436
+ }
437
+ await replaceRegularFile(target, content, entry.mode);
438
+ }
439
+ else {
440
+ await replaceSymbolicLink(target, entry.target);
441
+ }
442
+ }
443
+ }
444
+ async verifyPaths(workspace, desiredEntries, paths, signal) {
445
+ const current = await captureStableTree({ cwd: workspace, config: this.config, ...(signal === undefined ? {} : { signal }) });
446
+ for (const path of paths) {
447
+ if (!entriesEqual(desiredEntries[path], current.entries[path])) {
448
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`);
449
+ }
450
+ }
451
+ }
452
+ async assertStorageSeparated(workspace) {
453
+ const storage = await realpath(resolve(this.config.storageDir));
454
+ if (isWithin(workspace, storage) || isWithin(storage, workspace)) {
455
+ throw new ChangeLedgerError('STATE_WORKSPACE_OVERLAP', `storageDir ${JSON.stringify(storage)} must not overlap workspace ${JSON.stringify(workspace)}`);
456
+ }
457
+ }
458
+ expirePlans() {
459
+ const now = Date.now();
460
+ for (const [id, plan] of this.plans) {
461
+ if (plan.expiresAt <= now)
462
+ this.plans.delete(id);
463
+ }
464
+ }
465
+ }
466
+ /** Resolve and validate every deployment-varying configuration value. */
467
+ export function resolveConfig(config) {
468
+ const home = homedir();
469
+ const configuredDshHome = process.env.DSH_HOME?.trim();
470
+ const dshHome = configuredDshHome === undefined || configuredDshHome === ''
471
+ ? join(home, '.dsh')
472
+ : resolve(expandHome(configuredDshHome, home));
473
+ const storageInput = config.storageDir ?? join(dshHome, 'change-ledger', 'v1');
474
+ const storageDir = resolve(expandHome(requireNonEmptyString(storageInput, 'storageDir'), home));
475
+ return {
476
+ storageDir,
477
+ maxRestorePoints: positiveInteger(config.maxRestorePoints ?? DEFAULTS.maxRestorePoints, 'maxRestorePoints'),
478
+ maxTurnCheckpointsPerSession: positiveInteger(config.maxTurnCheckpointsPerSession ?? DEFAULTS.maxTurnCheckpointsPerSession, 'maxTurnCheckpointsPerSession'),
479
+ maxFiles: positiveInteger(config.maxFiles ?? DEFAULTS.maxFiles, 'maxFiles'),
480
+ maxFileBytes: positiveInteger(config.maxFileBytes ?? DEFAULTS.maxFileBytes, 'maxFileBytes'),
481
+ maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
482
+ planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
483
+ staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
484
+ };
485
+ }
486
+ function summarize(manifest) {
487
+ return {
488
+ id: manifest.id,
489
+ kind: manifest.kind,
490
+ workspace: manifest.workspace,
491
+ ...(manifest.sessionId === undefined ? {} : { sessionId: manifest.sessionId }),
492
+ ...(manifest.label === undefined ? {} : { label: manifest.label }),
493
+ ...(manifest.parentRestorePoint === undefined ? {} : { parentRestorePoint: manifest.parentRestorePoint }),
494
+ ...(manifest.turn === undefined ? {} : { turn: manifest.turn }),
495
+ ...(manifest.turnStartSeq === undefined ? {} : { turnStartSeq: manifest.turnStartSeq }),
496
+ ...(manifest.turnEndSeq === undefined ? {} : { turnEndSeq: manifest.turnEndSeq }),
497
+ createdAt: manifest.createdAt,
498
+ treeHash: manifest.treeHash,
499
+ fileCount: manifest.fileCount,
500
+ totalBytes: manifest.totalBytes,
501
+ restoreCount: manifest.restoreCount,
502
+ ...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
503
+ ...(manifest.repository.head === undefined ? {} : { head: manifest.repository.head }),
504
+ ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
505
+ ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
506
+ stagedPathCount: manifest.repository.stagedPaths.length,
507
+ };
508
+ }
509
+ function assertRepositoryCompatible(manifest, current, allowHeadChange) {
510
+ if (manifest.repository.root !== current.root || manifest.repository.commonDir !== current.commonDir) {
511
+ throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree');
512
+ }
513
+ if (manifest.repository.operation !== current.operation) {
514
+ throw new ChangeLedgerError('GIT_OPERATION_CHANGED', `Git operation changed from ${manifest.repository.operation ?? 'none'} to ${current.operation ?? 'none'}`);
515
+ }
516
+ if (!allowHeadChange && repositoryHeadChanged(manifest.repository, current)) {
517
+ throw new ChangeLedgerError('HEAD_CHANGED', 'HEAD or branch changed since the restore point; re-plan with allowHeadChange only after reviewing the diff');
518
+ }
519
+ }
520
+ function repositoryHeadChanged(before, after) {
521
+ return before.head !== after.head || before.branch !== after.branch;
522
+ }
523
+ function assertPlanRepositoryFresh(planned, current) {
524
+ if (planned.root !== current.root
525
+ || planned.commonDir !== current.commonDir
526
+ || planned.head !== current.head
527
+ || planned.branch !== current.branch
528
+ || planned.operation !== current.operation) {
529
+ throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after restore planning; inspect and plan again');
530
+ }
531
+ }
532
+ function selectChanges(changes, requested) {
533
+ if (requested === undefined || requested.length === 0)
534
+ return [...changes];
535
+ const normalized = requested.map(validateRelativePath);
536
+ if (new Set(normalized).size !== normalized.length) {
537
+ throw new ChangeLedgerError('DUPLICATE_PATH', 'restore path selection contains duplicates');
538
+ }
539
+ const byPath = new Map(changes.map((change) => [change.path, change]));
540
+ return normalized.map((path) => {
541
+ const change = byPath.get(path);
542
+ if (change === undefined)
543
+ throw new ChangeLedgerError('PATH_NOT_CHANGED', `${JSON.stringify(path)} is not changed from the restore point`);
544
+ return change;
545
+ });
546
+ }
547
+ function assertPlanFresh(plan, current) {
548
+ for (const path of plan.paths) {
549
+ const expected = plan.expected[path];
550
+ if (expected === undefined)
551
+ throw new ChangeLedgerError('PLAN_CORRUPT', `plan lacks expected state for ${JSON.stringify(path)}`);
552
+ const normalizedExpected = expected === null ? undefined : expected;
553
+ if (!entriesEqual(normalizedExpected, current[path])) {
554
+ throw new ChangeLedgerError('PLAN_STALE', `workspace changed after planning at ${JSON.stringify(path)}; inspect and plan again`);
555
+ }
556
+ }
557
+ }
558
+ async function assertNoUnmanagedRestoreConflicts(workspace, desired, current, paths) {
559
+ for (const path of paths) {
560
+ if (desired[path] === undefined || current[path] !== undefined)
561
+ continue;
562
+ const target = resolveWorkspacePath(workspace, path);
563
+ try {
564
+ const info = await lstat(target);
565
+ if (info.isDirectory() && !info.isSymbolicLink())
566
+ continue;
567
+ }
568
+ catch (error) {
569
+ if (isNodeError(error, 'ENOENT'))
570
+ continue;
571
+ throw error;
572
+ }
573
+ throw new ChangeLedgerError('UNMANAGED_PATH_CONFLICT', `refusing to replace ${JSON.stringify(path)} because it exists on disk but is excluded from the current Git snapshot`);
574
+ }
575
+ }
576
+ function clonePlan(plan) {
577
+ return structuredClone(plan);
578
+ }
579
+ function makeId(prefix) {
580
+ return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString('hex')}`;
581
+ }
582
+ function normalizeLabel(label) {
583
+ if (label === undefined)
584
+ return undefined;
585
+ const normalized = label.trim();
586
+ if (normalized === '')
587
+ throw new ChangeLedgerError('INVALID_LABEL', 'restore-point label must not be blank');
588
+ if (normalized.length > 200)
589
+ throw new ChangeLedgerError('INVALID_LABEL', 'restore-point label must be at most 200 characters');
590
+ return normalized;
591
+ }
592
+ function positiveInteger(value, name) {
593
+ if (!Number.isSafeInteger(value) || value <= 0) {
594
+ throw new ChangeLedgerError('INVALID_CONFIG', `${name} must be a positive safe integer`);
595
+ }
596
+ return value;
597
+ }
598
+ function requireNonEmptyString(value, name) {
599
+ if (typeof value !== 'string' || value.trim() === '') {
600
+ throw new ChangeLedgerError('INVALID_CONFIG', `${name} must be a non-empty string`);
601
+ }
602
+ return value;
603
+ }
604
+ function throwIfAborted(signal) {
605
+ if (signal?.aborted === true)
606
+ throw signal.reason;
607
+ }
608
+ function depth(path) {
609
+ return path.split('/').length;
610
+ }
611
+ function compareDeepestFirst(left, right) {
612
+ return depth(right) - depth(left) || Buffer.from(left).compare(Buffer.from(right));
613
+ }
614
+ function compareShallowestFirst(left, right) {
615
+ return depth(left) - depth(right) || Buffer.from(left).compare(Buffer.from(right));
616
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Stable error carrying a machine-readable change-ledger code. */
2
+ export class ChangeLedgerError extends Error {
3
+ code;
4
+ name = 'ChangeLedgerError';
5
+ /**
6
+ * @param code - Stable error category.
7
+ * @param message - Human-readable diagnostic.
8
+ * @param cause - Optional lower-level failure.
9
+ */
10
+ constructor(code, message, options) {
11
+ super(`[${code}] ${message}`, options);
12
+ this.code = code;
13
+ }
14
+ }
15
+ /** Convert an unknown thrown value into one bounded diagnostic line. */
16
+ export function errorMessage(error) {
17
+ if (error instanceof Error)
18
+ return error.message.slice(0, 2_000);
19
+ return String(error).slice(0, 2_000);
20
+ }