@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.
- package/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- 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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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.
|
|
30
|
-
this.store = new LedgerStore(this.
|
|
31
|
-
this.ready = this.
|
|
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.
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
274
|
+
finally {
|
|
275
|
+
deadline.cancel();
|
|
66
276
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
const
|
|
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 (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
299
|
+
reason: options.reason.slice(0, 2_000),
|
|
300
|
+
createdAt: Date.now(),
|
|
86
301
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
392
|
-
.
|
|
393
|
-
|
|
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 =
|
|
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
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
}
|