@anionex/dsh-turn-rewind 0.1.1 → 0.2.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 (47) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +19 -6
  3. package/SECURITY.md +3 -1
  4. package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
  5. package/docs/assets/turn-rewind-dialog.png +0 -0
  6. package/lib/client.js +291 -22
  7. package/lib/client.js.map +1 -1
  8. package/lib/deadline.js +13 -0
  9. package/lib/engine.js +622 -121
  10. package/lib/git-checkpoint.js +739 -0
  11. package/lib/git.js +295 -22
  12. package/lib/index.js +18 -2
  13. package/lib/path-utils.js +9 -0
  14. package/lib/rewind-host.js +250 -48
  15. package/lib/settings.js +58 -0
  16. package/lib/snapshot.js +113 -13
  17. package/lib/store.js +1361 -119
  18. package/lib/types/client/index.d.ts +74 -7
  19. package/lib/types/deadline.d.ts +6 -0
  20. package/lib/types/engine.d.ts +48 -2
  21. package/lib/types/git-checkpoint.d.ts +46 -0
  22. package/lib/types/git.d.ts +31 -0
  23. package/lib/types/index.d.ts +7 -0
  24. package/lib/types/path-utils.d.ts +2 -0
  25. package/lib/types/rewind-host.d.ts +8 -1
  26. package/lib/types/settings.d.ts +44 -0
  27. package/lib/types/snapshot.d.ts +6 -1
  28. package/lib/types/store.d.ts +90 -6
  29. package/lib/types/types.d.ts +63 -1
  30. package/lib/types.js +2 -0
  31. package/lib/validate.js +68 -9
  32. package/package.json +21 -3
  33. package/scripts/benchmark-git-checkpoint.mjs +149 -0
  34. package/scripts/wrap-client.mjs +18 -0
  35. package/src/client/index.tsx +536 -41
  36. package/src/deadline.ts +18 -0
  37. package/src/engine.ts +695 -126
  38. package/src/git-checkpoint.ts +853 -0
  39. package/src/git.ts +337 -22
  40. package/src/index.ts +21 -2
  41. package/src/path-utils.ts +10 -0
  42. package/src/rewind-host.ts +278 -50
  43. package/src/settings.ts +93 -0
  44. package/src/snapshot.ts +122 -12
  45. package/src/store.ts +1502 -104
  46. package/src/types.ts +69 -1
  47. package/src/validate.ts +67 -7
package/src/engine.ts CHANGED
@@ -2,9 +2,25 @@ import { randomBytes } from 'node:crypto'
2
2
  import { homedir } from 'node:os'
3
3
  import { join, resolve } from 'node:path'
4
4
  import { lstat, realpath } from 'node:fs/promises'
5
+ import { createDeadline } from './deadline.js'
5
6
  import { ChangeLedgerError, errorMessage } from './errors.js'
6
- import { discoverRepository, sameRepositoryFence } from './git.js'
7
7
  import {
8
+ discoverRepository,
9
+ discoverRepositoryRoot,
10
+ ensureGitWorktreeIdentity,
11
+ resolveGitWorktreeRoot,
12
+ sameRepositoryFence,
13
+ } from './git.js'
14
+ import {
15
+ captureGitTurnCheckpoint,
16
+ deleteGitCheckpoint,
17
+ deleteGitCheckpointRef,
18
+ publishGitCheckpoint,
19
+ readGitBlob,
20
+ verifyGitCheckpoint,
21
+ } from './git-checkpoint.js'
22
+ import {
23
+ assertSafeParents,
8
24
  ensureSafeParents,
9
25
  expandHome,
10
26
  isNodeError,
@@ -16,8 +32,8 @@ import {
16
32
  resolveWorkspacePath,
17
33
  validateRelativePath,
18
34
  } from './path-utils.js'
19
- import { captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
20
- import { LedgerStore } from './store.js'
35
+ import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
36
+ import { LedgerStore, type GitCheckpointJournal } from './store.js'
21
37
  import {
22
38
  LEDGER_FORMAT_VERSION,
23
39
  type ChangeLedgerConfig,
@@ -28,10 +44,13 @@ import {
28
44
  type RestorePointInspection,
29
45
  type RestorePointKind,
30
46
  type RestorePointManifest,
47
+ type RestorePointManifestV2,
31
48
  type RestorePointSummary,
32
49
  type RestoreResult,
33
50
  type SnapshotEntry,
34
51
  type WorkspaceChange,
52
+ type WorkspaceOverview,
53
+ type WorkspacePurgeReport,
35
54
  } from './types.js'
36
55
 
37
56
  const DEFAULTS = {
@@ -42,11 +61,15 @@ const DEFAULTS = {
42
61
  maxSnapshotBytes: 512 * 1024 * 1024,
43
62
  planTtlMs: 15 * 60 * 1_000,
44
63
  staleLockMs: 30_000,
64
+ turnCheckpointMode: 'legacy',
65
+ turnCheckpointTimeoutMs: 5_000,
66
+ turnCheckpointMaxNewBytes: 32 * 1024 * 1024,
67
+ turnCheckpointTrust: 'fast',
45
68
  } as const
46
69
 
47
70
  /** Persistent workspace change-set engine, independent of the DSH tool adapter. */
48
71
  export class ChangeLedgerEngine {
49
- readonly config: ResolvedChangeLedgerConfig
72
+ private readonly currentConfig: ResolvedChangeLedgerConfig
50
73
  readonly store: LedgerStore
51
74
  private readonly plans = new Map<string, RestorePlan>()
52
75
  private readonly activePlans = new Set<string>()
@@ -54,9 +77,26 @@ export class ChangeLedgerEngine {
54
77
 
55
78
  /** Build an engine and start crash-journal reconciliation. */
56
79
  constructor(config: ChangeLedgerConfig = {}) {
57
- this.config = resolveConfig(config)
58
- this.store = new LedgerStore(this.config)
59
- this.ready = this.store.initialize()
80
+ this.currentConfig = resolveConfig(config)
81
+ this.store = new LedgerStore(this.currentConfig)
82
+ this.ready = this.initializeStore()
83
+ }
84
+
85
+ /** Currently resolved configuration; runtime-tunable fields update in place. */
86
+ get config(): ResolvedChangeLedgerConfig {
87
+ return this.currentConfig
88
+ }
89
+
90
+ /**
91
+ * Swap runtime-tunable configuration (limits, modes, timeouts, trust) in place.
92
+ * The storage root must never move once the engine owns locks and journals.
93
+ */
94
+ updateConfig(config: ChangeLedgerConfig): void {
95
+ const resolved = resolveConfig(config)
96
+ if (resolved.storageDir !== this.currentConfig.storageDir) {
97
+ throw new ChangeLedgerError('INVALID_CONFIG', 'storageDir cannot change while the engine is running')
98
+ }
99
+ Object.assign(this.currentConfig as WritableResolvedConfig, resolved)
60
100
  }
61
101
 
62
102
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
@@ -64,6 +104,117 @@ export class ChangeLedgerEngine {
64
104
  return this.ready
65
105
  }
66
106
 
107
+ private async initializeStore(): Promise<number> {
108
+ const restoredOperations = await this.store.initialize()
109
+ return restoredOperations + await this.reconcileGitCheckpointJournals()
110
+ }
111
+
112
+ private async acquireWorkspace(workspace: string, signal?: AbortSignal): Promise<() => Promise<void>> {
113
+ const identity = await ensureGitWorktreeIdentity(workspace, signal)
114
+ if (identity.root !== workspace) {
115
+ throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`)
116
+ }
117
+ return this.store.acquire(workspace, identity.lockPath, {
118
+ commonDir: identity.commonDir,
119
+ gitDir: identity.gitDir,
120
+ worktreeId: identity.worktreeId,
121
+ }, signal)
122
+ }
123
+
124
+ private async reconcileGitCheckpointJournals(): Promise<number> {
125
+ let reconciled = 0
126
+ for (const journal of await this.store.listGitCheckpointJournals()) {
127
+ const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace)
128
+ const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace }
129
+ const release = await this.acquireWorkspace(workspace)
130
+ try {
131
+ const manifest = await this.tryReadManifest(workspace, journal.restorePointId)
132
+ if (journal.action === 'publish') {
133
+ if (manifest === undefined) {
134
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
135
+ } else {
136
+ assertJournalMatchesManifest(activeJournal, manifest)
137
+ await verifyGitCheckpoint(manifest)
138
+ }
139
+ } else {
140
+ if (manifest !== undefined) {
141
+ assertJournalMatchesManifest(activeJournal, manifest)
142
+ await this.store.deleteManifest(workspace, journal.restorePointId)
143
+ }
144
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
145
+ }
146
+ await this.store.deleteGitCheckpointJournal(workspace, journal.restorePointId)
147
+ reconciled += 1
148
+ } finally {
149
+ await release()
150
+ }
151
+ }
152
+ return reconciled
153
+ }
154
+
155
+ private async publishGitManifest(manifest: RestorePointManifestV2, signal?: AbortSignal): Promise<void> {
156
+ let journalWritten = false
157
+ try {
158
+ await this.store.writeGitCheckpointJournal('publish', manifest)
159
+ journalWritten = true
160
+ await publishGitCheckpoint(manifest, this.config.storageDir, signal)
161
+ await verifyGitCheckpoint(manifest, signal)
162
+ await this.store.writeManifest(manifest)
163
+ } catch (error) {
164
+ if (!journalWritten) throw error
165
+ const persisted = await this.tryReadManifest(manifest.workspace, manifest.id)
166
+ if (persisted !== undefined) {
167
+ assertJournalMatchesManifest({
168
+ version: 1,
169
+ action: 'publish',
170
+ storeId: manifest.git.storeId,
171
+ workspace: manifest.workspace,
172
+ commonDir: manifest.repository.commonDir,
173
+ worktreeId: manifest.git.worktreeId,
174
+ restorePointId: manifest.id,
175
+ ref: manifest.git.ref,
176
+ commit: manifest.git.commit,
177
+ createdAt: manifest.createdAt,
178
+ }, persisted)
179
+ await verifyGitCheckpoint(persisted, signal)
180
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
181
+ return
182
+ }
183
+ try {
184
+ await deleteGitCheckpoint(manifest, this.config.storageDir)
185
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
186
+ } catch (cleanupError) {
187
+ throw new ChangeLedgerError(
188
+ 'SNAPSHOT_CLEANUP_FAILED',
189
+ `Git checkpoint publication failed (${errorMessage(error)}) and journaled ref cleanup also failed (${errorMessage(cleanupError)})`,
190
+ { cause: error },
191
+ )
192
+ }
193
+ throw error
194
+ }
195
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
196
+ }
197
+
198
+ private async deleteManifestWithGit(manifest: RestorePointManifest, signal?: AbortSignal): Promise<void> {
199
+ if (manifest.version === LEDGER_FORMAT_VERSION) {
200
+ await this.store.deleteManifest(manifest.workspace, manifest.id)
201
+ return
202
+ }
203
+ await this.store.writeGitCheckpointJournal('delete', manifest)
204
+ await this.store.deleteManifest(manifest.workspace, manifest.id)
205
+ await deleteGitCheckpoint(manifest, this.config.storageDir, signal)
206
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
207
+ }
208
+
209
+ private async tryReadManifest(workspace: string, id: string): Promise<RestorePointManifest | undefined> {
210
+ try {
211
+ return await this.store.readManifest(workspace, id)
212
+ } catch (error) {
213
+ if (error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND') return undefined
214
+ throw error
215
+ }
216
+ }
217
+
67
218
  /** Create a durable restore point for the current Git worktree. */
68
219
  async create(options: {
69
220
  readonly cwd: string
@@ -74,7 +225,7 @@ export class ChangeLedgerEngine {
74
225
  await this.ready
75
226
  const source = await discoverRepository(options.cwd, options.signal)
76
227
  await this.assertStorageSeparated(source.state.root)
77
- const release = await this.store.acquire(source.state.root)
228
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
78
229
  try {
79
230
  const label = normalizeLabel(options.label)
80
231
  const manifest = await this.createLocked({
@@ -98,41 +249,156 @@ export class ChangeLedgerEngine {
98
249
  readonly turnStartSeq: number
99
250
  readonly signal?: AbortSignal
100
251
  }): 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')
252
+ const deadline = createDeadline(this.config.turnCheckpointTimeoutMs)
253
+ const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal])
254
+ try {
255
+ try {
256
+ await waitWithSignal(this.ready, signal)
257
+ } catch (error) {
258
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
259
+ }
260
+ if (this.config.turnCheckpointMode === 'off') {
261
+ throw new ChangeLedgerError('TURN_CHECKPOINT_DISABLED', 'automatic turn checkpoints are disabled')
262
+ }
263
+ if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
264
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer')
265
+ }
266
+ if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
267
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer')
268
+ }
269
+ try {
270
+ const source = await discoverRepository(options.cwd, signal)
271
+ await this.assertStorageSeparated(source.state.root)
272
+ const release = await this.acquireWorkspace(source.state.root, signal)
273
+ try {
274
+ const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal)
275
+ const duplicate = existing.find(manifest => manifest.kind === 'turn'
276
+ && manifest.sessionId === options.sessionId
277
+ && manifest.turn === options.turn
278
+ && manifest.turnStartSeq === options.turnStartSeq)
279
+ if (duplicate !== undefined) {
280
+ if (duplicate.version === 2) await verifyGitCheckpoint(duplicate, signal)
281
+ await this.store.deleteTurnCheckpointSkip(
282
+ source.state.root,
283
+ options.sessionId,
284
+ options.turn,
285
+ options.turnStartSeq,
286
+ ).catch(() => undefined)
287
+ return summarize(duplicate)
288
+ }
289
+
290
+ throwIfAborted(signal)
291
+ let manifest: RestorePointManifest
292
+ if (this.config.turnCheckpointMode === 'legacy') {
293
+ manifest = await this.createLocked({
294
+ cwd: source.state.root,
295
+ kind: 'turn',
296
+ sessionId: options.sessionId,
297
+ label: `Before turn ${String(options.turn)} checkpoint`,
298
+ turn: options.turn,
299
+ turnStartSeq: options.turnStartSeq,
300
+ signal,
301
+ deferGarbageCollection: true,
302
+ })
303
+ } else {
304
+ const id = makeId('rp')
305
+ const captured = (await captureGitTurnCheckpoint({
306
+ cwd: source.state.root,
307
+ id,
308
+ sessionId: options.sessionId,
309
+ turn: options.turn,
310
+ turnStartSeq: options.turnStartSeq,
311
+ config: this.config,
312
+ signal,
313
+ })).manifest
314
+ await this.publishGitManifest(captured, signal)
315
+ manifest = captured
316
+ }
317
+ await this.store.deleteTurnCheckpointSkip(
318
+ source.state.root,
319
+ options.sessionId,
320
+ options.turn,
321
+ options.turnStartSeq,
322
+ ).catch(() => undefined)
323
+
324
+ if (!signal.aborted) {
325
+ const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
326
+ .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
327
+ for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
328
+ if (signal.aborted) break
329
+ if (await this.store.isReferencedByRecovery(source.state.root, stale.id)) continue
330
+ await this.deleteManifestWithGit(stale, signal).catch(() => undefined)
331
+ }
332
+ }
333
+ return summarize(manifest)
334
+ } finally {
335
+ await release()
336
+ }
337
+ } catch (error) {
338
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
339
+ }
340
+ } finally {
341
+ deadline.cancel()
107
342
  }
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)
343
+ }
344
+
345
+ /** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
346
+ async recordTurnCheckpointSkip(options: {
347
+ readonly cwd: string
348
+ readonly sessionId: string
349
+ readonly turn: number
350
+ readonly turnStartSeq: number
351
+ readonly reason: string
352
+ readonly signal?: AbortSignal
353
+ }): Promise<void> {
354
+ await waitWithSignal(this.ready, options.signal)
355
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
356
+ await this.assertStorageSeparated(workspace)
357
+ const release = await this.acquireWorkspace(workspace, options.signal)
111
358
  try {
112
- const existing = await this.store.listManifests(source.state.root)
113
- const duplicate = existing.find(manifest => manifest.kind === 'turn'
359
+ throwIfAborted(options.signal)
360
+ const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest =>
361
+ manifest.kind === 'turn'
114
362
  && manifest.sessionId === options.sessionId
115
363
  && manifest.turn === options.turn
116
364
  && 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',
365
+ if (existing) {
366
+ await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq)
367
+ return
368
+ }
369
+ await this.store.writeTurnCheckpointSkip(workspace, {
370
+ version: 1,
122
371
  sessionId: options.sessionId,
123
- label: `Before turn ${String(options.turn)} checkpoint`,
124
372
  turn: options.turn,
125
373
  turnStartSeq: options.turnStartSeq,
126
- ...(options.signal === undefined ? {} : { signal: options.signal }),
374
+ reason: options.reason.slice(0, 2_000),
375
+ createdAt: Date.now(),
127
376
  })
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)
377
+ } finally {
378
+ await release()
379
+ }
380
+ }
381
+
382
+ /** Read a durable skip marker for one exact prompt boundary. */
383
+ async findTurnCheckpointSkip(options: {
384
+ readonly cwd: string
385
+ readonly sessionId: string
386
+ readonly turn: number
387
+ readonly turnStartSeq: number
388
+ readonly signal?: AbortSignal
389
+ }): Promise<{ readonly reason: string } | undefined> {
390
+ await this.ready
391
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
392
+ await this.assertStorageSeparated(workspace)
393
+ const release = await this.acquireWorkspace(workspace, options.signal)
394
+ try {
395
+ const skip = await this.store.readTurnCheckpointSkip(
396
+ workspace,
397
+ options.sessionId,
398
+ options.turn,
399
+ options.turnStartSeq,
400
+ )
401
+ return skip === undefined ? undefined : { reason: skip.reason }
136
402
  } finally {
137
403
  await release()
138
404
  }
@@ -148,12 +414,18 @@ export class ChangeLedgerEngine {
148
414
  await this.ready
149
415
  const source = await discoverRepository(options.cwd, options.signal)
150
416
  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)
417
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
418
+ try {
419
+ const manifest = (await this.store.listManifests(source.state.root)).find(point =>
420
+ point.kind === 'turn'
421
+ && point.sessionId === options.sessionId
422
+ && point.turn === options.turn
423
+ && point.turnStartSeq !== undefined)
424
+ if (manifest?.version === 2) await verifyGitCheckpoint(manifest, options.signal)
425
+ return manifest === undefined ? undefined : summarize(manifest)
426
+ } finally {
427
+ await release()
428
+ }
157
429
  }
158
430
 
159
431
  /** List restore points for the current worktree. */
@@ -166,12 +438,17 @@ export class ChangeLedgerEngine {
166
438
  await this.ready
167
439
  const source = await discoverRepository(options.cwd, options.signal)
168
440
  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)
441
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
442
+ try {
443
+ const manifests = await this.store.listManifests(source.state.root)
444
+ return manifests
445
+ .filter((manifest) => manifest.kind === 'user'
446
+ || (manifest.kind === 'rescue' && options.includeRescue === true)
447
+ || (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
448
+ .map(summarize)
449
+ } finally {
450
+ await release()
451
+ }
175
452
  }
176
453
 
177
454
  /** Compare one restore point with the current worktree. */
@@ -183,18 +460,31 @@ export class ChangeLedgerEngine {
183
460
  await this.ready
184
461
  const source = await discoverRepository(options.cwd, options.signal)
185
462
  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),
463
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
464
+ try {
465
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
466
+ if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
467
+ const current = await captureStableTree({
468
+ cwd: source.state.root,
469
+ config: this.config,
470
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
471
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
472
+ })
473
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
474
+ if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
475
+ return {
476
+ restorePoint: summarize(manifest),
477
+ currentTreeHash: current.treeHash,
478
+ currentRepository: current.source.state,
479
+ ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
480
+ ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
481
+ ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
482
+ headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
483
+ operationChanged: manifest.repository.operation !== current.source.state.operation,
484
+ changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
485
+ }
486
+ } finally {
487
+ await release()
198
488
  }
199
489
  }
200
490
 
@@ -213,45 +503,58 @@ export class ChangeLedgerEngine {
213
503
  this.expirePlans()
214
504
  const source = await discoverRepository(options.cwd, options.signal)
215
505
  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)
506
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
507
+ try {
508
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
509
+ if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
510
+ const current = await captureStableTree({
511
+ cwd: source.state.root,
512
+ config: this.config,
513
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
514
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
515
+ })
516
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
517
+ if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
518
+ if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
519
+ throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again')
520
+ }
521
+ if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
522
+ throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again')
523
+ }
524
+ assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
525
+ const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)
526
+ if (changes.length === 0) {
527
+ throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`)
528
+ }
529
+ const selected = selectChanges(changes, options.paths)
530
+ await assertNoUnmanagedRestoreConflicts(
531
+ source.state.root,
532
+ manifest.entries,
533
+ current.entries,
534
+ selected.map((change) => change.path),
535
+ )
536
+ const expected: Record<string, SnapshotEntry | null> = Object.create(null) as Record<string, SnapshotEntry | null>
537
+ for (const change of selected) expected[change.path] = current.entries[change.path] ?? null
538
+ const now = Date.now()
539
+ const plan: RestorePlan = {
540
+ id: makeId('plan'),
541
+ restorePointId: manifest.id,
542
+ workspace: manifest.workspace,
543
+ repository: current.source.state,
544
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
545
+ createdAt: now,
546
+ expiresAt: now + this.config.planTtlMs,
547
+ confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
548
+ allowHeadChange: options.allowHeadChange === true,
549
+ paths: selected.map((change) => change.path),
550
+ changes: selected,
551
+ expected,
552
+ }
553
+ this.plans.set(plan.id, plan)
554
+ return clonePlan(plan)
555
+ } finally {
556
+ await release()
557
+ }
255
558
  }
256
559
 
257
560
  /** Apply one approved restore plan, creating a durable rescue point first. */
@@ -278,9 +581,10 @@ export class ChangeLedgerEngine {
278
581
 
279
582
  try {
280
583
  await this.assertStorageSeparated(plan.workspace)
281
- const release = await this.store.acquire(plan.workspace)
584
+ const release = await this.acquireWorkspace(plan.workspace, options.signal)
282
585
  try {
283
586
  const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId)
587
+ if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
284
588
  const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) })
285
589
  assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange)
286
590
  assertPlanRepositoryFresh(plan.repository, current.source.state)
@@ -317,8 +621,14 @@ export class ChangeLedgerEngine {
317
621
  await this.store.writeOperation(operation)
318
622
 
319
623
  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)
624
+ await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal, plan.expected)
625
+ await this.verifyPaths(
626
+ plan.workspace,
627
+ manifest.entries,
628
+ plan.paths,
629
+ options.signal,
630
+ manifest.version === 2 ? manifest.git.objectFormat : undefined,
631
+ )
322
632
  } catch (error) {
323
633
  const primaryError = errorMessage(error)
324
634
  let journalWarning: string | undefined
@@ -413,13 +723,13 @@ export class ChangeLedgerEngine {
413
723
  }
414
724
  const source = await discoverRepository(options.cwd, options.signal)
415
725
  await this.assertStorageSeparated(source.state.root)
416
- const release = await this.store.acquire(source.state.root)
726
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
417
727
  try {
418
- await this.store.readManifest(source.state.root, options.restorePointId)
728
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
419
729
  if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
420
730
  throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal')
421
731
  }
422
- await this.store.deleteManifest(source.state.root, options.restorePointId)
732
+ await this.deleteManifestWithGit(manifest, options.signal)
423
733
  const gc = await this.store.collectGarbage(source.state.root)
424
734
  return { restorePointId: options.restorePointId, ...gc }
425
735
  } finally {
@@ -432,19 +742,153 @@ export class ChangeLedgerEngine {
432
742
  await this.ready
433
743
  const source = await discoverRepository(options.cwd, options.signal)
434
744
  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 }),
745
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
746
+ try {
747
+ return (await this.store.listOperations(source.state.root))
748
+ .filter((operation): operation is RestoreOperation & { state: 'interrupted' | 'recovery-required' } =>
749
+ operation.state === 'interrupted' || operation.state === 'recovery-required')
750
+ .map((operation) => ({
751
+ operationId: operation.id,
752
+ restorePointId: operation.restorePointId,
753
+ rescuePointId: operation.rescuePointId,
754
+ state: operation.state,
755
+ paths: operation.paths,
756
+ startedAt: operation.startedAt,
757
+ ...(operation.error === undefined ? {} : { error: operation.error }),
758
+ ...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
759
+ }))
760
+ } finally {
761
+ await release()
762
+ }
763
+ }
764
+
765
+ /** Inventory every workspace this storage root has ever persisted state for. */
766
+ async listWorkspaces(options: { readonly signal?: AbortSignal } = {}): Promise<WorkspaceOverview[]> {
767
+ await this.ready
768
+ const groups = new Map<string, { restorePoints: RestorePointSummary[]; recoveryCount: number }>()
769
+ const groupOf = (workspace: string): { restorePoints: RestorePointSummary[]; recoveryCount: number } => {
770
+ const existing = groups.get(workspace)
771
+ if (existing !== undefined) return existing
772
+ const created = { restorePoints: [], recoveryCount: 0 }
773
+ groups.set(workspace, created)
774
+ return created
775
+ }
776
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
777
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
778
+ groupOf(manifest.workspace).restorePoints.push(summarize(manifest))
779
+ }
780
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
781
+ if (operation.state === 'interrupted' || operation.state === 'recovery-required') {
782
+ groupOf(operation.workspace).recoveryCount += 1
783
+ }
784
+ }
785
+ }
786
+ return [...groups.entries()]
787
+ .map(([workspace, group]) => ({
788
+ workspace,
789
+ restorePoints: [...group.restorePoints].sort(
790
+ (left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id),
791
+ ),
792
+ totalBytes: group.restorePoints.reduce((total, point) => total + point.totalBytes, 0),
793
+ recoveryCount: group.recoveryCount,
447
794
  }))
795
+ .sort((left, right) => left.workspace.localeCompare(right.workspace))
796
+ }
797
+
798
+ /** Delete unprotected restore points recorded for one workspace and collect unused blobs. */
799
+ async purgeWorkspace(options: {
800
+ readonly workspace: string
801
+ /** Restrict the purge to these restore points; protection rules still apply. */
802
+ readonly restorePointIds?: readonly string[]
803
+ readonly signal?: AbortSignal
804
+ }): Promise<WorkspacePurgeReport> {
805
+ await this.ready
806
+ const restorePointIds = options.restorePointIds === undefined ? undefined : new Set(options.restorePointIds)
807
+ const targetDirs = new Set<string>()
808
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
809
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
810
+ if (manifest.workspace === options.workspace
811
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id))) {
812
+ targetDirs.add(workspaceDir)
813
+ }
814
+ }
815
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
816
+ if (operation.workspace === options.workspace) targetDirs.add(workspaceDir)
817
+ }
818
+ }
819
+ let deletedRestorePoints = 0
820
+ let retainedRestorePoints = 0
821
+ let deletedBlobs = 0
822
+ let retainedBlobs = 0
823
+ for (const workspaceDir of targetDirs) {
824
+ const result = await this.purgeWorkspaceDir(workspaceDir, options.workspace, restorePointIds, options.signal)
825
+ deletedRestorePoints += result.deletedRestorePoints
826
+ retainedRestorePoints += result.retainedRestorePoints
827
+ deletedBlobs += result.deletedBlobs
828
+ retainedBlobs += result.retainedBlobs
829
+ }
830
+ return { workspace: options.workspace, deletedRestorePoints, retainedRestorePoints, deletedBlobs, retainedBlobs }
831
+ }
832
+
833
+ private async purgeWorkspaceDir(
834
+ workspaceDir: string,
835
+ workspace: string,
836
+ restorePointIds: ReadonlySet<string> | undefined,
837
+ signal?: AbortSignal,
838
+ ): Promise<{ deletedRestorePoints: number; retainedRestorePoints: number; deletedBlobs: number; retainedBlobs: number }> {
839
+ // A live repository needs both its shared Git lock and durable directory lock.
840
+ // A missing path, or one now nested under a different repository, cannot expose
841
+ // its old Git namespace but still takes the durable lock before storage cleanup.
842
+ let liveRepository = false
843
+ let release: () => Promise<void>
844
+ try {
845
+ const root = await discoverRepositoryRoot(workspace, signal)
846
+ if (root === workspace) {
847
+ await this.assertStorageSeparated(root)
848
+ release = await this.acquireWorkspace(root, signal)
849
+ liveRepository = true
850
+ } else {
851
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
852
+ }
853
+ } catch (error) {
854
+ if (!(error instanceof ChangeLedgerError) || error.code !== 'WORKSPACE_NOT_FOUND') throw error
855
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
856
+ }
857
+ let deletedRestorePoints = 0
858
+ let retainedRestorePoints = 0
859
+ try {
860
+ const manifests = (await this.store.listManifestsInDir(workspaceDir, signal))
861
+ .filter((manifest) => manifest.workspace === workspace
862
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id)))
863
+ const protectedIds = new Set<string>()
864
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, signal)) {
865
+ if (operation.state === 'running' || operation.state === 'rollback-running'
866
+ || operation.state === 'interrupted' || operation.state === 'recovery-required') {
867
+ protectedIds.add(operation.restorePointId)
868
+ protectedIds.add(operation.rescuePointId)
869
+ }
870
+ }
871
+ for (const journal of await this.store.listGitCheckpointJournalsInDir(workspaceDir, signal)) {
872
+ protectedIds.add(journal.restorePointId)
873
+ }
874
+ for (const manifest of manifests) {
875
+ if (protectedIds.has(manifest.id)) {
876
+ retainedRestorePoints += 1
877
+ continue
878
+ }
879
+ if (liveRepository) {
880
+ await this.deleteManifestWithGit(manifest, signal)
881
+ } else {
882
+ await this.store.deleteManifestInDir(workspaceDir, manifest.id)
883
+ }
884
+ deletedRestorePoints += 1
885
+ }
886
+ if (restorePointIds === undefined) await this.store.clearTurnOutcomesInDir(workspaceDir)
887
+ const gc = await this.store.collectGarbageInDir(workspaceDir, signal)
888
+ return { deletedRestorePoints, retainedRestorePoints, ...gc }
889
+ } finally {
890
+ await release()
891
+ }
448
892
  }
449
893
 
450
894
  private async createLocked(options: {
@@ -457,6 +901,7 @@ export class ChangeLedgerEngine {
457
901
  readonly turnStartSeq?: number
458
902
  readonly turnEndSeq?: number
459
903
  readonly signal?: AbortSignal
904
+ readonly deferGarbageCollection?: boolean
460
905
  }): Promise<RestorePointManifest> {
461
906
  const existing = await this.store.listManifests(options.cwd)
462
907
  const durableUserPoints = existing.filter(point => point.kind !== 'turn')
@@ -466,6 +911,11 @@ export class ChangeLedgerEngine {
466
911
  `workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`,
467
912
  )
468
913
  }
914
+ if (options.deferGarbageCollection === true) {
915
+ await this.store.reconcileSnapshotCleanup(options.cwd, options.signal)
916
+ throwIfAborted(options.signal)
917
+ await this.store.writeSnapshotCleanup(options.cwd)
918
+ }
469
919
  try {
470
920
  const tree = await captureStableTree({
471
921
  cwd: options.cwd,
@@ -492,15 +942,21 @@ export class ChangeLedgerEngine {
492
942
  entries: tree.entries,
493
943
  restoreCount: 0,
494
944
  }
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
- )
945
+ if (options.deferGarbageCollection !== true) {
946
+ await this.store.collectGarbage(
947
+ options.cwd,
948
+ Object.values(manifest.entries)
949
+ .filter((entry): entry is SnapshotEntry & { readonly kind: 'file' } => entry.kind === 'file' && entry.provider !== 'git')
950
+ .map((entry) => entry.blob),
951
+ )
952
+ }
501
953
  await this.store.writeManifest(manifest)
954
+ if (options.deferGarbageCollection === true) {
955
+ await this.store.deleteSnapshotCleanup(options.cwd).catch(() => undefined)
956
+ }
502
957
  return manifest
503
958
  } catch (error) {
959
+ if (options.deferGarbageCollection === true) throw error
504
960
  try {
505
961
  await this.store.collectGarbage(options.cwd)
506
962
  } catch (cleanupError) {
@@ -519,11 +975,14 @@ export class ChangeLedgerEngine {
519
975
  desiredEntries: Readonly<Record<string, SnapshotEntry>>,
520
976
  paths: readonly string[],
521
977
  signal?: AbortSignal,
978
+ expectedEntries?: Readonly<Record<string, SnapshotEntry | null>>,
522
979
  ): Promise<void> {
523
980
  const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst)
524
981
  for (const path of deletions) {
525
982
  throwIfAborted(signal)
526
983
  const target = resolveWorkspacePath(workspace, path)
984
+ if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
985
+ await assertSafeParents(workspace, target)
527
986
  await removeRestoreTarget(target)
528
987
  await pruneEmptyParents(workspace, target)
529
988
  }
@@ -534,6 +993,7 @@ export class ChangeLedgerEngine {
534
993
  const entry = desiredEntries[path]
535
994
  if (entry === undefined) continue
536
995
  const target = resolveWorkspacePath(workspace, path)
996
+ if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
537
997
  await ensureSafeParents(workspace, target)
538
998
  try {
539
999
  const info = await lstat(target)
@@ -542,10 +1002,14 @@ export class ChangeLedgerEngine {
542
1002
  if (!isNodeError(error, 'ENOENT')) throw error
543
1003
  }
544
1004
  if (entry.kind === 'file') {
545
- const content = await this.store.readBlob(workspace, entry.blob)
1005
+ const content = entry.provider === 'git'
1006
+ ? await readGitBlob(workspace, entry.blob, entry.size, signal)
1007
+ : await this.store.readBlob(workspace, entry.blob)
546
1008
  if (content.length !== entry.size) {
547
1009
  throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`)
548
1010
  }
1011
+ if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
1012
+ else await assertSafeParents(workspace, target)
549
1013
  await replaceRegularFile(target, content, entry.mode)
550
1014
  } else {
551
1015
  await replaceSymbolicLink(target, entry.target)
@@ -553,18 +1017,60 @@ export class ChangeLedgerEngine {
553
1017
  }
554
1018
  }
555
1019
 
1020
+ private async assertPathFresh(
1021
+ workspace: string,
1022
+ path: string,
1023
+ expected: SnapshotEntry | null | undefined,
1024
+ signal?: AbortSignal,
1025
+ ): Promise<void> {
1026
+ const target = resolveWorkspacePath(workspace, path)
1027
+ await assertSafeParents(workspace, target)
1028
+ let current: SnapshotEntry | undefined
1029
+ try {
1030
+ current = await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal)
1031
+ } catch (error) {
1032
+ if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
1033
+ throw new ChangeLedgerError('PLAN_STALE', `workspace path changed type after planning: ${JSON.stringify(path)}`, { cause: error })
1034
+ }
1035
+ throw error
1036
+ }
1037
+ if (!entriesEqual(expected === null ? undefined : expected, current)) {
1038
+ throw new ChangeLedgerError('PLAN_STALE', `workspace changed immediately before restore at ${JSON.stringify(path)}; inspect and plan again`)
1039
+ }
1040
+ }
1041
+
556
1042
  private async verifyPaths(
557
1043
  workspace: string,
558
1044
  desiredEntries: Readonly<Record<string, SnapshotEntry>>,
559
1045
  paths: readonly string[],
560
1046
  signal?: AbortSignal,
1047
+ gitObjectFormat?: 'sha1' | 'sha256',
561
1048
  ): 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)}`)
1049
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1050
+ const first = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
1051
+ const second = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
1052
+ if (!paths.every(path => entriesEqual(first.get(path), second.get(path)))) continue
1053
+ for (const path of paths) {
1054
+ if (!entriesEqual(desiredEntries[path], second.get(path))) {
1055
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`)
1056
+ }
566
1057
  }
1058
+ return
567
1059
  }
1060
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', 'restored paths did not remain stable during post-verification')
1061
+ }
1062
+
1063
+ private async captureSelectedEntries(
1064
+ workspace: string,
1065
+ paths: readonly string[],
1066
+ signal?: AbortSignal,
1067
+ gitObjectFormat?: 'sha1' | 'sha256',
1068
+ ): Promise<ReadonlyMap<string, SnapshotEntry | undefined>> {
1069
+ const entries = new Map<string, SnapshotEntry | undefined>()
1070
+ for (const path of paths) {
1071
+ entries.set(path, await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal, gitObjectFormat))
1072
+ }
1073
+ return entries
568
1074
  }
569
1075
 
570
1076
  private async assertStorageSeparated(workspace: string): Promise<void> {
@@ -585,6 +1091,9 @@ export class ChangeLedgerEngine {
585
1091
  }
586
1092
  }
587
1093
 
1094
+ /** Mutable view used only by `updateConfig` to swap fields on the shared resolved object. */
1095
+ type WritableResolvedConfig = { -readonly [K in keyof ResolvedChangeLedgerConfig]: ResolvedChangeLedgerConfig[K] }
1096
+
588
1097
  /** Resolve and validate every deployment-varying configuration value. */
589
1098
  export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerConfig {
590
1099
  const home = homedir()
@@ -606,11 +1115,22 @@ export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerC
606
1115
  maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
607
1116
  planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
608
1117
  staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
1118
+ turnCheckpointMode: checkpointMode(config.turnCheckpointMode ?? DEFAULTS.turnCheckpointMode),
1119
+ turnCheckpointTimeoutMs: positiveInteger(
1120
+ config.turnCheckpointTimeoutMs ?? DEFAULTS.turnCheckpointTimeoutMs,
1121
+ 'turnCheckpointTimeoutMs',
1122
+ ),
1123
+ turnCheckpointMaxNewBytes: positiveInteger(
1124
+ config.turnCheckpointMaxNewBytes ?? DEFAULTS.turnCheckpointMaxNewBytes,
1125
+ 'turnCheckpointMaxNewBytes',
1126
+ ),
1127
+ turnCheckpointTrust: checkpointTrust(config.turnCheckpointTrust ?? DEFAULTS.turnCheckpointTrust),
609
1128
  }
610
1129
  }
611
1130
 
612
1131
  function summarize(manifest: RestorePointManifest): RestorePointSummary {
613
1132
  return {
1133
+ format: manifest.version,
614
1134
  id: manifest.id,
615
1135
  kind: manifest.kind,
616
1136
  workspace: manifest.workspace,
@@ -630,6 +1150,7 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
630
1150
  ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
631
1151
  ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
632
1152
  stagedPathCount: manifest.repository.stagedPaths.length,
1153
+ ...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
633
1154
  }
634
1155
  }
635
1156
 
@@ -675,6 +1196,19 @@ function assertPlanRepositoryFresh(
675
1196
  }
676
1197
  }
677
1198
 
1199
+ function assertJournalMatchesManifest(journal: GitCheckpointJournal, manifest: RestorePointManifest): asserts manifest is RestorePointManifestV2 {
1200
+ if (manifest.version !== 2
1201
+ || manifest.workspace !== journal.workspace
1202
+ || manifest.id !== journal.restorePointId
1203
+ || manifest.repository.commonDir !== journal.commonDir
1204
+ || manifest.git.storeId !== journal.storeId
1205
+ || manifest.git.worktreeId !== journal.worktreeId
1206
+ || manifest.git.ref !== journal.ref
1207
+ || manifest.git.commit !== journal.commit) {
1208
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal does not match restore point ${journal.restorePointId}`)
1209
+ }
1210
+ }
1211
+
678
1212
  function selectChanges(changes: readonly WorkspaceChange[], requested?: readonly string[]): WorkspaceChange[] {
679
1213
  if (requested === undefined || requested.length === 0) return [...changes]
680
1214
  const normalized = requested.map(validateRelativePath)
@@ -753,10 +1287,45 @@ function requireNonEmptyString(value: string, name: string): string {
753
1287
  return value
754
1288
  }
755
1289
 
1290
+ function checkpointMode(value: ChangeLedgerConfig['turnCheckpointMode']): ResolvedChangeLedgerConfig['turnCheckpointMode'] {
1291
+ if (value !== 'off' && value !== 'git-native' && value !== 'legacy') {
1292
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointMode must be off, git-native, or legacy')
1293
+ }
1294
+ return value
1295
+ }
1296
+
1297
+ function checkpointTrust(value: ChangeLedgerConfig['turnCheckpointTrust']): ResolvedChangeLedgerConfig['turnCheckpointTrust'] {
1298
+ if (value !== 'fast' && value !== 'strict') {
1299
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointTrust must be fast or strict')
1300
+ }
1301
+ return value
1302
+ }
1303
+
756
1304
  function throwIfAborted(signal?: AbortSignal): void {
757
1305
  if (signal?.aborted === true) throw signal.reason
758
1306
  }
759
1307
 
1308
+ async function waitWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
1309
+ if (signal === undefined) return promise
1310
+ signal.throwIfAborted()
1311
+ let rejectAbort: ((reason?: unknown) => void) | undefined
1312
+ const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
1313
+ const onAbort = () => rejectAbort?.(signal.reason)
1314
+ signal.addEventListener('abort', onAbort, { once: true })
1315
+ try {
1316
+ return await Promise.race([promise, aborted])
1317
+ } finally {
1318
+ signal.removeEventListener('abort', onAbort)
1319
+ }
1320
+ }
1321
+
1322
+ function checkpointDeadlineError(error: unknown, timeoutMs: number, deadline: AbortSignal, callerSignal?: AbortSignal): unknown {
1323
+ if (callerSignal?.aborted !== true && deadline.aborted) {
1324
+ return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error })
1325
+ }
1326
+ return error
1327
+ }
1328
+
760
1329
  function depth(path: string): number {
761
1330
  return path.split('/').length
762
1331
  }