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