@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/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,30 +18,158 @@ 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 {
22
- config;
28
+ currentConfig;
23
29
  store;
24
30
  plans = new Map();
25
31
  activePlans = new Set();
26
32
  ready;
27
33
  /** Build an engine and start crash-journal reconciliation. */
28
34
  constructor(config = {}) {
29
- this.config = resolveConfig(config);
30
- this.store = new LedgerStore(this.config);
31
- this.ready = this.store.initialize();
35
+ this.currentConfig = resolveConfig(config);
36
+ this.store = new LedgerStore(this.currentConfig);
37
+ this.ready = this.initializeStore();
38
+ }
39
+ /** Currently resolved configuration; runtime-tunable fields update in place. */
40
+ get config() {
41
+ return this.currentConfig;
42
+ }
43
+ /**
44
+ * Swap runtime-tunable configuration (limits, modes, timeouts, trust) in place.
45
+ * The storage root must never move once the engine owns locks and journals.
46
+ */
47
+ updateConfig(config) {
48
+ const resolved = resolveConfig(config);
49
+ if (resolved.storageDir !== this.currentConfig.storageDir) {
50
+ throw new ChangeLedgerError('INVALID_CONFIG', 'storageDir cannot change while the engine is running');
51
+ }
52
+ Object.assign(this.currentConfig, resolved);
32
53
  }
33
54
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
34
55
  async initialize() {
35
56
  return this.ready;
36
57
  }
58
+ async initializeStore() {
59
+ const restoredOperations = await this.store.initialize();
60
+ return restoredOperations + await this.reconcileGitCheckpointJournals();
61
+ }
62
+ async acquireWorkspace(workspace, signal) {
63
+ const identity = await ensureGitWorktreeIdentity(workspace, signal);
64
+ if (identity.root !== workspace) {
65
+ throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`);
66
+ }
67
+ return this.store.acquire(workspace, identity.lockPath, {
68
+ commonDir: identity.commonDir,
69
+ gitDir: identity.gitDir,
70
+ worktreeId: identity.worktreeId,
71
+ }, signal);
72
+ }
73
+ async reconcileGitCheckpointJournals() {
74
+ let reconciled = 0;
75
+ for (const journal of await this.store.listGitCheckpointJournals()) {
76
+ const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace);
77
+ const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace };
78
+ const release = await this.acquireWorkspace(workspace);
79
+ try {
80
+ const manifest = await this.tryReadManifest(workspace, journal.restorePointId);
81
+ if (journal.action === 'publish') {
82
+ if (manifest === undefined) {
83
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir);
84
+ }
85
+ else {
86
+ assertJournalMatchesManifest(activeJournal, manifest);
87
+ await verifyGitCheckpoint(manifest);
88
+ }
89
+ }
90
+ else {
91
+ if (manifest !== undefined) {
92
+ assertJournalMatchesManifest(activeJournal, manifest);
93
+ await this.store.deleteManifest(workspace, journal.restorePointId);
94
+ }
95
+ await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir);
96
+ }
97
+ await this.store.deleteGitCheckpointJournal(workspace, journal.restorePointId);
98
+ reconciled += 1;
99
+ }
100
+ finally {
101
+ await release();
102
+ }
103
+ }
104
+ return reconciled;
105
+ }
106
+ async publishGitManifest(manifest, signal) {
107
+ let journalWritten = false;
108
+ try {
109
+ await this.store.writeGitCheckpointJournal('publish', manifest);
110
+ journalWritten = true;
111
+ await publishGitCheckpoint(manifest, this.config.storageDir, signal);
112
+ await verifyGitCheckpoint(manifest, signal);
113
+ await this.store.writeManifest(manifest);
114
+ }
115
+ catch (error) {
116
+ if (!journalWritten)
117
+ throw error;
118
+ const persisted = await this.tryReadManifest(manifest.workspace, manifest.id);
119
+ if (persisted !== undefined) {
120
+ assertJournalMatchesManifest({
121
+ version: 1,
122
+ action: 'publish',
123
+ storeId: manifest.git.storeId,
124
+ workspace: manifest.workspace,
125
+ commonDir: manifest.repository.commonDir,
126
+ worktreeId: manifest.git.worktreeId,
127
+ restorePointId: manifest.id,
128
+ ref: manifest.git.ref,
129
+ commit: manifest.git.commit,
130
+ createdAt: manifest.createdAt,
131
+ }, persisted);
132
+ await verifyGitCheckpoint(persisted, signal);
133
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined);
134
+ return;
135
+ }
136
+ try {
137
+ await deleteGitCheckpoint(manifest, this.config.storageDir);
138
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id);
139
+ }
140
+ catch (cleanupError) {
141
+ throw new ChangeLedgerError('SNAPSHOT_CLEANUP_FAILED', `Git checkpoint publication failed (${errorMessage(error)}) and journaled ref cleanup also failed (${errorMessage(cleanupError)})`, { cause: error });
142
+ }
143
+ throw error;
144
+ }
145
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined);
146
+ }
147
+ async deleteManifestWithGit(manifest, signal) {
148
+ if (manifest.version === LEDGER_FORMAT_VERSION) {
149
+ await this.store.deleteManifest(manifest.workspace, manifest.id);
150
+ return;
151
+ }
152
+ await this.store.writeGitCheckpointJournal('delete', manifest);
153
+ await this.store.deleteManifest(manifest.workspace, manifest.id);
154
+ await deleteGitCheckpoint(manifest, this.config.storageDir, signal);
155
+ await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id);
156
+ }
157
+ async tryReadManifest(workspace, id) {
158
+ try {
159
+ return await this.store.readManifest(workspace, id);
160
+ }
161
+ catch (error) {
162
+ if (error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND')
163
+ return undefined;
164
+ throw error;
165
+ }
166
+ }
37
167
  /** Create a durable restore point for the current Git worktree. */
38
168
  async create(options) {
39
169
  await this.ready;
40
170
  const source = await discoverRepository(options.cwd, options.signal);
41
171
  await this.assertStorageSeparated(source.state.root);
42
- const release = await this.store.acquire(source.state.root);
172
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
43
173
  try {
44
174
  const label = normalizeLabel(options.label);
45
175
  const manifest = await this.createLocked({
@@ -57,42 +187,132 @@ export class ChangeLedgerEngine {
57
187
  }
58
188
  /** Capture project files before one DSH turn begins its first step. */
59
189
  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');
190
+ const deadline = createDeadline(this.config.turnCheckpointTimeoutMs);
191
+ const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal]);
192
+ try {
193
+ try {
194
+ await waitWithSignal(this.ready, signal);
195
+ }
196
+ catch (error) {
197
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal);
198
+ }
199
+ if (this.config.turnCheckpointMode === 'off') {
200
+ throw new ChangeLedgerError('TURN_CHECKPOINT_DISABLED', 'automatic turn checkpoints are disabled');
201
+ }
202
+ if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
203
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer');
204
+ }
205
+ if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
206
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
207
+ }
208
+ try {
209
+ const source = await discoverRepository(options.cwd, signal);
210
+ await this.assertStorageSeparated(source.state.root);
211
+ const release = await this.acquireWorkspace(source.state.root, signal);
212
+ try {
213
+ const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal);
214
+ const duplicate = existing.find(manifest => manifest.kind === 'turn'
215
+ && manifest.sessionId === options.sessionId
216
+ && manifest.turn === options.turn
217
+ && manifest.turnStartSeq === options.turnStartSeq);
218
+ if (duplicate !== undefined) {
219
+ if (duplicate.version === 2)
220
+ await verifyGitCheckpoint(duplicate, signal);
221
+ await this.store.deleteTurnCheckpointSkip(source.state.root, options.sessionId, options.turn, options.turnStartSeq).catch(() => undefined);
222
+ return summarize(duplicate);
223
+ }
224
+ throwIfAborted(signal);
225
+ let manifest;
226
+ if (this.config.turnCheckpointMode === 'legacy') {
227
+ manifest = await this.createLocked({
228
+ cwd: source.state.root,
229
+ kind: 'turn',
230
+ sessionId: options.sessionId,
231
+ label: `Before turn ${String(options.turn)} checkpoint`,
232
+ turn: options.turn,
233
+ turnStartSeq: options.turnStartSeq,
234
+ signal,
235
+ deferGarbageCollection: true,
236
+ });
237
+ }
238
+ else {
239
+ const id = makeId('rp');
240
+ const captured = (await captureGitTurnCheckpoint({
241
+ cwd: source.state.root,
242
+ id,
243
+ sessionId: options.sessionId,
244
+ turn: options.turn,
245
+ turnStartSeq: options.turnStartSeq,
246
+ config: this.config,
247
+ signal,
248
+ })).manifest;
249
+ await this.publishGitManifest(captured, signal);
250
+ manifest = captured;
251
+ }
252
+ await this.store.deleteTurnCheckpointSkip(source.state.root, options.sessionId, options.turn, options.turnStartSeq).catch(() => undefined);
253
+ if (!signal.aborted) {
254
+ const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
255
+ .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id));
256
+ for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
257
+ if (signal.aborted)
258
+ break;
259
+ if (await this.store.isReferencedByRecovery(source.state.root, stale.id))
260
+ continue;
261
+ await this.deleteManifestWithGit(stale, signal).catch(() => undefined);
262
+ }
263
+ }
264
+ return summarize(manifest);
265
+ }
266
+ finally {
267
+ await release();
268
+ }
269
+ }
270
+ catch (error) {
271
+ throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal);
272
+ }
63
273
  }
64
- if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
65
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
274
+ finally {
275
+ deadline.cancel();
66
276
  }
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);
277
+ }
278
+ /** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
279
+ async recordTurnCheckpointSkip(options) {
280
+ await waitWithSignal(this.ready, options.signal);
281
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
282
+ await this.assertStorageSeparated(workspace);
283
+ const release = await this.acquireWorkspace(workspace, options.signal);
70
284
  try {
71
- const existing = await this.store.listManifests(source.state.root);
72
- const duplicate = existing.find(manifest => manifest.kind === 'turn'
285
+ throwIfAborted(options.signal);
286
+ const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest => manifest.kind === 'turn'
73
287
  && manifest.sessionId === options.sessionId
74
288
  && manifest.turn === options.turn
75
289
  && 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',
290
+ if (existing) {
291
+ await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
292
+ return;
293
+ }
294
+ await this.store.writeTurnCheckpointSkip(workspace, {
295
+ version: 1,
81
296
  sessionId: options.sessionId,
82
- label: `Before turn ${String(options.turn)} checkpoint`,
83
297
  turn: options.turn,
84
298
  turnStartSeq: options.turnStartSeq,
85
- ...(options.signal === undefined ? {} : { signal: options.signal }),
299
+ reason: options.reason.slice(0, 2_000),
300
+ createdAt: Date.now(),
86
301
  });
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);
302
+ }
303
+ finally {
304
+ await release();
305
+ }
306
+ }
307
+ /** Read a durable skip marker for one exact prompt boundary. */
308
+ async findTurnCheckpointSkip(options) {
309
+ await this.ready;
310
+ const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
311
+ await this.assertStorageSeparated(workspace);
312
+ const release = await this.acquireWorkspace(workspace, options.signal);
313
+ try {
314
+ const skip = await this.store.readTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
315
+ return skip === undefined ? undefined : { reason: skip.reason };
96
316
  }
97
317
  finally {
98
318
  await release();
@@ -103,42 +323,72 @@ export class ChangeLedgerEngine {
103
323
  await this.ready;
104
324
  const source = await discoverRepository(options.cwd, options.signal);
105
325
  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);
326
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
327
+ try {
328
+ const manifest = (await this.store.listManifests(source.state.root)).find(point => point.kind === 'turn'
329
+ && point.sessionId === options.sessionId
330
+ && point.turn === options.turn
331
+ && point.turnStartSeq !== undefined);
332
+ if (manifest?.version === 2)
333
+ await verifyGitCheckpoint(manifest, options.signal);
334
+ return manifest === undefined ? undefined : summarize(manifest);
335
+ }
336
+ finally {
337
+ await release();
338
+ }
111
339
  }
112
340
  /** List restore points for the current worktree. */
113
341
  async list(options) {
114
342
  await this.ready;
115
343
  const source = await discoverRepository(options.cwd, options.signal);
116
344
  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);
345
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
346
+ try {
347
+ const manifests = await this.store.listManifests(source.state.root);
348
+ return manifests
349
+ .filter((manifest) => manifest.kind === 'user'
350
+ || (manifest.kind === 'rescue' && options.includeRescue === true)
351
+ || (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
352
+ .map(summarize);
353
+ }
354
+ finally {
355
+ await release();
356
+ }
123
357
  }
124
358
  /** Compare one restore point with the current worktree. */
125
359
  async inspect(options) {
126
360
  await this.ready;
127
361
  const source = await discoverRepository(options.cwd, options.signal);
128
362
  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
- };
363
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
364
+ try {
365
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
366
+ if (manifest.version === 2)
367
+ await verifyGitCheckpoint(manifest, options.signal);
368
+ const current = await captureStableTree({
369
+ cwd: source.state.root,
370
+ config: this.config,
371
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
372
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
373
+ });
374
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries;
375
+ if (currentEntries === undefined)
376
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable');
377
+ return {
378
+ restorePoint: summarize(manifest),
379
+ currentTreeHash: current.treeHash,
380
+ currentRepository: current.source.state,
381
+ ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
382
+ ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
383
+ ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
384
+ headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
385
+ operationChanged: manifest.repository.operation !== current.source.state.operation,
386
+ changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
387
+ };
388
+ }
389
+ finally {
390
+ await release();
391
+ }
142
392
  }
143
393
  /** Produce an expiring, exact confirmation plan for a restore. */
144
394
  async planRestore(options) {
@@ -146,41 +396,57 @@ export class ChangeLedgerEngine {
146
396
  this.expirePlans();
147
397
  const source = await discoverRepository(options.cwd, options.signal);
148
398
  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);
399
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
400
+ try {
401
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
402
+ if (manifest.version === 2)
403
+ await verifyGitCheckpoint(manifest, options.signal);
404
+ const current = await captureStableTree({
405
+ cwd: source.state.root,
406
+ config: this.config,
407
+ ...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
408
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
409
+ });
410
+ const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries;
411
+ if (currentEntries === undefined)
412
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable');
413
+ if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
414
+ throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again');
415
+ }
416
+ if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
417
+ throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again');
418
+ }
419
+ assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true);
420
+ const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries);
421
+ if (changes.length === 0) {
422
+ throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`);
423
+ }
424
+ const selected = selectChanges(changes, options.paths);
425
+ await assertNoUnmanagedRestoreConflicts(source.state.root, manifest.entries, current.entries, selected.map((change) => change.path));
426
+ const expected = Object.create(null);
427
+ for (const change of selected)
428
+ expected[change.path] = current.entries[change.path] ?? null;
429
+ const now = Date.now();
430
+ const plan = {
431
+ id: makeId('plan'),
432
+ restorePointId: manifest.id,
433
+ workspace: manifest.workspace,
434
+ repository: current.source.state,
435
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
436
+ createdAt: now,
437
+ expiresAt: now + this.config.planTtlMs,
438
+ confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
439
+ allowHeadChange: options.allowHeadChange === true,
440
+ paths: selected.map((change) => change.path),
441
+ changes: selected,
442
+ expected,
443
+ };
444
+ this.plans.set(plan.id, plan);
445
+ return clonePlan(plan);
446
+ }
447
+ finally {
448
+ await release();
449
+ }
184
450
  }
185
451
  /** Apply one approved restore plan, creating a durable rescue point first. */
186
452
  async applyRestore(options) {
@@ -201,9 +467,11 @@ export class ChangeLedgerEngine {
201
467
  this.activePlans.add(plan.id);
202
468
  try {
203
469
  await this.assertStorageSeparated(plan.workspace);
204
- const release = await this.store.acquire(plan.workspace);
470
+ const release = await this.acquireWorkspace(plan.workspace, options.signal);
205
471
  try {
206
472
  const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId);
473
+ if (manifest.version === 2)
474
+ await verifyGitCheckpoint(manifest, options.signal);
207
475
  const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) });
208
476
  assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange);
209
477
  assertPlanRepositoryFresh(plan.repository, current.source.state);
@@ -239,8 +507,8 @@ export class ChangeLedgerEngine {
239
507
  };
240
508
  await this.store.writeOperation(operation);
241
509
  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);
510
+ await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal, plan.expected);
511
+ await this.verifyPaths(plan.workspace, manifest.entries, plan.paths, options.signal, manifest.version === 2 ? manifest.git.objectFormat : undefined);
244
512
  }
245
513
  catch (error) {
246
514
  const primaryError = errorMessage(error);
@@ -324,13 +592,13 @@ export class ChangeLedgerEngine {
324
592
  }
325
593
  const source = await discoverRepository(options.cwd, options.signal);
326
594
  await this.assertStorageSeparated(source.state.root);
327
- const release = await this.store.acquire(source.state.root);
595
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
328
596
  try {
329
- await this.store.readManifest(source.state.root, options.restorePointId);
597
+ const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
330
598
  if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
331
599
  throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal');
332
600
  }
333
- await this.store.deleteManifest(source.state.root, options.restorePointId);
601
+ await this.deleteManifestWithGit(manifest, options.signal);
334
602
  const gc = await this.store.collectGarbage(source.state.root);
335
603
  return { restorePointId: options.restorePointId, ...gc };
336
604
  }
@@ -343,18 +611,146 @@ export class ChangeLedgerEngine {
343
611
  await this.ready;
344
612
  const source = await discoverRepository(options.cwd, options.signal);
345
613
  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
- }));
614
+ const release = await this.acquireWorkspace(source.state.root, options.signal);
615
+ try {
616
+ return (await this.store.listOperations(source.state.root))
617
+ .filter((operation) => operation.state === 'interrupted' || operation.state === 'recovery-required')
618
+ .map((operation) => ({
619
+ operationId: operation.id,
620
+ restorePointId: operation.restorePointId,
621
+ rescuePointId: operation.rescuePointId,
622
+ state: operation.state,
623
+ paths: operation.paths,
624
+ startedAt: operation.startedAt,
625
+ ...(operation.error === undefined ? {} : { error: operation.error }),
626
+ ...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
627
+ }));
628
+ }
629
+ finally {
630
+ await release();
631
+ }
632
+ }
633
+ /** Inventory every workspace this storage root has ever persisted state for. */
634
+ async listWorkspaces(options = {}) {
635
+ await this.ready;
636
+ const groups = new Map();
637
+ const groupOf = (workspace) => {
638
+ const existing = groups.get(workspace);
639
+ if (existing !== undefined)
640
+ return existing;
641
+ const created = { restorePoints: [], recoveryCount: 0 };
642
+ groups.set(workspace, created);
643
+ return created;
644
+ };
645
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
646
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
647
+ groupOf(manifest.workspace).restorePoints.push(summarize(manifest));
648
+ }
649
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
650
+ if (operation.state === 'interrupted' || operation.state === 'recovery-required') {
651
+ groupOf(operation.workspace).recoveryCount += 1;
652
+ }
653
+ }
654
+ }
655
+ return [...groups.entries()]
656
+ .map(([workspace, group]) => ({
657
+ workspace,
658
+ restorePoints: [...group.restorePoints].sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id)),
659
+ totalBytes: group.restorePoints.reduce((total, point) => total + point.totalBytes, 0),
660
+ recoveryCount: group.recoveryCount,
661
+ }))
662
+ .sort((left, right) => left.workspace.localeCompare(right.workspace));
663
+ }
664
+ /** Delete unprotected restore points recorded for one workspace and collect unused blobs. */
665
+ async purgeWorkspace(options) {
666
+ await this.ready;
667
+ const restorePointIds = options.restorePointIds === undefined ? undefined : new Set(options.restorePointIds);
668
+ const targetDirs = new Set();
669
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
670
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
671
+ if (manifest.workspace === options.workspace
672
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id))) {
673
+ targetDirs.add(workspaceDir);
674
+ }
675
+ }
676
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
677
+ if (operation.workspace === options.workspace)
678
+ targetDirs.add(workspaceDir);
679
+ }
680
+ }
681
+ let deletedRestorePoints = 0;
682
+ let retainedRestorePoints = 0;
683
+ let deletedBlobs = 0;
684
+ let retainedBlobs = 0;
685
+ for (const workspaceDir of targetDirs) {
686
+ const result = await this.purgeWorkspaceDir(workspaceDir, options.workspace, restorePointIds, options.signal);
687
+ deletedRestorePoints += result.deletedRestorePoints;
688
+ retainedRestorePoints += result.retainedRestorePoints;
689
+ deletedBlobs += result.deletedBlobs;
690
+ retainedBlobs += result.retainedBlobs;
691
+ }
692
+ return { workspace: options.workspace, deletedRestorePoints, retainedRestorePoints, deletedBlobs, retainedBlobs };
693
+ }
694
+ async purgeWorkspaceDir(workspaceDir, workspace, restorePointIds, signal) {
695
+ // A live repository needs both its shared Git lock and durable directory lock.
696
+ // A missing path, or one now nested under a different repository, cannot expose
697
+ // its old Git namespace but still takes the durable lock before storage cleanup.
698
+ let liveRepository = false;
699
+ let release;
700
+ try {
701
+ const root = await discoverRepositoryRoot(workspace, signal);
702
+ if (root === workspace) {
703
+ await this.assertStorageSeparated(root);
704
+ release = await this.acquireWorkspace(root, signal);
705
+ liveRepository = true;
706
+ }
707
+ else {
708
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal);
709
+ }
710
+ }
711
+ catch (error) {
712
+ if (!(error instanceof ChangeLedgerError) || error.code !== 'WORKSPACE_NOT_FOUND')
713
+ throw error;
714
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal);
715
+ }
716
+ let deletedRestorePoints = 0;
717
+ let retainedRestorePoints = 0;
718
+ try {
719
+ const manifests = (await this.store.listManifestsInDir(workspaceDir, signal))
720
+ .filter((manifest) => manifest.workspace === workspace
721
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id)));
722
+ const protectedIds = new Set();
723
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, signal)) {
724
+ if (operation.state === 'running' || operation.state === 'rollback-running'
725
+ || operation.state === 'interrupted' || operation.state === 'recovery-required') {
726
+ protectedIds.add(operation.restorePointId);
727
+ protectedIds.add(operation.rescuePointId);
728
+ }
729
+ }
730
+ for (const journal of await this.store.listGitCheckpointJournalsInDir(workspaceDir, signal)) {
731
+ protectedIds.add(journal.restorePointId);
732
+ }
733
+ for (const manifest of manifests) {
734
+ if (protectedIds.has(manifest.id)) {
735
+ retainedRestorePoints += 1;
736
+ continue;
737
+ }
738
+ if (liveRepository) {
739
+ await this.deleteManifestWithGit(manifest, signal);
740
+ }
741
+ else {
742
+ await this.store.deleteManifestInDir(workspaceDir, manifest.id);
743
+ }
744
+ deletedRestorePoints += 1;
745
+ }
746
+ if (restorePointIds === undefined)
747
+ await this.store.clearTurnOutcomesInDir(workspaceDir);
748
+ const gc = await this.store.collectGarbageInDir(workspaceDir, signal);
749
+ return { deletedRestorePoints, retainedRestorePoints, ...gc };
750
+ }
751
+ finally {
752
+ await release();
753
+ }
358
754
  }
359
755
  async createLocked(options) {
360
756
  const existing = await this.store.listManifests(options.cwd);
@@ -362,6 +758,11 @@ export class ChangeLedgerEngine {
362
758
  if (options.kind !== 'turn' && durableUserPoints.length >= this.config.maxRestorePoints) {
363
759
  throw new ChangeLedgerError('RESTORE_POINT_LIMIT', `workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`);
364
760
  }
761
+ if (options.deferGarbageCollection === true) {
762
+ await this.store.reconcileSnapshotCleanup(options.cwd, options.signal);
763
+ throwIfAborted(options.signal);
764
+ await this.store.writeSnapshotCleanup(options.cwd);
765
+ }
365
766
  try {
366
767
  const tree = await captureStableTree({
367
768
  cwd: options.cwd,
@@ -388,13 +789,20 @@ export class ChangeLedgerEngine {
388
789
  entries: tree.entries,
389
790
  restoreCount: 0,
390
791
  };
391
- await this.store.collectGarbage(options.cwd, Object.values(manifest.entries)
392
- .filter((entry) => entry.kind === 'file')
393
- .map((entry) => entry.blob));
792
+ if (options.deferGarbageCollection !== true) {
793
+ await this.store.collectGarbage(options.cwd, Object.values(manifest.entries)
794
+ .filter((entry) => entry.kind === 'file' && entry.provider !== 'git')
795
+ .map((entry) => entry.blob));
796
+ }
394
797
  await this.store.writeManifest(manifest);
798
+ if (options.deferGarbageCollection === true) {
799
+ await this.store.deleteSnapshotCleanup(options.cwd).catch(() => undefined);
800
+ }
395
801
  return manifest;
396
802
  }
397
803
  catch (error) {
804
+ if (options.deferGarbageCollection === true)
805
+ throw error;
398
806
  try {
399
807
  await this.store.collectGarbage(options.cwd);
400
808
  }
@@ -404,11 +812,14 @@ export class ChangeLedgerEngine {
404
812
  throw error;
405
813
  }
406
814
  }
407
- async restorePaths(workspace, desiredEntries, paths, signal) {
815
+ async restorePaths(workspace, desiredEntries, paths, signal, expectedEntries) {
408
816
  const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst);
409
817
  for (const path of deletions) {
410
818
  throwIfAborted(signal);
411
819
  const target = resolveWorkspacePath(workspace, path);
820
+ if (expectedEntries !== undefined)
821
+ await this.assertPathFresh(workspace, path, expectedEntries[path], signal);
822
+ await assertSafeParents(workspace, target);
412
823
  await removeRestoreTarget(target);
413
824
  await pruneEmptyParents(workspace, target);
414
825
  }
@@ -419,6 +830,8 @@ export class ChangeLedgerEngine {
419
830
  if (entry === undefined)
420
831
  continue;
421
832
  const target = resolveWorkspacePath(workspace, path);
833
+ if (expectedEntries !== undefined)
834
+ await this.assertPathFresh(workspace, path, expectedEntries[path], signal);
422
835
  await ensureSafeParents(workspace, target);
423
836
  try {
424
837
  const info = await lstat(target);
@@ -430,10 +843,16 @@ export class ChangeLedgerEngine {
430
843
  throw error;
431
844
  }
432
845
  if (entry.kind === 'file') {
433
- const content = await this.store.readBlob(workspace, entry.blob);
846
+ const content = entry.provider === 'git'
847
+ ? await readGitBlob(workspace, entry.blob, entry.size, signal)
848
+ : await this.store.readBlob(workspace, entry.blob);
434
849
  if (content.length !== entry.size) {
435
850
  throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`);
436
851
  }
852
+ if (expectedEntries !== undefined)
853
+ await this.assertPathFresh(workspace, path, expectedEntries[path], signal);
854
+ else
855
+ await assertSafeParents(workspace, target);
437
856
  await replaceRegularFile(target, content, entry.mode);
438
857
  }
439
858
  else {
@@ -441,13 +860,44 @@ export class ChangeLedgerEngine {
441
860
  }
442
861
  }
443
862
  }
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)}`);
863
+ async assertPathFresh(workspace, path, expected, signal) {
864
+ const target = resolveWorkspacePath(workspace, path);
865
+ await assertSafeParents(workspace, target);
866
+ let current;
867
+ try {
868
+ current = await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal);
869
+ }
870
+ catch (error) {
871
+ if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
872
+ throw new ChangeLedgerError('PLAN_STALE', `workspace path changed type after planning: ${JSON.stringify(path)}`, { cause: error });
449
873
  }
874
+ throw error;
875
+ }
876
+ if (!entriesEqual(expected === null ? undefined : expected, current)) {
877
+ throw new ChangeLedgerError('PLAN_STALE', `workspace changed immediately before restore at ${JSON.stringify(path)}; inspect and plan again`);
878
+ }
879
+ }
880
+ async verifyPaths(workspace, desiredEntries, paths, signal, gitObjectFormat) {
881
+ for (let attempt = 0; attempt < 3; attempt += 1) {
882
+ const first = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat);
883
+ const second = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat);
884
+ if (!paths.every(path => entriesEqual(first.get(path), second.get(path))))
885
+ continue;
886
+ for (const path of paths) {
887
+ if (!entriesEqual(desiredEntries[path], second.get(path))) {
888
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`);
889
+ }
890
+ }
891
+ return;
892
+ }
893
+ throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', 'restored paths did not remain stable during post-verification');
894
+ }
895
+ async captureSelectedEntries(workspace, paths, signal, gitObjectFormat) {
896
+ const entries = new Map();
897
+ for (const path of paths) {
898
+ entries.set(path, await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal, gitObjectFormat));
450
899
  }
900
+ return entries;
451
901
  }
452
902
  async assertStorageSeparated(workspace) {
453
903
  const storage = await realpath(resolve(this.config.storageDir));
@@ -481,10 +931,15 @@ export function resolveConfig(config) {
481
931
  maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
482
932
  planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
483
933
  staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
934
+ turnCheckpointMode: checkpointMode(config.turnCheckpointMode ?? DEFAULTS.turnCheckpointMode),
935
+ turnCheckpointTimeoutMs: positiveInteger(config.turnCheckpointTimeoutMs ?? DEFAULTS.turnCheckpointTimeoutMs, 'turnCheckpointTimeoutMs'),
936
+ turnCheckpointMaxNewBytes: positiveInteger(config.turnCheckpointMaxNewBytes ?? DEFAULTS.turnCheckpointMaxNewBytes, 'turnCheckpointMaxNewBytes'),
937
+ turnCheckpointTrust: checkpointTrust(config.turnCheckpointTrust ?? DEFAULTS.turnCheckpointTrust),
484
938
  };
485
939
  }
486
940
  function summarize(manifest) {
487
941
  return {
942
+ format: manifest.version,
488
943
  id: manifest.id,
489
944
  kind: manifest.kind,
490
945
  workspace: manifest.workspace,
@@ -504,6 +959,7 @@ function summarize(manifest) {
504
959
  ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
505
960
  ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
506
961
  stagedPathCount: manifest.repository.stagedPaths.length,
962
+ ...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
507
963
  };
508
964
  }
509
965
  function assertRepositoryCompatible(manifest, current, allowHeadChange) {
@@ -529,6 +985,18 @@ function assertPlanRepositoryFresh(planned, current) {
529
985
  throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after restore planning; inspect and plan again');
530
986
  }
531
987
  }
988
+ function assertJournalMatchesManifest(journal, manifest) {
989
+ if (manifest.version !== 2
990
+ || manifest.workspace !== journal.workspace
991
+ || manifest.id !== journal.restorePointId
992
+ || manifest.repository.commonDir !== journal.commonDir
993
+ || manifest.git.storeId !== journal.storeId
994
+ || manifest.git.worktreeId !== journal.worktreeId
995
+ || manifest.git.ref !== journal.ref
996
+ || manifest.git.commit !== journal.commit) {
997
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal does not match restore point ${journal.restorePointId}`);
998
+ }
999
+ }
532
1000
  function selectChanges(changes, requested) {
533
1001
  if (requested === undefined || requested.length === 0)
534
1002
  return [...changes];
@@ -601,10 +1069,43 @@ function requireNonEmptyString(value, name) {
601
1069
  }
602
1070
  return value;
603
1071
  }
1072
+ function checkpointMode(value) {
1073
+ if (value !== 'off' && value !== 'git-native' && value !== 'legacy') {
1074
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointMode must be off, git-native, or legacy');
1075
+ }
1076
+ return value;
1077
+ }
1078
+ function checkpointTrust(value) {
1079
+ if (value !== 'fast' && value !== 'strict') {
1080
+ throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointTrust must be fast or strict');
1081
+ }
1082
+ return value;
1083
+ }
604
1084
  function throwIfAborted(signal) {
605
1085
  if (signal?.aborted === true)
606
1086
  throw signal.reason;
607
1087
  }
1088
+ async function waitWithSignal(promise, signal) {
1089
+ if (signal === undefined)
1090
+ return promise;
1091
+ signal.throwIfAborted();
1092
+ let rejectAbort;
1093
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
1094
+ const onAbort = () => rejectAbort?.(signal.reason);
1095
+ signal.addEventListener('abort', onAbort, { once: true });
1096
+ try {
1097
+ return await Promise.race([promise, aborted]);
1098
+ }
1099
+ finally {
1100
+ signal.removeEventListener('abort', onAbort);
1101
+ }
1102
+ }
1103
+ function checkpointDeadlineError(error, timeoutMs, deadline, callerSignal) {
1104
+ if (callerSignal?.aborted !== true && deadline.aborted) {
1105
+ return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error });
1106
+ }
1107
+ return error;
1108
+ }
608
1109
  function depth(path) {
609
1110
  return path.split('/').length;
610
1111
  }