@anionex/dsh-turn-rewind 0.1.0 → 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/lib/engine.js CHANGED
@@ -2,10 +2,12 @@ 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
- import { ensureSafeParents, expandHome, isNodeError, isWithin, pruneEmptyParents, removeRestoreTarget, replaceRegularFile, replaceSymbolicLink, resolveWorkspacePath, validateRelativePath, } from './path-utils.js';
8
- import { captureStableTree, diffTrees, entriesEqual } from './snapshot.js';
7
+ import { discoverRepository, discoverRepositoryRoot, ensureGitWorktreeIdentity, resolveGitWorktreeRoot, sameRepositoryFence, } from './git.js';
8
+ import { captureGitTurnCheckpoint, deleteGitCheckpoint, deleteGitCheckpointRef, publishGitCheckpoint, readGitBlob, verifyGitCheckpoint, } from './git-checkpoint.js';
9
+ import { assertSafeParents, ensureSafeParents, expandHome, isNodeError, isWithin, pruneEmptyParents, removeRestoreTarget, replaceRegularFile, replaceSymbolicLink, resolveWorkspacePath, validateRelativePath, } from './path-utils.js';
10
+ import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js';
9
11
  import { LedgerStore } from './store.js';
10
12
  import { LEDGER_FORMAT_VERSION, } from './types.js';
11
13
  const DEFAULTS = {
@@ -16,6 +18,10 @@ const DEFAULTS = {
16
18
  maxSnapshotBytes: 512 * 1024 * 1024,
17
19
  planTtlMs: 15 * 60 * 1_000,
18
20
  staleLockMs: 30_000,
21
+ turnCheckpointMode: 'legacy',
22
+ turnCheckpointTimeoutMs: 5_000,
23
+ turnCheckpointMaxNewBytes: 32 * 1024 * 1024,
24
+ turnCheckpointTrust: 'fast',
19
25
  };
20
26
  /** Persistent workspace change-set engine, independent of the DSH tool adapter. */
21
27
  export class ChangeLedgerEngine {
@@ -28,18 +34,127 @@ export class ChangeLedgerEngine {
28
34
  constructor(config = {}) {
29
35
  this.config = resolveConfig(config);
30
36
  this.store = new LedgerStore(this.config);
31
- this.ready = this.store.initialize();
37
+ this.ready = this.initializeStore();
32
38
  }
33
39
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
34
40
  async initialize() {
35
41
  return this.ready;
36
42
  }
43
+ async initializeStore() {
44
+ const restoredOperations = await this.store.initialize();
45
+ return restoredOperations + await this.reconcileGitCheckpointJournals();
46
+ }
47
+ async acquireWorkspace(workspace, signal) {
48
+ const identity = await ensureGitWorktreeIdentity(workspace, signal);
49
+ if (identity.root !== workspace) {
50
+ throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`);
51
+ }
52
+ return this.store.acquire(workspace, identity.lockPath, {
53
+ commonDir: identity.commonDir,
54
+ gitDir: identity.gitDir,
55
+ worktreeId: identity.worktreeId,
56
+ }, signal);
57
+ }
58
+ async reconcileGitCheckpointJournals() {
59
+ let reconciled = 0;
60
+ for (const journal of await this.store.listGitCheckpointJournals()) {
61
+ const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace);
62
+ const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace };
63
+ const release = await this.acquireWorkspace(workspace);
64
+ try {
65
+ const manifest = await this.tryReadManifest(workspace, journal.restorePointId);
66
+ if (journal.action === 'publish') {
67
+ if (manifest === undefined) {
68
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir);
69
+ }
70
+ else {
71
+ assertJournalMatchesManifest(activeJournal, manifest);
72
+ await verifyGitCheckpoint(manifest);
73
+ }
74
+ }
75
+ else {
76
+ if (manifest !== undefined) {
77
+ assertJournalMatchesManifest(activeJournal, manifest);
78
+ await this.store.deleteManifest(workspace, journal.restorePointId);
79
+ }
80
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir);
81
+ }
82
+ await this.store.deleteGitCheckpointJournal(workspace, journal.restorePointId);
83
+ reconciled += 1;
84
+ }
85
+ finally {
86
+ await release();
87
+ }
88
+ }
89
+ return reconciled;
90
+ }
91
+ async publishGitManifest(manifest, signal) {
92
+ let journalWritten = false;
93
+ try {
94
+ await this.store.writeGitCheckpointJournal('publish', manifest);
95
+ journalWritten = true;
96
+ await publishGitCheckpoint(manifest, this.config.storageDir, signal);
97
+ await verifyGitCheckpoint(manifest, signal);
98
+ await this.store.writeManifest(manifest);
99
+ }
100
+ catch (error) {
101
+ if (!journalWritten)
102
+ throw error;
103
+ const persisted = await this.tryReadManifest(manifest.workspace, manifest.id);
104
+ if (persisted !== undefined) {
105
+ assertJournalMatchesManifest({
106
+ version: 1,
107
+ action: 'publish',
108
+ storeId: manifest.git.storeId,
109
+ workspace: manifest.workspace,
110
+ commonDir: manifest.repository.commonDir,
111
+ worktreeId: manifest.git.worktreeId,
112
+ restorePointId: manifest.id,
113
+ ref: manifest.git.ref,
114
+ commit: manifest.git.commit,
115
+ createdAt: manifest.createdAt,
116
+ }, persisted);
117
+ await verifyGitCheckpoint(persisted, signal);
118
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined);
119
+ return;
120
+ }
121
+ try {
122
+ await deleteGitCheckpoint(manifest, this.config.storageDir);
123
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id);
124
+ }
125
+ catch (cleanupError) {
126
+ throw new ChangeLedgerError('SNAPSHOT_CLEANUP_FAILED', `Git checkpoint publication failed (${errorMessage(error)}) and journaled ref cleanup also failed (${errorMessage(cleanupError)})`, { cause: error });
127
+ }
128
+ throw error;
129
+ }
130
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined);
131
+ }
132
+ async deleteManifestWithGit(manifest, signal) {
133
+ if (manifest.version === LEDGER_FORMAT_VERSION) {
134
+ await this.store.deleteManifest(manifest.workspace, manifest.id);
135
+ return;
136
+ }
137
+ await this.store.writeGitCheckpointJournal('delete', manifest);
138
+ await this.store.deleteManifest(manifest.workspace, manifest.id);
139
+ await deleteGitCheckpoint(manifest, this.config.storageDir, signal);
140
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id);
141
+ }
142
+ async tryReadManifest(workspace, id) {
143
+ try {
144
+ return await this.store.readManifest(workspace, id);
145
+ }
146
+ catch (error) {
147
+ if (error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND')
148
+ return undefined;
149
+ throw error;
150
+ }
151
+ }
37
152
  /** Create a durable restore point for the current Git worktree. */
38
153
  async create(options) {
39
154
  await this.ready;
40
155
  const source = await discoverRepository(options.cwd, options.signal);
41
156
  await this.assertStorageSeparated(source.state.root);
42
- const release = await this.store.acquire(source.state.root);
157
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
43
158
  try {
44
159
  const label = normalizeLabel(options.label);
45
160
  const manifest = await this.createLocked({
@@ -57,42 +172,132 @@ export class ChangeLedgerEngine {
57
172
  }
58
173
  /** Capture project files before one DSH turn begins its first step. */
59
174
  async createTurnCheckpoint(options) {
60
- await this.ready;
61
- if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
62
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer');
175
+ const deadline = createDeadline(this.config.turnCheckpointTimeoutMs);
176
+ const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
177
+ try {
178
+ try {
179
+ await waitWithSignal(this.ready, signal);
180
+ }
181
+ catch (error) {
182
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal);
183
+ }
184
+ if (this.config.turnCheckpointMode === 'off') {
185
+ throw new ChangeLedgerError('TURN_CHECKPOINT_DISABLED', 'automatic turn checkpoints are disabled');
186
+ }
187
+ if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
188
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer');
189
+ }
190
+ if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
191
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
192
+ }
193
+ try {
194
+ const source = await discoverRepository(options.cwd, signal);
195
+ await this.assertStorageSeparated(source.state.root);
196
+ const release = await this.acquireWorkspace(source.state.root, signal);
197
+ try {
198
+ const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal);
199
+ const duplicate = existing.find(manifest => manifest.kind === 'turn'
200
+ && manifest.sessionId === options.sessionId
201
+ && manifest.turn === options.turn
202
+ && manifest.turnStartSeq === options.turnStartSeq);
203
+ if (duplicate !== undefined) {
204
+ if (duplicate.version === 2)
205
+ await verifyGitCheckpoint(duplicate, signal);
206
+ await this.store.deleteTurnCheckpointSkip(source.state.root, options.sessionId, options.turn, options.turnStartSeq).catch(() => undefined);
207
+ return summarize(duplicate);
208
+ }
209
+ throwIfAborted(signal);
210
+ let manifest;
211
+ if (this.config.turnCheckpointMode === 'legacy') {
212
+ manifest = await this.createLocked({
213
+ cwd: source.state.root,
214
+ kind: 'turn',
215
+ sessionId: options.sessionId,
216
+ label: `Before turn ${String(options.turn)} checkpoint`,
217
+ turn: options.turn,
218
+ turnStartSeq: options.turnStartSeq,
219
+ signal,
220
+ deferGarbageCollection: true,
221
+ });
222
+ }
223
+ else {
224
+ const id = makeId('rp');
225
+ const captured = (await captureGitTurnCheckpoint({
226
+ cwd: source.state.root,
227
+ id,
228
+ sessionId: options.sessionId,
229
+ turn: options.turn,
230
+ turnStartSeq: options.turnStartSeq,
231
+ config: this.config,
232
+ signal,
233
+ })).manifest;
234
+ await this.publishGitManifest(captured, signal);
235
+ manifest = captured;
236
+ }
237
+ await this.store.deleteTurnCheckpointSkip(source.state.root, options.sessionId, options.turn, options.turnStartSeq).catch(() => undefined);
238
+ if (!signal.aborted) {
239
+ const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
240
+ .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id));
241
+ for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
242
+ if (signal.aborted)
243
+ break;
244
+ if (await this.store.isReferencedByRecovery(source.state.root, stale.id))
245
+ continue;
246
+ await this.deleteManifestWithGit(stale, signal).catch(() => undefined);
247
+ }
248
+ }
249
+ return summarize(manifest);
250
+ }
251
+ finally {
252
+ await release();
253
+ }
254
+ }
255
+ catch (error) {
256
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal);
257
+ }
63
258
  }
64
- if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
65
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
259
+ finally {
260
+ deadline.cancel();
66
261
  }
67
- const source = await discoverRepository(options.cwd, options.signal);
68
- await this.assertStorageSeparated(source.state.root);
69
- const release = await this.store.acquire(source.state.root);
262
+ }
263
+ /** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
264
+ async recordTurnCheckpointSkip(options) {
265
+ await waitWithSignal(this.ready, options.signal);
266
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
267
+ await this.assertStorageSeparated(workspace);
268
+ const release = await this.acquireWorkspace(workspace, options.signal);
70
269
  try {
71
- const existing = await this.store.listManifests(source.state.root);
72
- const duplicate = existing.find(manifest => manifest.kind === 'turn'
270
+ throwIfAborted(options.signal);
271
+ const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest => manifest.kind === 'turn'
73
272
  && manifest.sessionId === options.sessionId
74
273
  && manifest.turn === options.turn
75
274
  && manifest.turnStartSeq === options.turnStartSeq);
76
- if (duplicate !== undefined)
77
- return summarize(duplicate);
78
- const manifest = await this.createLocked({
79
- cwd: source.state.root,
80
- kind: 'turn',
275
+ if (existing) {
276
+ await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
277
+ return;
278
+ }
279
+ await this.store.writeTurnCheckpointSkip(workspace, {
280
+ version: 1,
81
281
  sessionId: options.sessionId,
82
- label: `Before turn ${String(options.turn)} checkpoint`,
83
282
  turn: options.turn,
84
283
  turnStartSeq: options.turnStartSeq,
85
- ...(options.signal === undefined ? {} : { signal: options.signal }),
284
+ reason: options.reason.slice(0, 2_000),
285
+ createdAt: Date.now(),
86
286
  });
87
- const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
88
- .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id));
89
- for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
90
- if (await this.store.isReferencedByRecovery(source.state.root, stale.id))
91
- continue;
92
- await this.store.deleteManifest(source.state.root, stale.id);
93
- }
94
- await this.store.collectGarbage(source.state.root);
95
- return summarize(manifest);
287
+ }
288
+ finally {
289
+ await release();
290
+ }
291
+ }
292
+ /** Read a durable skip marker for one exact prompt boundary. */
293
+ async findTurnCheckpointSkip(options) {
294
+ await this.ready;
295
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
296
+ await this.assertStorageSeparated(workspace);
297
+ const release = await this.acquireWorkspace(workspace, options.signal);
298
+ try {
299
+ const skip = await this.store.readTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
300
+ return skip === undefined ? undefined : { reason: skip.reason };
96
301
  }
97
302
  finally {
98
303
  await release();
@@ -103,42 +308,72 @@ export class ChangeLedgerEngine {
103
308
  await this.ready;
104
309
  const source = await discoverRepository(options.cwd, options.signal);
105
310
  await this.assertStorageSeparated(source.state.root);
106
- const manifest = (await this.store.listManifests(source.state.root)).find(point => point.kind === 'turn'
107
- && point.sessionId === options.sessionId
108
- && point.turn === options.turn
109
- && point.turnStartSeq !== undefined);
110
- return manifest === undefined ? undefined : summarize(manifest);
311
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
312
+ try {
313
+ const manifest = (await this.store.listManifests(source.state.root)).find(point => point.kind === 'turn'
314
+ && point.sessionId === options.sessionId
315
+ && point.turn === options.turn
316
+ && point.turnStartSeq !== undefined);
317
+ if (manifest?.version === 2)
318
+ await verifyGitCheckpoint(manifest, options.signal);
319
+ return manifest === undefined ? undefined : summarize(manifest);
320
+ }
321
+ finally {
322
+ await release();
323
+ }
111
324
  }
112
325
  /** List restore points for the current worktree. */
113
326
  async list(options) {
114
327
  await this.ready;
115
328
  const source = await discoverRepository(options.cwd, options.signal);
116
329
  await this.assertStorageSeparated(source.state.root);
117
- const manifests = await this.store.listManifests(source.state.root);
118
- return manifests
119
- .filter((manifest) => manifest.kind === 'user'
120
- || (manifest.kind === 'rescue' && options.includeRescue === true)
121
- || (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
122
- .map(summarize);
330
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
331
+ try {
332
+ const manifests = await this.store.listManifests(source.state.root);
333
+ return manifests
334
+ .filter((manifest) => manifest.kind === 'user'
335
+ || (manifest.kind === 'rescue' && options.includeRescue === true)
336
+ || (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
337
+ .map(summarize);
338
+ }
339
+ finally {
340
+ await release();
341
+ }
123
342
  }
124
343
  /** Compare one restore point with the current worktree. */
125
344
  async inspect(options) {
126
345
  await this.ready;
127
346
  const source = await discoverRepository(options.cwd, options.signal);
128
347
  await this.assertStorageSeparated(source.state.root);
129
- const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
130
- const current = await captureStableTree({ cwd: source.state.root, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) });
131
- return {
132
- restorePoint: summarize(manifest),
133
- currentTreeHash: current.treeHash,
134
- currentRepository: current.source.state,
135
- ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
136
- ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
137
- ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
138
- headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
139
- operationChanged: manifest.repository.operation !== current.source.state.operation,
140
- changes: diffTrees(manifest.entries, current.entries),
141
- };
348
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
349
+ try {
350
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
351
+ if (manifest.version === 2)
352
+ await verifyGitCheckpoint(manifest, options.signal);
353
+ const current = await captureStableTree({
354
+ cwd: source.state.root,
355
+ config: this.config,
356
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
357
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
358
+ });
359
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries;
360
+ if (currentEntries === undefined)
361
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable');
362
+ return {
363
+ restorePoint: summarize(manifest),
364
+ currentTreeHash: current.treeHash,
365
+ currentRepository: current.source.state,
366
+ ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
367
+ ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
368
+ ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
369
+ headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
370
+ operationChanged: manifest.repository.operation !== current.source.state.operation,
371
+ changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
372
+ };
373
+ }
374
+ finally {
375
+ await release();
376
+ }
142
377
  }
143
378
  /** Produce an expiring, exact confirmation plan for a restore. */
144
379
  async planRestore(options) {
@@ -146,41 +381,57 @@ export class ChangeLedgerEngine {
146
381
  this.expirePlans();
147
382
  const source = await discoverRepository(options.cwd, options.signal);
148
383
  await this.assertStorageSeparated(source.state.root);
149
- const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
150
- const current = await captureStableTree({ cwd: source.state.root, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) });
151
- if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
152
- throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again');
153
- }
154
- if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
155
- throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again');
156
- }
157
- assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true);
158
- const changes = diffTrees(manifest.entries, current.entries);
159
- if (changes.length === 0) {
160
- throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`);
161
- }
162
- const selected = selectChanges(changes, options.paths);
163
- await assertNoUnmanagedRestoreConflicts(source.state.root, manifest.entries, current.entries, selected.map((change) => change.path));
164
- const expected = Object.create(null);
165
- for (const change of selected)
166
- expected[change.path] = current.entries[change.path] ?? null;
167
- const now = Date.now();
168
- const plan = {
169
- id: makeId('plan'),
170
- restorePointId: manifest.id,
171
- workspace: manifest.workspace,
172
- repository: current.source.state,
173
- ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
174
- createdAt: now,
175
- expiresAt: now + this.config.planTtlMs,
176
- confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
177
- allowHeadChange: options.allowHeadChange === true,
178
- paths: selected.map((change) => change.path),
179
- changes: selected,
180
- expected,
181
- };
182
- this.plans.set(plan.id, plan);
183
- return clonePlan(plan);
384
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
385
+ try {
386
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
387
+ if (manifest.version === 2)
388
+ await verifyGitCheckpoint(manifest, options.signal);
389
+ const current = await captureStableTree({
390
+ cwd: source.state.root,
391
+ config: this.config,
392
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
393
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
394
+ });
395
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries;
396
+ if (currentEntries === undefined)
397
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable');
398
+ if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
399
+ throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again');
400
+ }
401
+ if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
402
+ throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again');
403
+ }
404
+ assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true);
405
+ const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries);
406
+ if (changes.length === 0) {
407
+ throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`);
408
+ }
409
+ const selected = selectChanges(changes, options.paths);
410
+ await assertNoUnmanagedRestoreConflicts(source.state.root, manifest.entries, current.entries, selected.map((change) => change.path));
411
+ const expected = Object.create(null);
412
+ for (const change of selected)
413
+ expected[change.path] = current.entries[change.path] ?? null;
414
+ const now = Date.now();
415
+ const plan = {
416
+ id: makeId('plan'),
417
+ restorePointId: manifest.id,
418
+ workspace: manifest.workspace,
419
+ repository: current.source.state,
420
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
421
+ createdAt: now,
422
+ expiresAt: now + this.config.planTtlMs,
423
+ confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
424
+ allowHeadChange: options.allowHeadChange === true,
425
+ paths: selected.map((change) => change.path),
426
+ changes: selected,
427
+ expected,
428
+ };
429
+ this.plans.set(plan.id, plan);
430
+ return clonePlan(plan);
431
+ }
432
+ finally {
433
+ await release();
434
+ }
184
435
  }
185
436
  /** Apply one approved restore plan, creating a durable rescue point first. */
186
437
  async applyRestore(options) {
@@ -201,9 +452,11 @@ export class ChangeLedgerEngine {
201
452
  this.activePlans.add(plan.id);
202
453
  try {
203
454
  await this.assertStorageSeparated(plan.workspace);
204
- const release = await this.store.acquire(plan.workspace);
455
+ const release = await this.acquireWorkspace(plan.workspace, options.signal);
205
456
  try {
206
457
  const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId);
458
+ if (manifest.version === 2)
459
+ await verifyGitCheckpoint(manifest, options.signal);
207
460
  const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) });
208
461
  assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange);
209
462
  assertPlanRepositoryFresh(plan.repository, current.source.state);
@@ -239,8 +492,8 @@ export class ChangeLedgerEngine {
239
492
  };
240
493
  await this.store.writeOperation(operation);
241
494
  try {
242
- await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal);
243
- await this.verifyPaths(plan.workspace, manifest.entries, plan.paths, options.signal);
495
+ await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal, plan.expected);
496
+ await this.verifyPaths(plan.workspace, manifest.entries, plan.paths, options.signal, manifest.version === 2 ? manifest.git.objectFormat : undefined);
244
497
  }
245
498
  catch (error) {
246
499
  const primaryError = errorMessage(error);
@@ -324,13 +577,13 @@ export class ChangeLedgerEngine {
324
577
  }
325
578
  const source = await discoverRepository(options.cwd, options.signal);
326
579
  await this.assertStorageSeparated(source.state.root);
327
- const release = await this.store.acquire(source.state.root);
580
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
328
581
  try {
329
- await this.store.readManifest(source.state.root, options.restorePointId);
582
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
330
583
  if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
331
584
  throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal');
332
585
  }
333
- await this.store.deleteManifest(source.state.root, options.restorePointId);
586
+ await this.deleteManifestWithGit(manifest, options.signal);
334
587
  const gc = await this.store.collectGarbage(source.state.root);
335
588
  return { restorePointId: options.restorePointId, ...gc };
336
589
  }
@@ -343,18 +596,24 @@ export class ChangeLedgerEngine {
343
596
  await this.ready;
344
597
  const source = await discoverRepository(options.cwd, options.signal);
345
598
  await this.assertStorageSeparated(source.state.root);
346
- return (await this.store.listOperations(source.state.root))
347
- .filter((operation) => operation.state === 'interrupted' || operation.state === 'recovery-required')
348
- .map((operation) => ({
349
- operationId: operation.id,
350
- restorePointId: operation.restorePointId,
351
- rescuePointId: operation.rescuePointId,
352
- state: operation.state,
353
- paths: operation.paths,
354
- startedAt: operation.startedAt,
355
- ...(operation.error === undefined ? {} : { error: operation.error }),
356
- ...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
357
- }));
599
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
600
+ try {
601
+ return (await this.store.listOperations(source.state.root))
602
+ .filter((operation) => operation.state === 'interrupted' || operation.state === 'recovery-required')
603
+ .map((operation) => ({
604
+ operationId: operation.id,
605
+ restorePointId: operation.restorePointId,
606
+ rescuePointId: operation.rescuePointId,
607
+ state: operation.state,
608
+ paths: operation.paths,
609
+ startedAt: operation.startedAt,
610
+ ...(operation.error === undefined ? {} : { error: operation.error }),
611
+ ...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
612
+ }));
613
+ }
614
+ finally {
615
+ await release();
616
+ }
358
617
  }
359
618
  async createLocked(options) {
360
619
  const existing = await this.store.listManifests(options.cwd);
@@ -362,6 +621,11 @@ export class ChangeLedgerEngine {
362
621
  if (options.kind !== 'turn' && durableUserPoints.length >= this.config.maxRestorePoints) {
363
622
  throw new ChangeLedgerError('RESTORE_POINT_LIMIT', `workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`);
364
623
  }
624
+ if (options.deferGarbageCollection === true) {
625
+ await this.store.reconcileSnapshotCleanup(options.cwd, options.signal);
626
+ throwIfAborted(options.signal);
627
+ await this.store.writeSnapshotCleanup(options.cwd);
628
+ }
365
629
  try {
366
630
  const tree = await captureStableTree({
367
631
  cwd: options.cwd,
@@ -388,13 +652,20 @@ export class ChangeLedgerEngine {
388
652
  entries: tree.entries,
389
653
  restoreCount: 0,
390
654
  };
391
- await this.store.collectGarbage(options.cwd, Object.values(manifest.entries)
392
- .filter((entry) => entry.kind === 'file')
393
- .map((entry) => entry.blob));
655
+ if (options.deferGarbageCollection !== true) {
656
+ await this.store.collectGarbage(options.cwd, Object.values(manifest.entries)
657
+ .filter((entry) => entry.kind === 'file' && entry.provider !== 'git')
658
+ .map((entry) => entry.blob));
659
+ }
394
660
  await this.store.writeManifest(manifest);
661
+ if (options.deferGarbageCollection === true) {
662
+ await this.store.deleteSnapshotCleanup(options.cwd).catch(() => undefined);
663
+ }
395
664
  return manifest;
396
665
  }
397
666
  catch (error) {
667
+ if (options.deferGarbageCollection === true)
668
+ throw error;
398
669
  try {
399
670
  await this.store.collectGarbage(options.cwd);
400
671
  }
@@ -404,11 +675,14 @@ export class ChangeLedgerEngine {
404
675
  throw error;
405
676
  }
406
677
  }
407
- async restorePaths(workspace, desiredEntries, paths, signal) {
678
+ async restorePaths(workspace, desiredEntries, paths, signal, expectedEntries) {
408
679
  const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst);
409
680
  for (const path of deletions) {
410
681
  throwIfAborted(signal);
411
682
  const target = resolveWorkspacePath(workspace, path);
683
+ if (expectedEntries !== undefined)
684
+ await this.assertPathFresh(workspace, path, expectedEntries[path], signal);
685
+ await assertSafeParents(workspace, target);
412
686
  await removeRestoreTarget(target);
413
687
  await pruneEmptyParents(workspace, target);
414
688
  }
@@ -419,6 +693,8 @@ export class ChangeLedgerEngine {
419
693
  if (entry === undefined)
420
694
  continue;
421
695
  const target = resolveWorkspacePath(workspace, path);
696
+ if (expectedEntries !== undefined)
697
+ await this.assertPathFresh(workspace, path, expectedEntries[path], signal);
422
698
  await ensureSafeParents(workspace, target);
423
699
  try {
424
700
  const info = await lstat(target);
@@ -430,10 +706,16 @@ export class ChangeLedgerEngine {
430
706
  throw error;
431
707
  }
432
708
  if (entry.kind === 'file') {
433
- const content = await this.store.readBlob(workspace, entry.blob);
709
+ const content = entry.provider === 'git'
710
+ ? await readGitBlob(workspace, entry.blob, entry.size, signal)
711
+ : await this.store.readBlob(workspace, entry.blob);
434
712
  if (content.length !== entry.size) {
435
713
  throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`);
436
714
  }
715
+ if (expectedEntries !== undefined)
716
+ await this.assertPathFresh(workspace, path, expectedEntries[path], signal);
717
+ else
718
+ await assertSafeParents(workspace, target);
437
719
  await replaceRegularFile(target, content, entry.mode);
438
720
  }
439
721
  else {
@@ -441,13 +723,44 @@ export class ChangeLedgerEngine {
441
723
  }
442
724
  }
443
725
  }
444
- async verifyPaths(workspace, desiredEntries, paths, signal) {
445
- const current = await captureStableTree({ cwd: workspace, config: this.config, ...(signal === undefined ? {} : { signal }) });
446
- for (const path of paths) {
447
- if (!entriesEqual(desiredEntries[path], current.entries[path])) {
448
- throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`);
726
+ async assertPathFresh(workspace, path, expected, signal) {
727
+ const target = resolveWorkspacePath(workspace, path);
728
+ await assertSafeParents(workspace, target);
729
+ let current;
730
+ try {
731
+ current = await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal);
732
+ }
733
+ catch (error) {
734
+ if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
735
+ throw new ChangeLedgerError('PLAN_STALE', `workspace path changed type after planning: ${JSON.stringify(path)}`, { cause: error });
736
+ }
737
+ throw error;
738
+ }
739
+ if (!entriesEqual(expected === null ? undefined : expected, current)) {
740
+ throw new ChangeLedgerError('PLAN_STALE', `workspace changed immediately before restore at ${JSON.stringify(path)}; inspect and plan again`);
741
+ }
742
+ }
743
+ async verifyPaths(workspace, desiredEntries, paths, signal, gitObjectFormat) {
744
+ for (let attempt = 0; attempt < 3; attempt += 1) {
745
+ const first = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat);
746
+ const second = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat);
747
+ if (!paths.every(path => entriesEqual(first.get(path), second.get(path))))
748
+ continue;
749
+ for (const path of paths) {
750
+ if (!entriesEqual(desiredEntries[path], second.get(path))) {
751
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`);
752
+ }
449
753
  }
754
+ return;
755
+ }
756
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', 'restored paths did not remain stable during post-verification');
757
+ }
758
+ async captureSelectedEntries(workspace, paths, signal, gitObjectFormat) {
759
+ const entries = new Map();
760
+ for (const path of paths) {
761
+ entries.set(path, await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal, gitObjectFormat));
450
762
  }
763
+ return entries;
451
764
  }
452
765
  async assertStorageSeparated(workspace) {
453
766
  const storage = await realpath(resolve(this.config.storageDir));
@@ -481,10 +794,15 @@ export function resolveConfig(config) {
481
794
  maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
482
795
  planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
483
796
  staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
797
+ turnCheckpointMode: checkpointMode(config.turnCheckpointMode ?? DEFAULTS.turnCheckpointMode),
798
+ turnCheckpointTimeoutMs: positiveInteger(config.turnCheckpointTimeoutMs ?? DEFAULTS.turnCheckpointTimeoutMs, 'turnCheckpointTimeoutMs'),
799
+ turnCheckpointMaxNewBytes: positiveInteger(config.turnCheckpointMaxNewBytes ?? DEFAULTS.turnCheckpointMaxNewBytes, 'turnCheckpointMaxNewBytes'),
800
+ turnCheckpointTrust: checkpointTrust(config.turnCheckpointTrust ?? DEFAULTS.turnCheckpointTrust),
484
801
  };
485
802
  }
486
803
  function summarize(manifest) {
487
804
  return {
805
+ format: manifest.version,
488
806
  id: manifest.id,
489
807
  kind: manifest.kind,
490
808
  workspace: manifest.workspace,
@@ -504,6 +822,7 @@ function summarize(manifest) {
504
822
  ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
505
823
  ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
506
824
  stagedPathCount: manifest.repository.stagedPaths.length,
825
+ ...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
507
826
  };
508
827
  }
509
828
  function assertRepositoryCompatible(manifest, current, allowHeadChange) {
@@ -529,6 +848,18 @@ function assertPlanRepositoryFresh(planned, current) {
529
848
  throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after restore planning; inspect and plan again');
530
849
  }
531
850
  }
851
+ function assertJournalMatchesManifest(journal, manifest) {
852
+ if (manifest.version !== 2
853
+ || manifest.workspace !== journal.workspace
854
+ || manifest.id !== journal.restorePointId
855
+ || manifest.repository.commonDir !== journal.commonDir
856
+ || manifest.git.storeId !== journal.storeId
857
+ || manifest.git.worktreeId !== journal.worktreeId
858
+ || manifest.git.ref !== journal.ref
859
+ || manifest.git.commit !== journal.commit) {
860
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal does not match restore point ${journal.restorePointId}`);
861
+ }
862
+ }
532
863
  function selectChanges(changes, requested) {
533
864
  if (requested === undefined || requested.length === 0)
534
865
  return [...changes];
@@ -601,10 +932,43 @@ function requireNonEmptyString(value, name) {
601
932
  }
602
933
  return value;
603
934
  }
935
+ function checkpointMode(value) {
936
+ if (value !== 'off' && value !== 'git-native' && value !== 'legacy') {
937
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointMode must be off, git-native, or legacy');
938
+ }
939
+ return value;
940
+ }
941
+ function checkpointTrust(value) {
942
+ if (value !== 'fast' && value !== 'strict') {
943
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointTrust must be fast or strict');
944
+ }
945
+ return value;
946
+ }
604
947
  function throwIfAborted(signal) {
605
948
  if (signal?.aborted === true)
606
949
  throw signal.reason;
607
950
  }
951
+ async function waitWithSignal(promise, signal) {
952
+ if (signal === undefined)
953
+ return promise;
954
+ signal.throwIfAborted();
955
+ let rejectAbort;
956
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
957
+ const onAbort = () => rejectAbort?.(signal.reason);
958
+ signal.addEventListener('abort', onAbort, { once: true });
959
+ try {
960
+ return await Promise.race([promise, aborted]);
961
+ }
962
+ finally {
963
+ signal.removeEventListener('abort', onAbort);
964
+ }
965
+ }
966
+ function checkpointDeadlineError(error, timeoutMs, deadline, callerSignal) {
967
+ if (callerSignal?.aborted !== true && deadline.aborted) {
968
+ return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error });
969
+ }
970
+ return error;
971
+ }
608
972
  function depth(path) {
609
973
  return path.split('/').length;
610
974
  }