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