@anionex/dsh-turn-rewind 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,6 +44,7 @@ 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,
@@ -42,6 +59,10 @@ const DEFAULTS = {
42
59
  maxSnapshotBytes: 512 * 1024 * 1024,
43
60
  planTtlMs: 15 * 60 * 1_000,
44
61
  staleLockMs: 30_000,
62
+ turnCheckpointMode: 'legacy',
63
+ turnCheckpointTimeoutMs: 5_000,
64
+ turnCheckpointMaxNewBytes: 32 * 1024 * 1024,
65
+ turnCheckpointTrust: 'fast',
45
66
  } as const
46
67
 
47
68
  /** Persistent workspace change-set engine, independent of the DSH tool adapter. */
@@ -56,7 +77,7 @@ export class ChangeLedgerEngine {
56
77
  constructor(config: ChangeLedgerConfig = {}) {
57
78
  this.config = resolveConfig(config)
58
79
  this.store = new LedgerStore(this.config)
59
- this.ready = this.store.initialize()
80
+ this.ready = this.initializeStore()
60
81
  }
61
82
 
62
83
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
@@ -64,6 +85,117 @@ export class ChangeLedgerEngine {
64
85
  return this.ready
65
86
  }
66
87
 
88
+ private async initializeStore(): Promise<number> {
89
+ const restoredOperations = await this.store.initialize()
90
+ return restoredOperations + await this.reconcileGitCheckpointJournals()
91
+ }
92
+
93
+ private async acquireWorkspace(workspace: string, signal?: AbortSignal): Promise<() => Promise<void>> {
94
+ const identity = await ensureGitWorktreeIdentity(workspace, signal)
95
+ if (identity.root !== workspace) {
96
+ throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`)
97
+ }
98
+ return this.store.acquire(workspace, identity.lockPath, {
99
+ commonDir: identity.commonDir,
100
+ gitDir: identity.gitDir,
101
+ worktreeId: identity.worktreeId,
102
+ }, signal)
103
+ }
104
+
105
+ private async reconcileGitCheckpointJournals(): Promise<number> {
106
+ let reconciled = 0
107
+ for (const journal of await this.store.listGitCheckpointJournals()) {
108
+ const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace)
109
+ const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace }
110
+ const release = await this.acquireWorkspace(workspace)
111
+ try {
112
+ const manifest = await this.tryReadManifest(workspace, journal.restorePointId)
113
+ if (journal.action === 'publish') {
114
+ if (manifest === undefined) {
115
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
116
+ } else {
117
+ assertJournalMatchesManifest(activeJournal, manifest)
118
+ await verifyGitCheckpoint(manifest)
119
+ }
120
+ } else {
121
+ if (manifest !== undefined) {
122
+ assertJournalMatchesManifest(activeJournal, manifest)
123
+ await this.store.deleteManifest(workspace, journal.restorePointId)
124
+ }
125
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
126
+ }
127
+ await this.store.deleteGitCheckpointJournal(workspace, journal.restorePointId)
128
+ reconciled += 1
129
+ } finally {
130
+ await release()
131
+ }
132
+ }
133
+ return reconciled
134
+ }
135
+
136
+ private async publishGitManifest(manifest: RestorePointManifestV2, signal?: AbortSignal): Promise<void> {
137
+ let journalWritten = false
138
+ try {
139
+ await this.store.writeGitCheckpointJournal('publish', manifest)
140
+ journalWritten = true
141
+ await publishGitCheckpoint(manifest, this.config.storageDir, signal)
142
+ await verifyGitCheckpoint(manifest, signal)
143
+ await this.store.writeManifest(manifest)
144
+ } catch (error) {
145
+ if (!journalWritten) throw error
146
+ const persisted = await this.tryReadManifest(manifest.workspace, manifest.id)
147
+ if (persisted !== undefined) {
148
+ assertJournalMatchesManifest({
149
+ version: 1,
150
+ action: 'publish',
151
+ storeId: manifest.git.storeId,
152
+ workspace: manifest.workspace,
153
+ commonDir: manifest.repository.commonDir,
154
+ worktreeId: manifest.git.worktreeId,
155
+ restorePointId: manifest.id,
156
+ ref: manifest.git.ref,
157
+ commit: manifest.git.commit,
158
+ createdAt: manifest.createdAt,
159
+ }, persisted)
160
+ await verifyGitCheckpoint(persisted, signal)
161
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
162
+ return
163
+ }
164
+ try {
165
+ await deleteGitCheckpoint(manifest, this.config.storageDir)
166
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
167
+ } catch (cleanupError) {
168
+ throw new ChangeLedgerError(
169
+ 'SNAPSHOT_CLEANUP_FAILED',
170
+ `Git checkpoint publication failed (${errorMessage(error)}) and journaled ref cleanup also failed (${errorMessage(cleanupError)})`,
171
+ { cause: error },
172
+ )
173
+ }
174
+ throw error
175
+ }
176
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
177
+ }
178
+
179
+ private async deleteManifestWithGit(manifest: RestorePointManifest, signal?: AbortSignal): Promise<void> {
180
+ if (manifest.version === LEDGER_FORMAT_VERSION) {
181
+ await this.store.deleteManifest(manifest.workspace, manifest.id)
182
+ return
183
+ }
184
+ await this.store.writeGitCheckpointJournal('delete', manifest)
185
+ await this.store.deleteManifest(manifest.workspace, manifest.id)
186
+ await deleteGitCheckpoint(manifest, this.config.storageDir, signal)
187
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
188
+ }
189
+
190
+ private async tryReadManifest(workspace: string, id: string): Promise<RestorePointManifest | undefined> {
191
+ try {
192
+ return await this.store.readManifest(workspace, id)
193
+ } catch (error) {
194
+ if (error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND') return undefined
195
+ throw error
196
+ }
197
+ }
198
+
67
199
  /** Create a durable restore point for the current Git worktree. */
68
200
  async create(options: {
69
201
  readonly cwd: string
@@ -74,7 +206,7 @@ export class ChangeLedgerEngine {
74
206
  await this.ready
75
207
  const source = await discoverRepository(options.cwd, options.signal)
76
208
  await this.assertStorageSeparated(source.state.root)
77
- const release = await this.store.acquire(source.state.root)
209
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
78
210
  try {
79
211
  const label = normalizeLabel(options.label)
80
212
  const manifest = await this.createLocked({
@@ -98,41 +230,156 @@ export class ChangeLedgerEngine {
98
230
  readonly turnStartSeq: number
99
231
  readonly signal?: AbortSignal
100
232
  }): 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')
233
+ const deadline = createDeadline(this.config.turnCheckpointTimeoutMs)
234
+ const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal])
235
+ try {
236
+ try {
237
+ await waitWithSignal(this.ready, signal)
238
+ } catch (error) {
239
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
240
+ }
241
+ if (this.config.turnCheckpointMode === 'off') {
242
+ throw new ChangeLedgerError('TURN_CHECKPOINT_DISABLED', 'automatic turn checkpoints are disabled')
243
+ }
244
+ if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
245
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer')
246
+ }
247
+ if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
248
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer')
249
+ }
250
+ try {
251
+ const source = await discoverRepository(options.cwd, signal)
252
+ await this.assertStorageSeparated(source.state.root)
253
+ const release = await this.acquireWorkspace(source.state.root, signal)
254
+ try {
255
+ const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal)
256
+ const duplicate = existing.find(manifest => manifest.kind === 'turn'
257
+ && manifest.sessionId === options.sessionId
258
+ && manifest.turn === options.turn
259
+ && manifest.turnStartSeq === options.turnStartSeq)
260
+ if (duplicate !== undefined) {
261
+ if (duplicate.version === 2) await verifyGitCheckpoint(duplicate, signal)
262
+ await this.store.deleteTurnCheckpointSkip(
263
+ source.state.root,
264
+ options.sessionId,
265
+ options.turn,
266
+ options.turnStartSeq,
267
+ ).catch(() => undefined)
268
+ return summarize(duplicate)
269
+ }
270
+
271
+ throwIfAborted(signal)
272
+ let manifest: RestorePointManifest
273
+ if (this.config.turnCheckpointMode === 'legacy') {
274
+ manifest = await this.createLocked({
275
+ cwd: source.state.root,
276
+ kind: 'turn',
277
+ sessionId: options.sessionId,
278
+ label: `Before turn ${String(options.turn)} checkpoint`,
279
+ turn: options.turn,
280
+ turnStartSeq: options.turnStartSeq,
281
+ signal,
282
+ deferGarbageCollection: true,
283
+ })
284
+ } else {
285
+ const id = makeId('rp')
286
+ const captured = (await captureGitTurnCheckpoint({
287
+ cwd: source.state.root,
288
+ id,
289
+ sessionId: options.sessionId,
290
+ turn: options.turn,
291
+ turnStartSeq: options.turnStartSeq,
292
+ config: this.config,
293
+ signal,
294
+ })).manifest
295
+ await this.publishGitManifest(captured, signal)
296
+ manifest = captured
297
+ }
298
+ await this.store.deleteTurnCheckpointSkip(
299
+ source.state.root,
300
+ options.sessionId,
301
+ options.turn,
302
+ options.turnStartSeq,
303
+ ).catch(() => undefined)
304
+
305
+ if (!signal.aborted) {
306
+ const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
307
+ .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
308
+ for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
309
+ if (signal.aborted) break
310
+ if (await this.store.isReferencedByRecovery(source.state.root, stale.id)) continue
311
+ await this.deleteManifestWithGit(stale, signal).catch(() => undefined)
312
+ }
313
+ }
314
+ return summarize(manifest)
315
+ } finally {
316
+ await release()
317
+ }
318
+ } catch (error) {
319
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
320
+ }
321
+ } finally {
322
+ deadline.cancel()
107
323
  }
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)
324
+ }
325
+
326
+ /** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
327
+ async recordTurnCheckpointSkip(options: {
328
+ readonly cwd: string
329
+ readonly sessionId: string
330
+ readonly turn: number
331
+ readonly turnStartSeq: number
332
+ readonly reason: string
333
+ readonly signal?: AbortSignal
334
+ }): Promise<void> {
335
+ await waitWithSignal(this.ready, options.signal)
336
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
337
+ await this.assertStorageSeparated(workspace)
338
+ const release = await this.acquireWorkspace(workspace, options.signal)
111
339
  try {
112
- const existing = await this.store.listManifests(source.state.root)
113
- const duplicate = existing.find(manifest => manifest.kind === 'turn'
340
+ throwIfAborted(options.signal)
341
+ const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest =>
342
+ manifest.kind === 'turn'
114
343
  && manifest.sessionId === options.sessionId
115
344
  && manifest.turn === options.turn
116
345
  && 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',
346
+ if (existing) {
347
+ await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq)
348
+ return
349
+ }
350
+ await this.store.writeTurnCheckpointSkip(workspace, {
351
+ version: 1,
122
352
  sessionId: options.sessionId,
123
- label: `Before turn ${String(options.turn)} checkpoint`,
124
353
  turn: options.turn,
125
354
  turnStartSeq: options.turnStartSeq,
126
- ...(options.signal === undefined ? {} : { signal: options.signal }),
355
+ reason: options.reason.slice(0, 2_000),
356
+ createdAt: Date.now(),
127
357
  })
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)
358
+ } finally {
359
+ await release()
360
+ }
361
+ }
362
+
363
+ /** Read a durable skip marker for one exact prompt boundary. */
364
+ async findTurnCheckpointSkip(options: {
365
+ readonly cwd: string
366
+ readonly sessionId: string
367
+ readonly turn: number
368
+ readonly turnStartSeq: number
369
+ readonly signal?: AbortSignal
370
+ }): Promise<{ readonly reason: string } | undefined> {
371
+ await this.ready
372
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
373
+ await this.assertStorageSeparated(workspace)
374
+ const release = await this.acquireWorkspace(workspace, options.signal)
375
+ try {
376
+ const skip = await this.store.readTurnCheckpointSkip(
377
+ workspace,
378
+ options.sessionId,
379
+ options.turn,
380
+ options.turnStartSeq,
381
+ )
382
+ return skip === undefined ? undefined : { reason: skip.reason }
136
383
  } finally {
137
384
  await release()
138
385
  }
@@ -148,12 +395,18 @@ export class ChangeLedgerEngine {
148
395
  await this.ready
149
396
  const source = await discoverRepository(options.cwd, options.signal)
150
397
  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)
398
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
399
+ try {
400
+ const manifest = (await this.store.listManifests(source.state.root)).find(point =>
401
+ point.kind === 'turn'
402
+ && point.sessionId === options.sessionId
403
+ && point.turn === options.turn
404
+ && point.turnStartSeq !== undefined)
405
+ if (manifest?.version === 2) await verifyGitCheckpoint(manifest, options.signal)
406
+ return manifest === undefined ? undefined : summarize(manifest)
407
+ } finally {
408
+ await release()
409
+ }
157
410
  }
158
411
 
159
412
  /** List restore points for the current worktree. */
@@ -166,12 +419,17 @@ export class ChangeLedgerEngine {
166
419
  await this.ready
167
420
  const source = await discoverRepository(options.cwd, options.signal)
168
421
  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)
422
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
423
+ try {
424
+ const manifests = await this.store.listManifests(source.state.root)
425
+ return manifests
426
+ .filter((manifest) => manifest.kind === 'user'
427
+ || (manifest.kind === 'rescue' && options.includeRescue === true)
428
+ || (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
429
+ .map(summarize)
430
+ } finally {
431
+ await release()
432
+ }
175
433
  }
176
434
 
177
435
  /** Compare one restore point with the current worktree. */
@@ -183,18 +441,31 @@ export class ChangeLedgerEngine {
183
441
  await this.ready
184
442
  const source = await discoverRepository(options.cwd, options.signal)
185
443
  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),
444
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
445
+ try {
446
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
447
+ if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
448
+ const current = await captureStableTree({
449
+ cwd: source.state.root,
450
+ config: this.config,
451
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
452
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
453
+ })
454
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
455
+ if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
456
+ return {
457
+ restorePoint: summarize(manifest),
458
+ currentTreeHash: current.treeHash,
459
+ currentRepository: current.source.state,
460
+ ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
461
+ ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
462
+ ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
463
+ headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
464
+ operationChanged: manifest.repository.operation !== current.source.state.operation,
465
+ changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
466
+ }
467
+ } finally {
468
+ await release()
198
469
  }
199
470
  }
200
471
 
@@ -213,45 +484,58 @@ export class ChangeLedgerEngine {
213
484
  this.expirePlans()
214
485
  const source = await discoverRepository(options.cwd, options.signal)
215
486
  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)
487
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
488
+ try {
489
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
490
+ if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
491
+ const current = await captureStableTree({
492
+ cwd: source.state.root,
493
+ config: this.config,
494
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
495
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
496
+ })
497
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
498
+ if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
499
+ if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
500
+ throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again')
501
+ }
502
+ if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
503
+ throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again')
504
+ }
505
+ assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
506
+ const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)
507
+ if (changes.length === 0) {
508
+ throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`)
509
+ }
510
+ const selected = selectChanges(changes, options.paths)
511
+ await assertNoUnmanagedRestoreConflicts(
512
+ source.state.root,
513
+ manifest.entries,
514
+ current.entries,
515
+ selected.map((change) => change.path),
516
+ )
517
+ const expected: Record<string, SnapshotEntry | null> = Object.create(null) as Record<string, SnapshotEntry | null>
518
+ for (const change of selected) expected[change.path] = current.entries[change.path] ?? null
519
+ const now = Date.now()
520
+ const plan: RestorePlan = {
521
+ id: makeId('plan'),
522
+ restorePointId: manifest.id,
523
+ workspace: manifest.workspace,
524
+ repository: current.source.state,
525
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
526
+ createdAt: now,
527
+ expiresAt: now + this.config.planTtlMs,
528
+ confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
529
+ allowHeadChange: options.allowHeadChange === true,
530
+ paths: selected.map((change) => change.path),
531
+ changes: selected,
532
+ expected,
533
+ }
534
+ this.plans.set(plan.id, plan)
535
+ return clonePlan(plan)
536
+ } finally {
537
+ await release()
538
+ }
255
539
  }
256
540
 
257
541
  /** Apply one approved restore plan, creating a durable rescue point first. */
@@ -278,9 +562,10 @@ export class ChangeLedgerEngine {
278
562
 
279
563
  try {
280
564
  await this.assertStorageSeparated(plan.workspace)
281
- const release = await this.store.acquire(plan.workspace)
565
+ const release = await this.acquireWorkspace(plan.workspace, options.signal)
282
566
  try {
283
567
  const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId)
568
+ if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
284
569
  const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) })
285
570
  assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange)
286
571
  assertPlanRepositoryFresh(plan.repository, current.source.state)
@@ -317,8 +602,14 @@ export class ChangeLedgerEngine {
317
602
  await this.store.writeOperation(operation)
318
603
 
319
604
  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)
605
+ await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal, plan.expected)
606
+ await this.verifyPaths(
607
+ plan.workspace,
608
+ manifest.entries,
609
+ plan.paths,
610
+ options.signal,
611
+ manifest.version === 2 ? manifest.git.objectFormat : undefined,
612
+ )
322
613
  } catch (error) {
323
614
  const primaryError = errorMessage(error)
324
615
  let journalWarning: string | undefined
@@ -413,13 +704,13 @@ export class ChangeLedgerEngine {
413
704
  }
414
705
  const source = await discoverRepository(options.cwd, options.signal)
415
706
  await this.assertStorageSeparated(source.state.root)
416
- const release = await this.store.acquire(source.state.root)
707
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
417
708
  try {
418
- await this.store.readManifest(source.state.root, options.restorePointId)
709
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
419
710
  if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
420
711
  throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal')
421
712
  }
422
- await this.store.deleteManifest(source.state.root, options.restorePointId)
713
+ await this.deleteManifestWithGit(manifest, options.signal)
423
714
  const gc = await this.store.collectGarbage(source.state.root)
424
715
  return { restorePointId: options.restorePointId, ...gc }
425
716
  } finally {
@@ -432,19 +723,24 @@ export class ChangeLedgerEngine {
432
723
  await this.ready
433
724
  const source = await discoverRepository(options.cwd, options.signal)
434
725
  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
- }))
726
+ const release = await this.acquireWorkspace(source.state.root, options.signal)
727
+ try {
728
+ return (await this.store.listOperations(source.state.root))
729
+ .filter((operation): operation is RestoreOperation & { state: 'interrupted' | 'recovery-required' } =>
730
+ operation.state === 'interrupted' || operation.state === 'recovery-required')
731
+ .map((operation) => ({
732
+ operationId: operation.id,
733
+ restorePointId: operation.restorePointId,
734
+ rescuePointId: operation.rescuePointId,
735
+ state: operation.state,
736
+ paths: operation.paths,
737
+ startedAt: operation.startedAt,
738
+ ...(operation.error === undefined ? {} : { error: operation.error }),
739
+ ...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
740
+ }))
741
+ } finally {
742
+ await release()
743
+ }
448
744
  }
449
745
 
450
746
  private async createLocked(options: {
@@ -457,6 +753,7 @@ export class ChangeLedgerEngine {
457
753
  readonly turnStartSeq?: number
458
754
  readonly turnEndSeq?: number
459
755
  readonly signal?: AbortSignal
756
+ readonly deferGarbageCollection?: boolean
460
757
  }): Promise<RestorePointManifest> {
461
758
  const existing = await this.store.listManifests(options.cwd)
462
759
  const durableUserPoints = existing.filter(point => point.kind !== 'turn')
@@ -466,6 +763,11 @@ export class ChangeLedgerEngine {
466
763
  `workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`,
467
764
  )
468
765
  }
766
+ if (options.deferGarbageCollection === true) {
767
+ await this.store.reconcileSnapshotCleanup(options.cwd, options.signal)
768
+ throwIfAborted(options.signal)
769
+ await this.store.writeSnapshotCleanup(options.cwd)
770
+ }
469
771
  try {
470
772
  const tree = await captureStableTree({
471
773
  cwd: options.cwd,
@@ -492,15 +794,21 @@ export class ChangeLedgerEngine {
492
794
  entries: tree.entries,
493
795
  restoreCount: 0,
494
796
  }
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
- )
797
+ if (options.deferGarbageCollection !== true) {
798
+ await this.store.collectGarbage(
799
+ options.cwd,
800
+ Object.values(manifest.entries)
801
+ .filter((entry): entry is SnapshotEntry & { readonly kind: 'file' } => entry.kind === 'file' && entry.provider !== 'git')
802
+ .map((entry) => entry.blob),
803
+ )
804
+ }
501
805
  await this.store.writeManifest(manifest)
806
+ if (options.deferGarbageCollection === true) {
807
+ await this.store.deleteSnapshotCleanup(options.cwd).catch(() => undefined)
808
+ }
502
809
  return manifest
503
810
  } catch (error) {
811
+ if (options.deferGarbageCollection === true) throw error
504
812
  try {
505
813
  await this.store.collectGarbage(options.cwd)
506
814
  } catch (cleanupError) {
@@ -519,11 +827,14 @@ export class ChangeLedgerEngine {
519
827
  desiredEntries: Readonly<Record<string, SnapshotEntry>>,
520
828
  paths: readonly string[],
521
829
  signal?: AbortSignal,
830
+ expectedEntries?: Readonly<Record<string, SnapshotEntry | null>>,
522
831
  ): Promise<void> {
523
832
  const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst)
524
833
  for (const path of deletions) {
525
834
  throwIfAborted(signal)
526
835
  const target = resolveWorkspacePath(workspace, path)
836
+ if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
837
+ await assertSafeParents(workspace, target)
527
838
  await removeRestoreTarget(target)
528
839
  await pruneEmptyParents(workspace, target)
529
840
  }
@@ -534,6 +845,7 @@ export class ChangeLedgerEngine {
534
845
  const entry = desiredEntries[path]
535
846
  if (entry === undefined) continue
536
847
  const target = resolveWorkspacePath(workspace, path)
848
+ if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
537
849
  await ensureSafeParents(workspace, target)
538
850
  try {
539
851
  const info = await lstat(target)
@@ -542,10 +854,14 @@ export class ChangeLedgerEngine {
542
854
  if (!isNodeError(error, 'ENOENT')) throw error
543
855
  }
544
856
  if (entry.kind === 'file') {
545
- const content = await this.store.readBlob(workspace, entry.blob)
857
+ const content = entry.provider === 'git'
858
+ ? await readGitBlob(workspace, entry.blob, entry.size, signal)
859
+ : await this.store.readBlob(workspace, entry.blob)
546
860
  if (content.length !== entry.size) {
547
861
  throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`)
548
862
  }
863
+ if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
864
+ else await assertSafeParents(workspace, target)
549
865
  await replaceRegularFile(target, content, entry.mode)
550
866
  } else {
551
867
  await replaceSymbolicLink(target, entry.target)
@@ -553,18 +869,60 @@ export class ChangeLedgerEngine {
553
869
  }
554
870
  }
555
871
 
872
+ private async assertPathFresh(
873
+ workspace: string,
874
+ path: string,
875
+ expected: SnapshotEntry | null | undefined,
876
+ signal?: AbortSignal,
877
+ ): Promise<void> {
878
+ const target = resolveWorkspacePath(workspace, path)
879
+ await assertSafeParents(workspace, target)
880
+ let current: SnapshotEntry | undefined
881
+ try {
882
+ current = await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal)
883
+ } catch (error) {
884
+ if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
885
+ throw new ChangeLedgerError('PLAN_STALE', `workspace path changed type after planning: ${JSON.stringify(path)}`, { cause: error })
886
+ }
887
+ throw error
888
+ }
889
+ if (!entriesEqual(expected === null ? undefined : expected, current)) {
890
+ throw new ChangeLedgerError('PLAN_STALE', `workspace changed immediately before restore at ${JSON.stringify(path)}; inspect and plan again`)
891
+ }
892
+ }
893
+
556
894
  private async verifyPaths(
557
895
  workspace: string,
558
896
  desiredEntries: Readonly<Record<string, SnapshotEntry>>,
559
897
  paths: readonly string[],
560
898
  signal?: AbortSignal,
899
+ gitObjectFormat?: 'sha1' | 'sha256',
561
900
  ): 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)}`)
901
+ for (let attempt = 0; attempt < 3; attempt += 1) {
902
+ const first = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
903
+ const second = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
904
+ if (!paths.every(path => entriesEqual(first.get(path), second.get(path)))) continue
905
+ for (const path of paths) {
906
+ if (!entriesEqual(desiredEntries[path], second.get(path))) {
907
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`)
908
+ }
566
909
  }
910
+ return
911
+ }
912
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', 'restored paths did not remain stable during post-verification')
913
+ }
914
+
915
+ private async captureSelectedEntries(
916
+ workspace: string,
917
+ paths: readonly string[],
918
+ signal?: AbortSignal,
919
+ gitObjectFormat?: 'sha1' | 'sha256',
920
+ ): Promise<ReadonlyMap<string, SnapshotEntry | undefined>> {
921
+ const entries = new Map<string, SnapshotEntry | undefined>()
922
+ for (const path of paths) {
923
+ entries.set(path, await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal, gitObjectFormat))
567
924
  }
925
+ return entries
568
926
  }
569
927
 
570
928
  private async assertStorageSeparated(workspace: string): Promise<void> {
@@ -606,11 +964,22 @@ export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerC
606
964
  maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
607
965
  planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
608
966
  staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
967
+ turnCheckpointMode: checkpointMode(config.turnCheckpointMode ?? DEFAULTS.turnCheckpointMode),
968
+ turnCheckpointTimeoutMs: positiveInteger(
969
+ config.turnCheckpointTimeoutMs ?? DEFAULTS.turnCheckpointTimeoutMs,
970
+ 'turnCheckpointTimeoutMs',
971
+ ),
972
+ turnCheckpointMaxNewBytes: positiveInteger(
973
+ config.turnCheckpointMaxNewBytes ?? DEFAULTS.turnCheckpointMaxNewBytes,
974
+ 'turnCheckpointMaxNewBytes',
975
+ ),
976
+ turnCheckpointTrust: checkpointTrust(config.turnCheckpointTrust ?? DEFAULTS.turnCheckpointTrust),
609
977
  }
610
978
  }
611
979
 
612
980
  function summarize(manifest: RestorePointManifest): RestorePointSummary {
613
981
  return {
982
+ format: manifest.version,
614
983
  id: manifest.id,
615
984
  kind: manifest.kind,
616
985
  workspace: manifest.workspace,
@@ -630,6 +999,7 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
630
999
  ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
631
1000
  ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
632
1001
  stagedPathCount: manifest.repository.stagedPaths.length,
1002
+ ...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
633
1003
  }
634
1004
  }
635
1005
 
@@ -675,6 +1045,19 @@ function assertPlanRepositoryFresh(
675
1045
  }
676
1046
  }
677
1047
 
1048
+ function assertJournalMatchesManifest(journal: GitCheckpointJournal, manifest: RestorePointManifest): asserts manifest is RestorePointManifestV2 {
1049
+ if (manifest.version !== 2
1050
+ || manifest.workspace !== journal.workspace
1051
+ || manifest.id !== journal.restorePointId
1052
+ || manifest.repository.commonDir !== journal.commonDir
1053
+ || manifest.git.storeId !== journal.storeId
1054
+ || manifest.git.worktreeId !== journal.worktreeId
1055
+ || manifest.git.ref !== journal.ref
1056
+ || manifest.git.commit !== journal.commit) {
1057
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal does not match restore point ${journal.restorePointId}`)
1058
+ }
1059
+ }
1060
+
678
1061
  function selectChanges(changes: readonly WorkspaceChange[], requested?: readonly string[]): WorkspaceChange[] {
679
1062
  if (requested === undefined || requested.length === 0) return [...changes]
680
1063
  const normalized = requested.map(validateRelativePath)
@@ -753,10 +1136,45 @@ function requireNonEmptyString(value: string, name: string): string {
753
1136
  return value
754
1137
  }
755
1138
 
1139
+ function checkpointMode(value: ChangeLedgerConfig['turnCheckpointMode']): ResolvedChangeLedgerConfig['turnCheckpointMode'] {
1140
+ if (value !== 'off' && value !== 'git-native' && value !== 'legacy') {
1141
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointMode must be off, git-native, or legacy')
1142
+ }
1143
+ return value
1144
+ }
1145
+
1146
+ function checkpointTrust(value: ChangeLedgerConfig['turnCheckpointTrust']): ResolvedChangeLedgerConfig['turnCheckpointTrust'] {
1147
+ if (value !== 'fast' && value !== 'strict') {
1148
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointTrust must be fast or strict')
1149
+ }
1150
+ return value
1151
+ }
1152
+
756
1153
  function throwIfAborted(signal?: AbortSignal): void {
757
1154
  if (signal?.aborted === true) throw signal.reason
758
1155
  }
759
1156
 
1157
+ async function waitWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
1158
+ if (signal === undefined) return promise
1159
+ signal.throwIfAborted()
1160
+ let rejectAbort: ((reason?: unknown) => void) | undefined
1161
+ const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
1162
+ const onAbort = () => rejectAbort?.(signal.reason)
1163
+ signal.addEventListener('abort', onAbort, { once: true })
1164
+ try {
1165
+ return await Promise.race([promise, aborted])
1166
+ } finally {
1167
+ signal.removeEventListener('abort', onAbort)
1168
+ }
1169
+ }
1170
+
1171
+ function checkpointDeadlineError(error: unknown, timeoutMs: number, deadline: AbortSignal, callerSignal?: AbortSignal): unknown {
1172
+ if (callerSignal?.aborted !== true && deadline.aborted) {
1173
+ return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error })
1174
+ }
1175
+ return error
1176
+ }
1177
+
760
1178
  function depth(path: string): number {
761
1179
  return path.split('/').length
762
1180
  }