@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/store.js
CHANGED
|
@@ -1,88 +1,168 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { link, mkdir, open, readFile, readdir, rename, rmdir, unlink } from 'node:fs/promises';
|
|
5
|
+
import { arch, platform } from 'node:os';
|
|
6
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
4
7
|
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
5
|
-
import { isNodeError,
|
|
8
|
+
import { isNodeError, pathExists, processExists, readJson, syncDirectory, writeJsonAtomic } from './path-utils.js';
|
|
6
9
|
import { parseManifest, parseOperation, validateBlobHash, validateOperationId, validateRestorePointId } from './validate.js';
|
|
10
|
+
let hostIdValue;
|
|
7
11
|
/** Durable content-addressed storage and per-workspace locking. */
|
|
8
12
|
export class LedgerStore {
|
|
9
13
|
config;
|
|
14
|
+
workspaceKeys = new Map();
|
|
15
|
+
storeIdValue;
|
|
10
16
|
constructor(config) {
|
|
11
17
|
this.config = config;
|
|
12
18
|
}
|
|
13
19
|
/** Create the state root and reconcile crash-interrupted operations. */
|
|
14
20
|
async initialize() {
|
|
15
|
-
await
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
this.storeIdValue = await ensureStoreId(this.config.storageDir);
|
|
22
|
+
const workspacesDir = join(this.config.storageDir, 'workspaces');
|
|
23
|
+
await mkdir(workspacesDir, { recursive: true, mode: 0o700 });
|
|
24
|
+
let reconciled = await this.reconcileWorkspaceMigrations(workspacesDir);
|
|
25
|
+
const workspaceKeys = await safeDirectoryNames(workspacesDir);
|
|
26
|
+
for (const workspaceKey of workspaceKeys) {
|
|
18
27
|
const workspaceDir = join(this.config.storageDir, 'workspaces', workspaceKey);
|
|
19
|
-
|
|
20
|
-
continue;
|
|
21
|
-
const operationDir = join(workspaceDir, 'operations');
|
|
22
|
-
for (const filename of await safeJsonNames(operationDir)) {
|
|
23
|
-
const path = join(operationDir, filename);
|
|
24
|
-
const operation = parseOperation(await readJson(path));
|
|
25
|
-
if (filename !== `${operation.id}.json`) {
|
|
26
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`);
|
|
27
|
-
}
|
|
28
|
-
if (this.workspaceDir(operation.workspace) !== workspaceDir) {
|
|
29
|
-
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} is stored under the wrong workspace key`);
|
|
30
|
-
}
|
|
31
|
-
if (operation.state !== 'running' && operation.state !== 'rollback-running')
|
|
32
|
-
continue;
|
|
33
|
-
await this.writeOperation({
|
|
34
|
-
...operation,
|
|
35
|
-
state: 'interrupted',
|
|
36
|
-
error: operation.error ?? 'DSH stopped before the restore operation reached a terminal state',
|
|
37
|
-
});
|
|
38
|
-
reconciled += 1;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return reconciled;
|
|
42
|
-
}
|
|
43
|
-
/** Acquire the exclusive lock for one canonical workspace. */
|
|
44
|
-
async acquire(workspace) {
|
|
45
|
-
const lockPath = join(this.workspaceDir(workspace), 'lock.json');
|
|
46
|
-
await mkdir(this.workspaceDir(workspace), { recursive: true, mode: 0o700 });
|
|
47
|
-
const nonce = randomUUID();
|
|
48
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
49
|
-
let handle;
|
|
28
|
+
let lease;
|
|
50
29
|
try {
|
|
51
|
-
|
|
30
|
+
lease = await this.acquireOne(join(workspaceDir, 'lock.json'), workspaceKey, false, true);
|
|
52
31
|
}
|
|
53
32
|
catch (error) {
|
|
54
|
-
if (
|
|
55
|
-
throw error;
|
|
56
|
-
const reclaimed = await this.reclaimStaleLock(lockPath);
|
|
57
|
-
if (reclaimed)
|
|
33
|
+
if (error instanceof ChangeLedgerError && error.code === 'WORKSPACE_LOCKED')
|
|
58
34
|
continue;
|
|
59
|
-
throw
|
|
35
|
+
throw error;
|
|
60
36
|
}
|
|
61
|
-
const record = { pid: process.pid, createdAt: Date.now(), nonce };
|
|
62
37
|
try {
|
|
63
|
-
await
|
|
64
|
-
await
|
|
38
|
+
await this.completeWorkspaceRebind(workspaceDir, workspaceKey);
|
|
39
|
+
const binding = await readWorkspaceBinding(workspaceDir, workspaceKey);
|
|
40
|
+
if (binding !== undefined)
|
|
41
|
+
this.workspaceKeys.set(binding.workspace, workspaceKey);
|
|
42
|
+
const operationDir = join(workspaceDir, 'operations');
|
|
43
|
+
for (const filename of await safeJsonNames(operationDir)) {
|
|
44
|
+
const path = join(operationDir, filename);
|
|
45
|
+
const operation = parseOperation(await readJson(path));
|
|
46
|
+
if (filename !== `${operation.id}.json`) {
|
|
47
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`);
|
|
48
|
+
}
|
|
49
|
+
if (this.workspaceDir(operation.workspace) !== workspaceDir) {
|
|
50
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} is stored under the wrong workspace key`);
|
|
51
|
+
}
|
|
52
|
+
if (operation.state !== 'running' && operation.state !== 'rollback-running')
|
|
53
|
+
continue;
|
|
54
|
+
await this.writeOperation({
|
|
55
|
+
...operation,
|
|
56
|
+
state: 'interrupted',
|
|
57
|
+
error: operation.error ?? 'DSH stopped before the restore operation reached a terminal state',
|
|
58
|
+
});
|
|
59
|
+
reconciled += 1;
|
|
60
|
+
}
|
|
61
|
+
const cleanup = await readSnapshotCleanupJournal(workspaceDir);
|
|
62
|
+
if (cleanup !== undefined) {
|
|
63
|
+
if (this.workspaceDir(cleanup.workspace) !== workspaceDir) {
|
|
64
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'snapshot cleanup journal is stored under the wrong workspace key');
|
|
65
|
+
}
|
|
66
|
+
await this.reconcileSnapshotCleanup(cleanup.workspace);
|
|
67
|
+
reconciled += 1;
|
|
68
|
+
}
|
|
65
69
|
}
|
|
66
70
|
finally {
|
|
67
|
-
await
|
|
71
|
+
await lease.release();
|
|
68
72
|
}
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
}
|
|
74
|
+
return reconciled;
|
|
75
|
+
}
|
|
76
|
+
/** Durable owner of this storage root's private Git namespace. */
|
|
77
|
+
get storeId() {
|
|
78
|
+
if (this.storeIdValue === undefined)
|
|
79
|
+
throw new ChangeLedgerError('STATE_READ_FAILED', 'store is not initialized');
|
|
80
|
+
return this.storeIdValue;
|
|
81
|
+
}
|
|
82
|
+
/** Acquire the exclusive lock for one canonical workspace. */
|
|
83
|
+
async acquire(workspace, sharedLockPath, binding, signal) {
|
|
84
|
+
throwIfAborted(signal);
|
|
85
|
+
const sharedLease = sharedLockPath === undefined
|
|
86
|
+
? undefined
|
|
87
|
+
: await this.acquireOne(sharedLockPath, workspace, true, true, signal);
|
|
88
|
+
let localLease;
|
|
89
|
+
try {
|
|
90
|
+
throwIfAborted(signal);
|
|
91
|
+
localLease = binding === undefined
|
|
92
|
+
? await this.acquireOne(join(this.workspaceDir(workspace), 'lock.json'), workspace, false, true, signal)
|
|
93
|
+
: await this.bindGitWorkspace(workspace, binding.commonDir, binding.gitDir, binding.worktreeId, signal);
|
|
94
|
+
const acquiredLocalLease = localLease;
|
|
71
95
|
return async () => {
|
|
72
|
-
|
|
73
|
-
return;
|
|
74
|
-
released = true;
|
|
96
|
+
let releaseError;
|
|
75
97
|
try {
|
|
76
|
-
|
|
77
|
-
if (current.nonce !== nonce)
|
|
78
|
-
return;
|
|
79
|
-
await unlink(lockPath);
|
|
80
|
-
await syncDirectory(this.workspaceDir(workspace));
|
|
98
|
+
await acquiredLocalLease.release();
|
|
81
99
|
}
|
|
82
100
|
catch (error) {
|
|
83
|
-
|
|
84
|
-
throw error;
|
|
101
|
+
releaseError = error;
|
|
85
102
|
}
|
|
103
|
+
try {
|
|
104
|
+
await sharedLease?.release();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (releaseError === undefined)
|
|
108
|
+
releaseError = error;
|
|
109
|
+
}
|
|
110
|
+
if (releaseError !== undefined)
|
|
111
|
+
throw releaseError;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
await localLease?.release();
|
|
116
|
+
await sharedLease?.release();
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async acquireOne(initialLockPath, workspace, shared, reclaim = true, signal) {
|
|
121
|
+
throwIfAborted(signal);
|
|
122
|
+
const ownerHostId = hostIdentity();
|
|
123
|
+
let lockPath = initialLockPath;
|
|
124
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
125
|
+
const nonce = randomUUID();
|
|
126
|
+
const record = { pid: process.pid, hostId: ownerHostId, createdAt: Date.now(), nonce };
|
|
127
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
128
|
+
throwIfAborted(signal);
|
|
129
|
+
const reclaimResult = await this.completeLockReclaims(lockPath, record, shared, reclaim);
|
|
130
|
+
let acquired = reclaimResult === 'acquired';
|
|
131
|
+
if (reclaimResult === 'busy') {
|
|
132
|
+
throw new ChangeLedgerError('WORKSPACE_LOCKED', `another change-ledger operation owns ${JSON.stringify(workspace)}`);
|
|
133
|
+
}
|
|
134
|
+
if (!acquired)
|
|
135
|
+
acquired = await publishExclusiveFile(lockPath, `${JSON.stringify(record)}\n`);
|
|
136
|
+
if (!acquired) {
|
|
137
|
+
if (!reclaim)
|
|
138
|
+
throw new ChangeLedgerError('WORKSPACE_LOCKED', `another change-ledger operation owns ${JSON.stringify(workspace)}`);
|
|
139
|
+
if (await this.prepareLockReclaim(lockPath, shared))
|
|
140
|
+
continue;
|
|
141
|
+
throw new ChangeLedgerError('WORKSPACE_LOCKED', `another change-ledger operation owns ${JSON.stringify(workspace)}`);
|
|
142
|
+
}
|
|
143
|
+
let released = false;
|
|
144
|
+
return {
|
|
145
|
+
relocate(path) {
|
|
146
|
+
if (released)
|
|
147
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'cannot relocate a released workspace lock');
|
|
148
|
+
lockPath = path;
|
|
149
|
+
},
|
|
150
|
+
async release() {
|
|
151
|
+
if (released)
|
|
152
|
+
return;
|
|
153
|
+
released = true;
|
|
154
|
+
try {
|
|
155
|
+
const current = parseLock(await readJson(lockPath));
|
|
156
|
+
if (current.nonce !== nonce)
|
|
157
|
+
return;
|
|
158
|
+
await unlink(lockPath);
|
|
159
|
+
await syncDirectory(dirname(lockPath));
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (!isNodeError(error, 'ENOENT') && !isMissingStateRead(error))
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
86
166
|
};
|
|
87
167
|
}
|
|
88
168
|
throw new ChangeLedgerError('WORKSPACE_LOCKED', `could not acquire change-ledger lock for ${JSON.stringify(workspace)}`);
|
|
@@ -143,7 +223,9 @@ export class LedgerStore {
|
|
|
143
223
|
}
|
|
144
224
|
/** Write one restore-point manifest atomically. */
|
|
145
225
|
async writeManifest(manifest) {
|
|
146
|
-
parseManifest(manifest);
|
|
226
|
+
const parsed = parseManifest(manifest);
|
|
227
|
+
this.assertManifestOwned(parsed);
|
|
228
|
+
await this.assertManifestWorkspaceIdentity(parsed, this.workspaceDir(manifest.workspace));
|
|
147
229
|
await writeJsonAtomic(this.manifestPath(manifest.workspace, manifest.id), manifest);
|
|
148
230
|
}
|
|
149
231
|
/** Load and validate one restore-point manifest. */
|
|
@@ -160,6 +242,8 @@ export class LedgerStore {
|
|
|
160
242
|
throw error;
|
|
161
243
|
}
|
|
162
244
|
const manifest = parseManifest(raw);
|
|
245
|
+
this.assertManifestOwned(manifest);
|
|
246
|
+
await this.assertManifestWorkspaceIdentity(manifest, this.workspaceDir(workspace));
|
|
163
247
|
if (manifest.id !== id) {
|
|
164
248
|
throw new ChangeLedgerError('STATE_CORRUPT', `restore point file ${id}.json contains id ${manifest.id}`);
|
|
165
249
|
}
|
|
@@ -169,10 +253,13 @@ export class LedgerStore {
|
|
|
169
253
|
return manifest;
|
|
170
254
|
}
|
|
171
255
|
/** List all validated restore points for one workspace, newest first. */
|
|
172
|
-
async listManifests(workspace) {
|
|
256
|
+
async listManifests(workspace, signal) {
|
|
173
257
|
const manifests = [];
|
|
174
258
|
for (const filename of await safeJsonNames(join(this.workspaceDir(workspace), 'manifests'))) {
|
|
259
|
+
throwIfAborted(signal);
|
|
175
260
|
const manifest = parseManifest(await readJson(join(this.workspaceDir(workspace), 'manifests', filename)));
|
|
261
|
+
this.assertManifestOwned(manifest);
|
|
262
|
+
await this.assertManifestWorkspaceIdentity(manifest, this.workspaceDir(workspace));
|
|
176
263
|
if (filename !== `${manifest.id}.json`) {
|
|
177
264
|
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} does not match its persisted id ${manifest.id}`);
|
|
178
265
|
}
|
|
@@ -198,6 +285,110 @@ export class LedgerStore {
|
|
|
198
285
|
}
|
|
199
286
|
await syncDirectory(directory);
|
|
200
287
|
}
|
|
288
|
+
/** Persist the intent needed to finish or roll back a Git ref/manifest transition after a crash. */
|
|
289
|
+
async writeGitCheckpointJournal(action, manifest) {
|
|
290
|
+
this.assertManifestOwned(manifest);
|
|
291
|
+
await this.assertManifestWorkspaceIdentity(manifest, this.workspaceDir(manifest.workspace));
|
|
292
|
+
const journal = {
|
|
293
|
+
version: 1,
|
|
294
|
+
action,
|
|
295
|
+
storeId: manifest.git.storeId,
|
|
296
|
+
workspace: manifest.workspace,
|
|
297
|
+
commonDir: manifest.repository.commonDir,
|
|
298
|
+
worktreeId: manifest.git.worktreeId,
|
|
299
|
+
restorePointId: manifest.id,
|
|
300
|
+
ref: manifest.git.ref,
|
|
301
|
+
commit: manifest.git.commit,
|
|
302
|
+
createdAt: Date.now(),
|
|
303
|
+
};
|
|
304
|
+
this.assertGitCheckpointJournalOwned(parseGitCheckpointJournal(journal));
|
|
305
|
+
await writeJsonAtomic(this.gitCheckpointJournalPath(manifest.workspace, manifest.id), journal);
|
|
306
|
+
}
|
|
307
|
+
/** List every pending Git checkpoint transition across this storage root. */
|
|
308
|
+
async listGitCheckpointJournals() {
|
|
309
|
+
const journals = [];
|
|
310
|
+
for (const workspaceKey of await safeDirectoryNames(join(this.config.storageDir, 'workspaces'))) {
|
|
311
|
+
const workspaceDir = join(this.config.storageDir, 'workspaces', workspaceKey);
|
|
312
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'git-journals'))) {
|
|
313
|
+
const journal = parseGitCheckpointJournal(await readJson(join(workspaceDir, 'git-journals', filename)));
|
|
314
|
+
this.assertGitCheckpointJournalOwned(journal);
|
|
315
|
+
await this.assertGitCheckpointJournalWorkspaceIdentity(journal, workspaceDir);
|
|
316
|
+
if (filename !== `${journal.restorePointId}.json`) {
|
|
317
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${filename} does not match its restore-point id`);
|
|
318
|
+
}
|
|
319
|
+
if (this.workspaceDir(journal.workspace) !== workspaceDir) {
|
|
320
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${filename} is stored under the wrong workspace key`);
|
|
321
|
+
}
|
|
322
|
+
journals.push(journal);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return journals.sort((left, right) => left.createdAt - right.createdAt || left.restorePointId.localeCompare(right.restorePointId));
|
|
326
|
+
}
|
|
327
|
+
/** Remove one completed Git checkpoint transition journal. */
|
|
328
|
+
async deleteGitCheckpointJournal(workspace, id) {
|
|
329
|
+
validateRestorePointId(id);
|
|
330
|
+
const path = this.gitCheckpointJournalPath(workspace, id);
|
|
331
|
+
try {
|
|
332
|
+
await unlink(path);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (isNodeError(error, 'ENOENT'))
|
|
336
|
+
return;
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
await syncDirectory(dirname(path));
|
|
340
|
+
}
|
|
341
|
+
/** Persist one explicit automatic-checkpoint skip so the UI survives restarts. */
|
|
342
|
+
async writeTurnCheckpointSkip(workspace, skip) {
|
|
343
|
+
parseTurnCheckpointSkip(skip);
|
|
344
|
+
await writeJsonAtomic(this.turnCheckpointSkipPath(workspace, skip.sessionId, skip.turn, skip.turnStartSeq), skip);
|
|
345
|
+
}
|
|
346
|
+
/** Read one durable automatic-checkpoint skip. */
|
|
347
|
+
async readTurnCheckpointSkip(workspace, sessionId, turn, turnStartSeq) {
|
|
348
|
+
const path = this.turnCheckpointSkipPath(workspace, sessionId, turn, turnStartSeq);
|
|
349
|
+
try {
|
|
350
|
+
const skip = parseTurnCheckpointSkip(await readJson(path));
|
|
351
|
+
if (skip.sessionId !== sessionId || skip.turn !== turn || skip.turnStartSeq !== turnStartSeq) {
|
|
352
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'turn checkpoint skip is stored under the wrong key');
|
|
353
|
+
}
|
|
354
|
+
return skip;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (isMissingStateRead(error))
|
|
358
|
+
return undefined;
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Remove a stale skip after the same turn obtains a ready checkpoint. */
|
|
363
|
+
async deleteTurnCheckpointSkip(workspace, sessionId, turn, turnStartSeq) {
|
|
364
|
+
const path = this.turnCheckpointSkipPath(workspace, sessionId, turn, turnStartSeq);
|
|
365
|
+
try {
|
|
366
|
+
await unlink(path);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (isNodeError(error, 'ENOENT'))
|
|
370
|
+
return;
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
await syncDirectory(dirname(path));
|
|
374
|
+
}
|
|
375
|
+
/** Mark a legacy capture whose unreferenced blobs need crash recovery. */
|
|
376
|
+
async writeSnapshotCleanup(workspace) {
|
|
377
|
+
await writeJsonAtomic(join(this.workspaceDir(workspace), 'snapshot-cleanup.json'), { version: 1, workspace });
|
|
378
|
+
}
|
|
379
|
+
/** Clear a completed legacy-capture cleanup marker. */
|
|
380
|
+
async deleteSnapshotCleanup(workspace) {
|
|
381
|
+
const path = join(this.workspaceDir(workspace), 'snapshot-cleanup.json');
|
|
382
|
+
try {
|
|
383
|
+
await unlink(path);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
if (isNodeError(error, 'ENOENT'))
|
|
387
|
+
return;
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
await syncDirectory(dirname(path));
|
|
391
|
+
}
|
|
201
392
|
/** Persist one restore-operation journal. */
|
|
202
393
|
async writeOperation(operation) {
|
|
203
394
|
parseOperation(operation);
|
|
@@ -224,13 +415,14 @@ export class LedgerStore {
|
|
|
224
415
|
&& (operation.restorePointId === restorePointId || operation.rescuePointId === restorePointId));
|
|
225
416
|
}
|
|
226
417
|
/** Delete blobs not referenced by any remaining manifest. */
|
|
227
|
-
async collectGarbage(workspace, additionalReferenced = []) {
|
|
418
|
+
async collectGarbage(workspace, additionalReferenced = [], signal) {
|
|
228
419
|
const referenced = new Set(additionalReferenced);
|
|
229
420
|
for (const hash of referenced)
|
|
230
421
|
validateBlobHash(hash);
|
|
231
|
-
for (const manifest of await this.listManifests(workspace)) {
|
|
422
|
+
for (const manifest of await this.listManifests(workspace, signal)) {
|
|
423
|
+
throwIfAborted(signal);
|
|
232
424
|
for (const entry of Object.values(manifest.entries)) {
|
|
233
|
-
if (entry.kind === 'file')
|
|
425
|
+
if (entry.kind === 'file' && entry.provider !== 'git')
|
|
234
426
|
referenced.add(entry.blob);
|
|
235
427
|
}
|
|
236
428
|
}
|
|
@@ -238,8 +430,10 @@ export class LedgerStore {
|
|
|
238
430
|
let retainedBlobs = 0;
|
|
239
431
|
const blobsRoot = join(this.workspaceDir(workspace), 'blobs');
|
|
240
432
|
for (const prefix of await safeDirectoryNames(blobsRoot)) {
|
|
433
|
+
throwIfAborted(signal);
|
|
241
434
|
const prefixPath = join(blobsRoot, prefix);
|
|
242
435
|
for (const filename of await safeFileNames(prefixPath)) {
|
|
436
|
+
throwIfAborted(signal);
|
|
243
437
|
if (filename.startsWith('.') && filename.endsWith('.tmp')) {
|
|
244
438
|
await unlink(join(prefixPath, filename));
|
|
245
439
|
deletedBlobs += 1;
|
|
@@ -266,83 +460,503 @@ export class LedgerStore {
|
|
|
266
460
|
}
|
|
267
461
|
return { deletedBlobs, retainedBlobs };
|
|
268
462
|
}
|
|
463
|
+
/** Finish a prior bounded legacy capture's orphan cleanup, if one is pending. */
|
|
464
|
+
async reconcileSnapshotCleanup(workspace, signal) {
|
|
465
|
+
const cleanup = await readSnapshotCleanupJournal(this.workspaceDir(workspace));
|
|
466
|
+
if (cleanup === undefined)
|
|
467
|
+
return false;
|
|
468
|
+
if (cleanup.workspace !== workspace) {
|
|
469
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'snapshot cleanup journal belongs to a different workspace');
|
|
470
|
+
}
|
|
471
|
+
await this.collectGarbage(workspace, [], signal);
|
|
472
|
+
throwIfAborted(signal);
|
|
473
|
+
await this.deleteSnapshotCleanup(workspace);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
269
476
|
workspaceDir(workspace) {
|
|
270
|
-
const key = createHash('sha256').update(workspace).digest('hex');
|
|
477
|
+
const key = this.workspaceKeys.get(workspace) ?? createHash('sha256').update(workspace).digest('hex');
|
|
271
478
|
return join(this.config.storageDir, 'workspaces', key);
|
|
272
479
|
}
|
|
480
|
+
assertManifestOwned(manifest) {
|
|
481
|
+
if (manifest.version === 2 && manifest.git.storeId !== this.storeId) {
|
|
482
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint ${manifest.id} belongs to a different storage root`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
assertGitCheckpointJournalOwned(journal) {
|
|
486
|
+
if (journal.storeId !== this.storeId) {
|
|
487
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${journal.restorePointId} belongs to a different storage root`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async assertManifestWorkspaceIdentity(manifest, workspaceDir, allowMovedWorkspace = false) {
|
|
491
|
+
if (manifest.version !== 2)
|
|
492
|
+
return;
|
|
493
|
+
const binding = await readWorkspaceBinding(workspaceDir, basename(workspaceDir));
|
|
494
|
+
if (binding === undefined
|
|
495
|
+
|| binding.commonDir !== manifest.repository.commonDir
|
|
496
|
+
|| binding.worktreeId !== manifest.git.worktreeId
|
|
497
|
+
|| (!allowMovedWorkspace && binding.workspace !== manifest.workspace)) {
|
|
498
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint ${manifest.id} does not match its workspace binding`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async assertGitCheckpointJournalWorkspaceIdentity(journal, workspaceDir, allowMovedWorkspace = false) {
|
|
502
|
+
const binding = await readWorkspaceBinding(workspaceDir, basename(workspaceDir));
|
|
503
|
+
if (binding === undefined
|
|
504
|
+
|| binding.commonDir !== journal.commonDir
|
|
505
|
+
|| binding.worktreeId !== journal.worktreeId
|
|
506
|
+
|| (!allowMovedWorkspace && binding.workspace !== journal.workspace)) {
|
|
507
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${journal.restorePointId} does not match its workspace binding`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
273
510
|
manifestPath(workspace, id) {
|
|
274
511
|
return join(this.workspaceDir(workspace), 'manifests', `${validateRestorePointId(id)}.json`);
|
|
275
512
|
}
|
|
276
513
|
operationPath(workspace, id) {
|
|
277
514
|
return join(this.workspaceDir(workspace), 'operations', `${validateOperationId(id)}.json`);
|
|
278
515
|
}
|
|
516
|
+
gitCheckpointJournalPath(workspace, id) {
|
|
517
|
+
return join(this.workspaceDir(workspace), 'git-journals', `${validateRestorePointId(id)}.json`);
|
|
518
|
+
}
|
|
519
|
+
turnCheckpointSkipPath(workspace, sessionId, turn, turnStartSeq) {
|
|
520
|
+
const key = createHash('sha256').update(`${sessionId}\0${turn}\0${turnStartSeq}`).digest('hex');
|
|
521
|
+
return join(this.workspaceDir(workspace), 'turn-outcomes', `${key}.json`);
|
|
522
|
+
}
|
|
279
523
|
blobPath(workspace, hash) {
|
|
280
524
|
validateBlobHash(hash);
|
|
281
525
|
return join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2), hash);
|
|
282
526
|
}
|
|
283
|
-
async
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
527
|
+
async bindGitWorkspace(workspace, commonDir, gitDir, worktreeId, signal) {
|
|
528
|
+
throwIfAborted(signal);
|
|
529
|
+
const workspaceKey = gitWorkspaceKey(commonDir, worktreeId);
|
|
530
|
+
const workspacesDir = join(this.config.storageDir, 'workspaces');
|
|
531
|
+
const knownWorkspaceKey = this.workspaceKeys.get(workspace);
|
|
532
|
+
if (knownWorkspaceKey !== undefined && knownWorkspaceKey !== workspaceKey) {
|
|
533
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity changed for ${JSON.stringify(workspace)}`);
|
|
287
534
|
}
|
|
288
|
-
|
|
289
|
-
|
|
535
|
+
await this.assertNoUnresolvedWorkspaceOwnership(workspacesDir, commonDir, gitDir, worktreeId, workspaceKey);
|
|
536
|
+
await this.completeWorkspaceMigration(workspacesDir, workspaceKey, signal);
|
|
537
|
+
const targetDir = join(workspacesDir, workspaceKey);
|
|
538
|
+
const legacyKey = createHash('sha256').update(workspace).digest('hex');
|
|
539
|
+
const legacyDir = join(workspacesDir, legacyKey);
|
|
540
|
+
const targetExists = await pathExists(targetDir);
|
|
541
|
+
const legacyExists = legacyDir !== targetDir && await pathExists(legacyDir);
|
|
542
|
+
if (targetExists && legacyExists) {
|
|
543
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `both legacy and durable state directories exist for ${JSON.stringify(workspace)}`);
|
|
544
|
+
}
|
|
545
|
+
let localLease;
|
|
546
|
+
let migratedLegacy = false;
|
|
547
|
+
if (legacyExists) {
|
|
548
|
+
localLease = await this.acquireOne(join(legacyDir, 'lock.json'), workspace, false, false, signal);
|
|
290
549
|
try {
|
|
291
|
-
|
|
550
|
+
throwIfAborted(signal);
|
|
551
|
+
await this.ensureWorktreeClaim(commonDir, gitDir, worktreeId, workspaceKey);
|
|
552
|
+
const migration = {
|
|
553
|
+
version: 1,
|
|
554
|
+
workspace,
|
|
555
|
+
legacyKey,
|
|
556
|
+
targetKey: workspaceKey,
|
|
557
|
+
commonDir,
|
|
558
|
+
gitDir,
|
|
559
|
+
worktreeId,
|
|
560
|
+
};
|
|
561
|
+
await writeJsonAtomic(workspaceMigrationPath(workspacesDir, workspaceKey), migration);
|
|
562
|
+
await rename(legacyDir, targetDir);
|
|
563
|
+
localLease.relocate(join(targetDir, 'lock.json'));
|
|
564
|
+
await syncDirectory(workspacesDir);
|
|
565
|
+
migratedLegacy = true;
|
|
292
566
|
}
|
|
293
|
-
catch (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
throw statError;
|
|
567
|
+
catch (error) {
|
|
568
|
+
await localLease.release();
|
|
569
|
+
throw error;
|
|
297
570
|
}
|
|
298
|
-
|
|
299
|
-
|
|
571
|
+
}
|
|
572
|
+
else if (targetExists) {
|
|
573
|
+
localLease = await this.acquireOne(join(targetDir, 'lock.json'), workspace, false, true, signal);
|
|
574
|
+
try {
|
|
575
|
+
await this.ensureWorktreeClaim(commonDir, gitDir, worktreeId, workspaceKey);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
await localLease.release();
|
|
579
|
+
throw error;
|
|
300
580
|
}
|
|
301
|
-
await unlink(lockPath);
|
|
302
|
-
return true;
|
|
303
581
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
582
|
+
else {
|
|
583
|
+
await this.ensureWorktreeClaim(commonDir, gitDir, worktreeId, workspaceKey);
|
|
584
|
+
await mkdir(targetDir, { recursive: true, mode: 0o700 });
|
|
585
|
+
localLease = await this.acquireOne(join(targetDir, 'lock.json'), workspace, false, true, signal);
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
throwIfAborted(signal);
|
|
589
|
+
await this.completeWorkspaceRebind(targetDir, workspaceKey, signal);
|
|
590
|
+
const existing = await readWorkspaceBinding(targetDir, workspaceKey);
|
|
591
|
+
if (existing !== undefined
|
|
592
|
+
&& (existing.commonDir !== commonDir || existing.gitDir !== gitDir || existing.worktreeId !== worktreeId)) {
|
|
593
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `workspace binding identity changed for ${JSON.stringify(workspace)}`);
|
|
594
|
+
}
|
|
595
|
+
const previousWorkspace = existing?.workspace;
|
|
596
|
+
if (previousWorkspace !== undefined && previousWorkspace !== workspace) {
|
|
597
|
+
const journal = {
|
|
598
|
+
version: 1,
|
|
599
|
+
previousWorkspace,
|
|
600
|
+
currentWorkspace: workspace,
|
|
601
|
+
commonDir,
|
|
602
|
+
worktreeId,
|
|
603
|
+
};
|
|
604
|
+
await writeJsonAtomic(join(targetDir, 'workspace-rebind.json'), journal);
|
|
605
|
+
await this.completeWorkspaceRebind(targetDir, workspaceKey, signal);
|
|
606
|
+
this.workspaceKeys.delete(previousWorkspace);
|
|
607
|
+
}
|
|
608
|
+
else if (existing === undefined) {
|
|
609
|
+
const binding = { version: 1, workspace, commonDir, gitDir, worktreeId };
|
|
610
|
+
await writeJsonAtomic(join(targetDir, 'workspace-binding.json'), binding);
|
|
611
|
+
}
|
|
612
|
+
this.workspaceKeys.set(workspace, workspaceKey);
|
|
613
|
+
if (migratedLegacy)
|
|
614
|
+
await deleteWorkspaceMigration(workspacesDir, workspaceKey);
|
|
615
|
+
return localLease;
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
await localLease.release();
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async reconcileWorkspaceMigrations(workspacesDir) {
|
|
623
|
+
let reconciled = 0;
|
|
624
|
+
for (const workspaceKey of await workspaceMigrationKeys(workspacesDir)) {
|
|
625
|
+
if (await this.completeWorkspaceMigration(workspacesDir, workspaceKey, undefined, true))
|
|
626
|
+
reconciled += 1;
|
|
627
|
+
}
|
|
628
|
+
return reconciled;
|
|
629
|
+
}
|
|
630
|
+
async completeWorkspaceMigration(workspacesDir, workspaceKey, signal, skipLocked = false) {
|
|
631
|
+
const journal = await readWorkspaceMigrationJournal(workspacesDir, workspaceKey);
|
|
632
|
+
if (journal === undefined)
|
|
307
633
|
return false;
|
|
634
|
+
const legacyDir = join(workspacesDir, journal.legacyKey);
|
|
635
|
+
const targetDir = join(workspacesDir, journal.targetKey);
|
|
636
|
+
const legacyExists = await pathExists(legacyDir);
|
|
637
|
+
const targetExists = await pathExists(targetDir);
|
|
638
|
+
if (legacyExists && targetExists) {
|
|
639
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `both source and target exist for workspace migration ${workspaceKey}`);
|
|
640
|
+
}
|
|
641
|
+
if (!legacyExists && !targetExists) {
|
|
642
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `workspace migration ${workspaceKey} has no source or target directory`);
|
|
643
|
+
}
|
|
644
|
+
const activeDir = legacyExists ? legacyDir : targetDir;
|
|
645
|
+
let lease;
|
|
308
646
|
try {
|
|
309
|
-
|
|
310
|
-
|
|
647
|
+
lease = await this.acquireOne(join(activeDir, 'lock.json'), journal.workspace, false, true, signal);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
if (error instanceof ChangeLedgerError && error.code === 'WORKSPACE_LOCKED' && skipLocked)
|
|
311
651
|
return false;
|
|
312
|
-
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
throwIfAborted(signal);
|
|
656
|
+
await this.assertNoUnresolvedWorkspaceOwnership(workspacesDir, journal.commonDir, journal.gitDir, journal.worktreeId, journal.targetKey);
|
|
657
|
+
await this.ensureWorktreeClaim(journal.commonDir, journal.gitDir, journal.worktreeId, journal.targetKey);
|
|
658
|
+
if (legacyExists) {
|
|
659
|
+
await rename(legacyDir, targetDir);
|
|
660
|
+
lease.relocate(join(targetDir, 'lock.json'));
|
|
661
|
+
await syncDirectory(workspacesDir);
|
|
662
|
+
}
|
|
663
|
+
await writeJsonAtomic(join(targetDir, 'workspace-binding.json'), {
|
|
664
|
+
version: 1,
|
|
665
|
+
workspace: journal.workspace,
|
|
666
|
+
commonDir: journal.commonDir,
|
|
667
|
+
gitDir: journal.gitDir,
|
|
668
|
+
worktreeId: journal.worktreeId,
|
|
669
|
+
});
|
|
670
|
+
await deleteWorkspaceMigration(workspacesDir, workspaceKey);
|
|
313
671
|
return true;
|
|
314
672
|
}
|
|
673
|
+
finally {
|
|
674
|
+
await lease.release();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
async assertNoUnresolvedWorkspaceOwnership(workspacesDir, commonDir, gitDir, worktreeId, currentWorkspaceKey) {
|
|
678
|
+
const claimsDir = join(this.config.storageDir, 'worktree-claims');
|
|
679
|
+
for (const claimedWorktreeId of await worktreeClaimIds(claimsDir)) {
|
|
680
|
+
const claim = await readWorktreeClaim(claimsDir, claimedWorktreeId);
|
|
681
|
+
if (claim === undefined)
|
|
682
|
+
continue;
|
|
683
|
+
if (claimedWorktreeId === worktreeId) {
|
|
684
|
+
if (claim.workspaceKey !== currentWorkspaceKey || claim.commonDir !== commonDir || claim.gitDir !== gitDir) {
|
|
685
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `worktree identity ${JSON.stringify(worktreeId)} is already claimed by another repository namespace`);
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (claim.commonDir === commonDir && claim.gitDir === gitDir) {
|
|
690
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `cannot create a new worktree identity while a durable claim still owns Git directory ${JSON.stringify(gitDir)}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
for (const workspaceKey of await safeDirectoryNames(workspacesDir)) {
|
|
694
|
+
const binding = await readWorkspaceBinding(join(workspacesDir, workspaceKey), workspaceKey);
|
|
695
|
+
if (binding === undefined || workspaceKey === currentWorkspaceKey)
|
|
696
|
+
continue;
|
|
697
|
+
if (binding.worktreeId === worktreeId) {
|
|
698
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `cannot open a new workspace namespace while historical state still owns worktree identity ${JSON.stringify(worktreeId)}`);
|
|
699
|
+
}
|
|
700
|
+
if (binding.commonDir === commonDir && binding.gitDir === gitDir) {
|
|
701
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `cannot create a new worktree identity while historical state still owns Git directory ${JSON.stringify(gitDir)}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
for (const workspaceKey of await workspaceMigrationKeys(workspacesDir)) {
|
|
705
|
+
if (workspaceKey === currentWorkspaceKey)
|
|
706
|
+
continue;
|
|
707
|
+
const journal = await readWorkspaceMigrationJournal(workspacesDir, workspaceKey);
|
|
708
|
+
if (journal === undefined)
|
|
709
|
+
continue;
|
|
710
|
+
if (journal.worktreeId === worktreeId) {
|
|
711
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `cannot open a new workspace namespace while a migration still owns worktree identity ${JSON.stringify(worktreeId)}`);
|
|
712
|
+
}
|
|
713
|
+
if (journal.commonDir === commonDir && journal.gitDir === gitDir) {
|
|
714
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `cannot create a new worktree identity while a migration still owns Git directory ${JSON.stringify(gitDir)}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async ensureWorktreeClaim(commonDir, gitDir, worktreeId, workspaceKey) {
|
|
719
|
+
const claimsDir = join(this.config.storageDir, 'worktree-claims');
|
|
720
|
+
const claimPath = worktreeClaimPath(claimsDir, worktreeId);
|
|
721
|
+
const claim = { version: 1, worktreeId, workspaceKey, commonDir, gitDir };
|
|
722
|
+
if (await publishExclusiveFile(claimPath, `${JSON.stringify(claim, null, 2)}\n`))
|
|
723
|
+
return;
|
|
724
|
+
const existing = await readWorktreeClaim(claimsDir, worktreeId);
|
|
725
|
+
if (existing === undefined
|
|
726
|
+
|| existing.workspaceKey !== workspaceKey
|
|
727
|
+
|| existing.commonDir !== commonDir
|
|
728
|
+
|| existing.gitDir !== gitDir) {
|
|
729
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `worktree identity ${JSON.stringify(worktreeId)} is already claimed by another repository namespace`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async completeWorkspaceRebind(directory, workspaceKey, signal) {
|
|
733
|
+
throwIfAborted(signal);
|
|
734
|
+
const journal = await readWorkspaceRebindJournal(directory, workspaceKey);
|
|
735
|
+
if (journal === undefined)
|
|
736
|
+
return;
|
|
737
|
+
const binding = await readWorkspaceBinding(directory, workspaceKey);
|
|
738
|
+
if (binding === undefined
|
|
739
|
+
|| binding.commonDir !== journal.commonDir
|
|
740
|
+
|| binding.worktreeId !== journal.worktreeId
|
|
741
|
+
|| (binding.workspace !== journal.previousWorkspace && binding.workspace !== journal.currentWorkspace)) {
|
|
742
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'workspace rebind journal does not match its durable binding');
|
|
743
|
+
}
|
|
744
|
+
await this.rewriteWorkspaceRoot(directory, journal.previousWorkspace, journal.currentWorkspace, signal);
|
|
745
|
+
throwIfAborted(signal);
|
|
746
|
+
await writeJsonAtomic(join(directory, 'workspace-binding.json'), {
|
|
747
|
+
version: 1,
|
|
748
|
+
workspace: journal.currentWorkspace,
|
|
749
|
+
commonDir: journal.commonDir,
|
|
750
|
+
gitDir: binding.gitDir,
|
|
751
|
+
worktreeId: journal.worktreeId,
|
|
752
|
+
});
|
|
753
|
+
try {
|
|
754
|
+
await unlink(join(directory, 'workspace-rebind.json'));
|
|
755
|
+
}
|
|
315
756
|
catch (error) {
|
|
316
|
-
if (isNodeError(error, 'ENOENT'))
|
|
317
|
-
|
|
318
|
-
|
|
757
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
await syncDirectory(directory);
|
|
761
|
+
}
|
|
762
|
+
async rewriteWorkspaceRoot(directory, previous, current, signal) {
|
|
763
|
+
for (const filename of await safeJsonNames(join(directory, 'manifests'))) {
|
|
764
|
+
throwIfAborted(signal);
|
|
765
|
+
const path = join(directory, 'manifests', filename);
|
|
766
|
+
const manifest = parseManifest(await readJson(path));
|
|
767
|
+
this.assertManifestOwned(manifest);
|
|
768
|
+
await this.assertManifestWorkspaceIdentity(manifest, directory, true);
|
|
769
|
+
const isPrevious = manifest.workspace === previous && manifest.repository.root === previous;
|
|
770
|
+
const isCurrent = manifest.workspace === current && manifest.repository.root === current;
|
|
771
|
+
if (!isPrevious && !isCurrent) {
|
|
772
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} cannot be rebound from ${JSON.stringify(previous)}`);
|
|
773
|
+
}
|
|
774
|
+
if (isPrevious) {
|
|
775
|
+
await writeJsonAtomic(path, {
|
|
776
|
+
...manifest,
|
|
777
|
+
workspace: current,
|
|
778
|
+
repository: { ...manifest.repository, root: current },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
for (const filename of await safeJsonNames(join(directory, 'operations'))) {
|
|
783
|
+
throwIfAborted(signal);
|
|
784
|
+
const path = join(directory, 'operations', filename);
|
|
785
|
+
const operation = parseOperation(await readJson(path));
|
|
786
|
+
if (operation.workspace !== previous && operation.workspace !== current) {
|
|
787
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} cannot be rebound from ${JSON.stringify(previous)}`);
|
|
788
|
+
}
|
|
789
|
+
if (operation.workspace === previous)
|
|
790
|
+
await writeJsonAtomic(path, { ...operation, workspace: current });
|
|
791
|
+
}
|
|
792
|
+
for (const filename of await safeJsonNames(join(directory, 'git-journals'))) {
|
|
793
|
+
throwIfAborted(signal);
|
|
794
|
+
const path = join(directory, 'git-journals', filename);
|
|
795
|
+
const journal = parseGitCheckpointJournal(await readJson(path));
|
|
796
|
+
this.assertGitCheckpointJournalOwned(journal);
|
|
797
|
+
await this.assertGitCheckpointJournalWorkspaceIdentity(journal, directory, true);
|
|
798
|
+
if (journal.workspace !== previous && journal.workspace !== current) {
|
|
799
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal ${filename} cannot be rebound from ${JSON.stringify(previous)}`);
|
|
800
|
+
}
|
|
801
|
+
if (journal.workspace === previous)
|
|
802
|
+
await writeJsonAtomic(path, { ...journal, workspace: current });
|
|
803
|
+
}
|
|
804
|
+
const cleanup = await readSnapshotCleanupJournal(directory);
|
|
805
|
+
if (cleanup !== undefined) {
|
|
806
|
+
if (cleanup.workspace !== previous && cleanup.workspace !== current) {
|
|
807
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `snapshot cleanup journal cannot be rebound from ${JSON.stringify(previous)}`);
|
|
808
|
+
}
|
|
809
|
+
if (cleanup.workspace === previous) {
|
|
810
|
+
await writeJsonAtomic(join(directory, 'snapshot-cleanup.json'), { ...cleanup, workspace: current });
|
|
811
|
+
}
|
|
319
812
|
}
|
|
320
813
|
}
|
|
321
|
-
async
|
|
322
|
-
|
|
814
|
+
async prepareLockReclaim(lockPath, shared) {
|
|
815
|
+
let lock;
|
|
323
816
|
try {
|
|
324
|
-
|
|
325
|
-
if (processExists(lock.pid))
|
|
326
|
-
return true;
|
|
327
|
-
return Date.now() - lock.createdAt < this.config.staleLockMs;
|
|
817
|
+
lock = parseLock(await readJson(lockPath), !shared);
|
|
328
818
|
}
|
|
329
819
|
catch (error) {
|
|
330
|
-
if (
|
|
331
|
-
return
|
|
332
|
-
|
|
333
|
-
|
|
820
|
+
if (isMissingStateRead(error))
|
|
821
|
+
return true;
|
|
822
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', `${shared ? 'shared workspace' : 'workspace'} lock is unreadable and cannot be safely reclaimed: ${errorMessage(error)}`);
|
|
823
|
+
}
|
|
824
|
+
if (!this.lockIsReclaimable(lock))
|
|
825
|
+
return false;
|
|
826
|
+
const journal = { version: 1, staleNonce: lock.nonce, createdAt: Date.now() };
|
|
827
|
+
await publishExclusiveFile(lockReclaimPath(lockPath, lock.nonce), `${JSON.stringify(journal)}\n`);
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
async completeLockReclaims(lockPath, candidate, shared, reclaim) {
|
|
831
|
+
const journals = await lockReclaimJournalPaths(lockPath);
|
|
832
|
+
if (journals.length === 0)
|
|
833
|
+
return 'none';
|
|
834
|
+
if (!reclaim)
|
|
835
|
+
return 'busy';
|
|
836
|
+
for (const journalPath of journals) {
|
|
837
|
+
let journal;
|
|
838
|
+
try {
|
|
839
|
+
journal = parseLockReclaimJournal(await readJson(journalPath));
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
if (isMissingStateRead(error))
|
|
843
|
+
continue;
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
let current;
|
|
847
|
+
try {
|
|
848
|
+
current = parseLock(await readJson(lockPath), !shared);
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
if (!isMissingStateRead(error))
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
if (current !== undefined && current.nonce !== journal.staleNonce) {
|
|
855
|
+
await deleteLockReclaimState(journalPath, lockPath, journal.staleNonce);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (current !== undefined && !this.lockIsReclaimable(current))
|
|
859
|
+
return 'busy';
|
|
860
|
+
if (!await this.acquireLockReclaimOwnership(journalPath, candidate))
|
|
861
|
+
return 'busy';
|
|
334
862
|
try {
|
|
335
|
-
|
|
863
|
+
current = parseLock(await readJson(lockPath), !shared);
|
|
336
864
|
}
|
|
337
|
-
catch (
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
865
|
+
catch (error) {
|
|
866
|
+
if (isMissingStateRead(error))
|
|
867
|
+
current = undefined;
|
|
868
|
+
else
|
|
869
|
+
throw error;
|
|
341
870
|
}
|
|
871
|
+
if (current !== undefined && current.nonce !== journal.staleNonce) {
|
|
872
|
+
await deleteLockReclaimState(journalPath, lockPath, journal.staleNonce);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (current !== undefined && !this.lockIsReclaimable(current))
|
|
876
|
+
return 'busy';
|
|
877
|
+
if (current !== undefined) {
|
|
878
|
+
const quarantine = lockQuarantinePath(lockPath, journal.staleNonce);
|
|
879
|
+
try {
|
|
880
|
+
await rename(lockPath, quarantine);
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
886
|
+
let moved;
|
|
887
|
+
try {
|
|
888
|
+
moved = parseLock(await readJson(quarantine), !shared);
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
if (!isMissingStateRead(error))
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
if (moved !== undefined && moved.nonce !== journal.staleNonce) {
|
|
895
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'reclaim ownership changed before stale-lock quarantine');
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
await unlink(quarantine);
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
902
|
+
throw error;
|
|
903
|
+
}
|
|
904
|
+
await syncDirectory(dirname(lockPath));
|
|
905
|
+
}
|
|
906
|
+
if (await publishExclusiveFile(lockPath, `${JSON.stringify(candidate)}\n`)) {
|
|
907
|
+
await deleteLockReclaimState(journalPath, lockPath, journal.staleNonce);
|
|
908
|
+
return 'acquired';
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return (await lockReclaimJournalPaths(lockPath)).length === 0 ? 'none' : 'busy';
|
|
912
|
+
}
|
|
913
|
+
async acquireLockReclaimOwnership(journalPath, candidate) {
|
|
914
|
+
let parentNonce = 'root';
|
|
915
|
+
for (let depth = 0; depth < 32; depth += 1) {
|
|
916
|
+
const ownerPath = lockReclaimOwnerPath(journalPath, parentNonce);
|
|
917
|
+
let owner;
|
|
918
|
+
try {
|
|
919
|
+
owner = parseLockReclaimOwner(await readJson(ownerPath));
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
if (!isMissingStateRead(error))
|
|
923
|
+
throw error;
|
|
924
|
+
}
|
|
925
|
+
if (owner === undefined) {
|
|
926
|
+
const next = {
|
|
927
|
+
version: 1,
|
|
928
|
+
pid: process.pid,
|
|
929
|
+
hostId: hostIdentity(),
|
|
930
|
+
createdAt: Date.now(),
|
|
931
|
+
nonce: candidate.nonce,
|
|
932
|
+
};
|
|
933
|
+
if (await publishExclusiveFile(ownerPath, `${JSON.stringify(next)}\n`))
|
|
934
|
+
return true;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const successorPath = lockReclaimOwnerPath(journalPath, owner.nonce);
|
|
938
|
+
if (await pathExists(successorPath)) {
|
|
939
|
+
parentNonce = owner.nonce;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
if (owner.nonce === candidate.nonce && owner.pid === process.pid && owner.hostId === hostIdentity())
|
|
943
|
+
return true;
|
|
944
|
+
if (owner.hostId !== hostIdentity()
|
|
945
|
+
|| processExists(owner.pid)
|
|
946
|
+
|| Date.now() - owner.createdAt < this.config.staleLockMs)
|
|
947
|
+
return false;
|
|
948
|
+
parentNonce = owner.nonce;
|
|
342
949
|
}
|
|
950
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'lock reclaim owner chain is too deep');
|
|
951
|
+
}
|
|
952
|
+
lockIsReclaimable(lock) {
|
|
953
|
+
return lock.hostId !== undefined
|
|
954
|
+
&& lock.hostId === hostIdentity()
|
|
955
|
+
&& !processExists(lock.pid)
|
|
956
|
+
&& Date.now() - lock.createdAt >= this.config.staleLockMs;
|
|
343
957
|
}
|
|
344
958
|
}
|
|
345
|
-
function parseLock(value) {
|
|
959
|
+
function parseLock(value, allowLegacy = false) {
|
|
346
960
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
347
961
|
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock must be an object');
|
|
348
962
|
}
|
|
@@ -350,13 +964,570 @@ function parseLock(value) {
|
|
|
350
964
|
if (!Number.isSafeInteger(record.pid) || record.pid <= 0) {
|
|
351
965
|
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock pid is invalid');
|
|
352
966
|
}
|
|
967
|
+
if (record.hostId !== undefined && (typeof record.hostId !== 'string' || !/^[0-9a-f]{64}$/.test(record.hostId))) {
|
|
968
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock hostId is invalid');
|
|
969
|
+
}
|
|
970
|
+
if (!allowLegacy && record.hostId === undefined) {
|
|
971
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock hostId is missing');
|
|
972
|
+
}
|
|
353
973
|
if (!Number.isSafeInteger(record.createdAt) || record.createdAt < 0) {
|
|
354
974
|
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock createdAt is invalid');
|
|
355
975
|
}
|
|
356
976
|
if (typeof record.nonce !== 'string' || record.nonce === '') {
|
|
357
977
|
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock nonce is invalid');
|
|
358
978
|
}
|
|
359
|
-
return {
|
|
979
|
+
return {
|
|
980
|
+
pid: record.pid,
|
|
981
|
+
...(record.hostId === undefined ? {} : { hostId: record.hostId }),
|
|
982
|
+
createdAt: record.createdAt,
|
|
983
|
+
nonce: record.nonce,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
function parseLockReclaimJournal(value) {
|
|
987
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
988
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'lock reclaim journal must be an object');
|
|
989
|
+
}
|
|
990
|
+
const record = value;
|
|
991
|
+
if (record.version !== 1
|
|
992
|
+
|| typeof record.staleNonce !== 'string'
|
|
993
|
+
|| record.staleNonce === ''
|
|
994
|
+
|| !Number.isSafeInteger(record.createdAt)
|
|
995
|
+
|| record.createdAt < 0) {
|
|
996
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'lock reclaim journal is invalid');
|
|
997
|
+
}
|
|
998
|
+
return { version: 1, staleNonce: record.staleNonce, createdAt: record.createdAt };
|
|
999
|
+
}
|
|
1000
|
+
function parseLockReclaimOwner(value) {
|
|
1001
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1002
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'lock reclaim owner must be an object');
|
|
1003
|
+
}
|
|
1004
|
+
const record = value;
|
|
1005
|
+
if (record.version !== 1
|
|
1006
|
+
|| !Number.isSafeInteger(record.pid)
|
|
1007
|
+
|| record.pid <= 0
|
|
1008
|
+
|| typeof record.hostId !== 'string'
|
|
1009
|
+
|| !/^[0-9a-f]{64}$/.test(record.hostId)
|
|
1010
|
+
|| !Number.isSafeInteger(record.createdAt)
|
|
1011
|
+
|| record.createdAt < 0
|
|
1012
|
+
|| typeof record.nonce !== 'string'
|
|
1013
|
+
|| record.nonce === '') {
|
|
1014
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'lock reclaim owner is invalid');
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
version: 1,
|
|
1018
|
+
pid: record.pid,
|
|
1019
|
+
hostId: record.hostId,
|
|
1020
|
+
createdAt: record.createdAt,
|
|
1021
|
+
nonce: record.nonce,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
function lockReclaimPath(lockPath, staleNonce) {
|
|
1025
|
+
const key = createHash('sha256').update(staleNonce).digest('hex');
|
|
1026
|
+
return join(dirname(lockPath), `${basename(lockPath)}.reclaim-${key}.json`);
|
|
1027
|
+
}
|
|
1028
|
+
function lockReclaimOwnerPath(journalPath, parentNonce) {
|
|
1029
|
+
const key = createHash('sha256').update(parentNonce).digest('hex');
|
|
1030
|
+
return `${journalPath}.owner-${key}.json`;
|
|
1031
|
+
}
|
|
1032
|
+
function lockQuarantinePath(lockPath, staleNonce) {
|
|
1033
|
+
const key = createHash('sha256').update(staleNonce).digest('hex');
|
|
1034
|
+
return `${lockPath}.stale-${key}`;
|
|
1035
|
+
}
|
|
1036
|
+
async function lockReclaimJournalPaths(lockPath) {
|
|
1037
|
+
const directory = dirname(lockPath);
|
|
1038
|
+
const prefix = `${basename(lockPath)}.reclaim-`;
|
|
1039
|
+
const paths = [];
|
|
1040
|
+
for (const filename of await safeFileNames(directory)) {
|
|
1041
|
+
if (!filename.startsWith(prefix))
|
|
1042
|
+
continue;
|
|
1043
|
+
if (filename.includes('.json.owner-'))
|
|
1044
|
+
continue;
|
|
1045
|
+
const suffix = filename.slice(prefix.length);
|
|
1046
|
+
if (!/^[0-9a-f]{64}\.json$/.test(suffix)) {
|
|
1047
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', `lock reclaim journal filename is invalid: ${filename}`);
|
|
1048
|
+
}
|
|
1049
|
+
paths.push(join(directory, filename));
|
|
1050
|
+
}
|
|
1051
|
+
return paths.sort();
|
|
1052
|
+
}
|
|
1053
|
+
async function deleteLockReclaimState(journalPath, lockPath, staleNonce) {
|
|
1054
|
+
const ownerPrefix = `${basename(journalPath)}.owner-`;
|
|
1055
|
+
for (const filename of await safeFileNames(dirname(journalPath))) {
|
|
1056
|
+
if (!filename.startsWith(ownerPrefix))
|
|
1057
|
+
continue;
|
|
1058
|
+
try {
|
|
1059
|
+
await unlink(join(dirname(journalPath), filename));
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
for (const path of [lockQuarantinePath(lockPath, staleNonce), journalPath]) {
|
|
1067
|
+
try {
|
|
1068
|
+
await unlink(path);
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
1072
|
+
throw error;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
await syncDirectory(dirname(journalPath));
|
|
1076
|
+
}
|
|
1077
|
+
function hostIdentity() {
|
|
1078
|
+
if (hostIdValue !== undefined)
|
|
1079
|
+
return hostIdValue;
|
|
1080
|
+
const configured = process.env.DSH_TURN_REWIND_HOST_ID;
|
|
1081
|
+
if (configured !== undefined) {
|
|
1082
|
+
if (!/^[0-9a-f]{64}$/.test(configured)) {
|
|
1083
|
+
throw new ChangeLedgerError('HOST_ID_UNAVAILABLE', 'DSH_TURN_REWIND_HOST_ID must be 64 lowercase hexadecimal characters');
|
|
1084
|
+
}
|
|
1085
|
+
hostIdValue = configured;
|
|
1086
|
+
return configured;
|
|
1087
|
+
}
|
|
1088
|
+
let machine = '';
|
|
1089
|
+
try {
|
|
1090
|
+
if (platform() === 'darwin') {
|
|
1091
|
+
const output = execFileSync('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
|
|
1092
|
+
encoding: 'utf8', timeout: 1_000, maxBuffer: 64 * 1024,
|
|
1093
|
+
});
|
|
1094
|
+
machine = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? '';
|
|
1095
|
+
}
|
|
1096
|
+
else if (platform() === 'win32') {
|
|
1097
|
+
const output = execFileSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], {
|
|
1098
|
+
encoding: 'utf8', timeout: 1_000, maxBuffer: 64 * 1024,
|
|
1099
|
+
});
|
|
1100
|
+
machine = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/)?.[1] ?? '';
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
for (const path of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
1104
|
+
try {
|
|
1105
|
+
machine = readFileSync(path, 'utf8').trim();
|
|
1106
|
+
if (machine !== '')
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
// Try the next machine-id location.
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
// Fall through to the fail-closed check below.
|
|
1117
|
+
}
|
|
1118
|
+
if (machine === '') {
|
|
1119
|
+
throw new ChangeLedgerError('HOST_ID_UNAVAILABLE', 'a stable operating-system machine identity is unavailable; configure DSH_TURN_REWIND_HOST_ID explicitly');
|
|
1120
|
+
}
|
|
1121
|
+
hostIdValue = createHash('sha256').update(JSON.stringify({ machine, platform: platform(), arch: arch() })).digest('hex');
|
|
1122
|
+
return hostIdValue;
|
|
1123
|
+
}
|
|
1124
|
+
function parseGitCheckpointJournal(value) {
|
|
1125
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1126
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal must be an object');
|
|
1127
|
+
}
|
|
1128
|
+
const record = value;
|
|
1129
|
+
if (record.version !== 1 || (record.action !== 'publish' && record.action !== 'delete')) {
|
|
1130
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal version or action is invalid');
|
|
1131
|
+
}
|
|
1132
|
+
if (typeof record.storeId !== 'string' || !/^[0-9a-f]{32}$/.test(record.storeId)) {
|
|
1133
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal storeId is invalid');
|
|
1134
|
+
}
|
|
1135
|
+
if (typeof record.workspace !== 'string' || !isAbsolute(record.workspace)) {
|
|
1136
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal workspace is invalid');
|
|
1137
|
+
}
|
|
1138
|
+
if (typeof record.commonDir !== 'string' || !isAbsolute(record.commonDir)) {
|
|
1139
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal commonDir is invalid');
|
|
1140
|
+
}
|
|
1141
|
+
if (typeof record.worktreeId !== 'string' || !/^[0-9a-f]{32}$/.test(record.worktreeId)) {
|
|
1142
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal worktreeId is invalid');
|
|
1143
|
+
}
|
|
1144
|
+
if (typeof record.restorePointId !== 'string') {
|
|
1145
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal restore-point id is invalid');
|
|
1146
|
+
}
|
|
1147
|
+
validateRestorePointId(record.restorePointId);
|
|
1148
|
+
if (record.ref !== `refs/dsh-turn-rewind/v2/${record.storeId}/${record.worktreeId}/${record.restorePointId}`) {
|
|
1149
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal ref is invalid');
|
|
1150
|
+
}
|
|
1151
|
+
if (typeof record.commit !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(record.commit)) {
|
|
1152
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal commit is invalid');
|
|
1153
|
+
}
|
|
1154
|
+
if (!Number.isSafeInteger(record.createdAt) || record.createdAt < 0) {
|
|
1155
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git checkpoint journal createdAt is invalid');
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
version: 1,
|
|
1159
|
+
action: record.action,
|
|
1160
|
+
storeId: record.storeId,
|
|
1161
|
+
workspace: record.workspace,
|
|
1162
|
+
commonDir: record.commonDir,
|
|
1163
|
+
worktreeId: record.worktreeId,
|
|
1164
|
+
restorePointId: record.restorePointId,
|
|
1165
|
+
ref: record.ref,
|
|
1166
|
+
commit: record.commit,
|
|
1167
|
+
createdAt: record.createdAt,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
function parseTurnCheckpointSkip(value) {
|
|
1171
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1172
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'turn checkpoint skip must be an object');
|
|
1173
|
+
}
|
|
1174
|
+
const record = value;
|
|
1175
|
+
if (record.version !== 1 || typeof record.sessionId !== 'string' || record.sessionId === '') {
|
|
1176
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'turn checkpoint skip identity is invalid');
|
|
1177
|
+
}
|
|
1178
|
+
if (!Number.isSafeInteger(record.turn) || record.turn < 0
|
|
1179
|
+
|| !Number.isSafeInteger(record.turnStartSeq) || record.turnStartSeq < 0
|
|
1180
|
+
|| !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) {
|
|
1181
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'turn checkpoint skip timing metadata is invalid');
|
|
1182
|
+
}
|
|
1183
|
+
if (typeof record.reason !== 'string' || record.reason === '' || record.reason.length > 2_000) {
|
|
1184
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'turn checkpoint skip reason is invalid');
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
version: 1,
|
|
1188
|
+
sessionId: record.sessionId,
|
|
1189
|
+
turn: record.turn,
|
|
1190
|
+
turnStartSeq: record.turnStartSeq,
|
|
1191
|
+
reason: record.reason,
|
|
1192
|
+
createdAt: record.createdAt,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
async function readSnapshotCleanupJournal(directory) {
|
|
1196
|
+
let value;
|
|
1197
|
+
try {
|
|
1198
|
+
value = await readJson(join(directory, 'snapshot-cleanup.json'));
|
|
1199
|
+
}
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
if (isMissingStateRead(error))
|
|
1202
|
+
return undefined;
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1205
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1206
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'snapshot cleanup journal must be an object');
|
|
1207
|
+
}
|
|
1208
|
+
const record = value;
|
|
1209
|
+
if (record.version !== 1 || typeof record.workspace !== 'string' || !isAbsolute(record.workspace)) {
|
|
1210
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'snapshot cleanup journal is invalid');
|
|
1211
|
+
}
|
|
1212
|
+
return { version: 1, workspace: record.workspace };
|
|
1213
|
+
}
|
|
1214
|
+
async function readWorkspaceBinding(directory, workspaceKey) {
|
|
1215
|
+
let value;
|
|
1216
|
+
try {
|
|
1217
|
+
value = await readJson(join(directory, 'workspace-binding.json'));
|
|
1218
|
+
}
|
|
1219
|
+
catch (error) {
|
|
1220
|
+
if (isMissingStateRead(error))
|
|
1221
|
+
return undefined;
|
|
1222
|
+
throw error;
|
|
1223
|
+
}
|
|
1224
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1225
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'workspace binding must be an object');
|
|
1226
|
+
}
|
|
1227
|
+
const record = value;
|
|
1228
|
+
if (record.version !== 1
|
|
1229
|
+
|| typeof record.workspace !== 'string'
|
|
1230
|
+
|| !isAbsolute(record.workspace)
|
|
1231
|
+
|| typeof record.commonDir !== 'string'
|
|
1232
|
+
|| !isAbsolute(record.commonDir)
|
|
1233
|
+
|| typeof record.gitDir !== 'string'
|
|
1234
|
+
|| !isAbsolute(record.gitDir)
|
|
1235
|
+
|| typeof record.worktreeId !== 'string'
|
|
1236
|
+
|| !/^[0-9a-f]{32}$/.test(record.worktreeId)) {
|
|
1237
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'workspace binding is invalid');
|
|
1238
|
+
}
|
|
1239
|
+
if (gitWorkspaceKey(record.commonDir, record.worktreeId) !== workspaceKey) {
|
|
1240
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `workspace binding is stored under the wrong key ${workspaceKey}`);
|
|
1241
|
+
}
|
|
1242
|
+
return {
|
|
1243
|
+
version: 1,
|
|
1244
|
+
workspace: record.workspace,
|
|
1245
|
+
commonDir: record.commonDir,
|
|
1246
|
+
gitDir: record.gitDir,
|
|
1247
|
+
worktreeId: record.worktreeId,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
async function readWorkspaceRebindJournal(directory, workspaceKey) {
|
|
1251
|
+
let value;
|
|
1252
|
+
try {
|
|
1253
|
+
value = await readJson(join(directory, 'workspace-rebind.json'));
|
|
1254
|
+
}
|
|
1255
|
+
catch (error) {
|
|
1256
|
+
if (isMissingStateRead(error))
|
|
1257
|
+
return undefined;
|
|
1258
|
+
throw error;
|
|
1259
|
+
}
|
|
1260
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1261
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'workspace rebind journal must be an object');
|
|
1262
|
+
}
|
|
1263
|
+
const record = value;
|
|
1264
|
+
if (record.version !== 1
|
|
1265
|
+
|| typeof record.previousWorkspace !== 'string'
|
|
1266
|
+
|| !isAbsolute(record.previousWorkspace)
|
|
1267
|
+
|| typeof record.currentWorkspace !== 'string'
|
|
1268
|
+
|| !isAbsolute(record.currentWorkspace)
|
|
1269
|
+
|| record.previousWorkspace === record.currentWorkspace
|
|
1270
|
+
|| typeof record.commonDir !== 'string'
|
|
1271
|
+
|| !isAbsolute(record.commonDir)
|
|
1272
|
+
|| typeof record.worktreeId !== 'string'
|
|
1273
|
+
|| !/^[0-9a-f]{32}$/.test(record.worktreeId)) {
|
|
1274
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'workspace rebind journal is invalid');
|
|
1275
|
+
}
|
|
1276
|
+
if (gitWorkspaceKey(record.commonDir, record.worktreeId) !== workspaceKey) {
|
|
1277
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `workspace rebind journal is stored under the wrong key ${workspaceKey}`);
|
|
1278
|
+
}
|
|
1279
|
+
return {
|
|
1280
|
+
version: 1,
|
|
1281
|
+
previousWorkspace: record.previousWorkspace,
|
|
1282
|
+
currentWorkspace: record.currentWorkspace,
|
|
1283
|
+
commonDir: record.commonDir,
|
|
1284
|
+
worktreeId: record.worktreeId,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
async function readWorkspaceMigrationJournal(workspacesDir, workspaceKey) {
|
|
1288
|
+
let value;
|
|
1289
|
+
try {
|
|
1290
|
+
value = await readJson(workspaceMigrationPath(workspacesDir, workspaceKey));
|
|
1291
|
+
}
|
|
1292
|
+
catch (error) {
|
|
1293
|
+
if (isMissingStateRead(error))
|
|
1294
|
+
return undefined;
|
|
1295
|
+
throw error;
|
|
1296
|
+
}
|
|
1297
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1298
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'workspace migration journal must be an object');
|
|
1299
|
+
}
|
|
1300
|
+
const record = value;
|
|
1301
|
+
if (record.version !== 1
|
|
1302
|
+
|| typeof record.workspace !== 'string'
|
|
1303
|
+
|| !isAbsolute(record.workspace)
|
|
1304
|
+
|| typeof record.legacyKey !== 'string'
|
|
1305
|
+
|| !/^[0-9a-f]{64}$/.test(record.legacyKey)
|
|
1306
|
+
|| typeof record.targetKey !== 'string'
|
|
1307
|
+
|| !/^git-[0-9a-f]{64}$/.test(record.targetKey)
|
|
1308
|
+
|| record.targetKey !== workspaceKey
|
|
1309
|
+
|| typeof record.commonDir !== 'string'
|
|
1310
|
+
|| !isAbsolute(record.commonDir)
|
|
1311
|
+
|| typeof record.gitDir !== 'string'
|
|
1312
|
+
|| !isAbsolute(record.gitDir)
|
|
1313
|
+
|| typeof record.worktreeId !== 'string'
|
|
1314
|
+
|| !/^[0-9a-f]{32}$/.test(record.worktreeId)
|
|
1315
|
+
|| gitWorkspaceKey(record.commonDir, record.worktreeId) !== workspaceKey
|
|
1316
|
+
|| createHash('sha256').update(record.workspace).digest('hex') !== record.legacyKey) {
|
|
1317
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'workspace migration journal is invalid');
|
|
1318
|
+
}
|
|
1319
|
+
return {
|
|
1320
|
+
version: 1,
|
|
1321
|
+
workspace: record.workspace,
|
|
1322
|
+
legacyKey: record.legacyKey,
|
|
1323
|
+
targetKey: record.targetKey,
|
|
1324
|
+
commonDir: record.commonDir,
|
|
1325
|
+
gitDir: record.gitDir,
|
|
1326
|
+
worktreeId: record.worktreeId,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
async function workspaceMigrationKeys(workspacesDir) {
|
|
1330
|
+
const keys = [];
|
|
1331
|
+
for (const filename of await safeFileNames(workspacesDir)) {
|
|
1332
|
+
if (!filename.startsWith('.workspace-migration-') || !filename.endsWith('.json'))
|
|
1333
|
+
continue;
|
|
1334
|
+
const workspaceKey = filename.slice('.workspace-migration-'.length, -'.json'.length);
|
|
1335
|
+
if (!/^git-[0-9a-f]{64}$/.test(workspaceKey)) {
|
|
1336
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `workspace migration filename is invalid: ${filename}`);
|
|
1337
|
+
}
|
|
1338
|
+
keys.push(workspaceKey);
|
|
1339
|
+
}
|
|
1340
|
+
return keys.sort();
|
|
1341
|
+
}
|
|
1342
|
+
function workspaceMigrationPath(workspacesDir, workspaceKey) {
|
|
1343
|
+
if (!/^git-[0-9a-f]{64}$/.test(workspaceKey)) {
|
|
1344
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `workspace migration key is invalid: ${workspaceKey}`);
|
|
1345
|
+
}
|
|
1346
|
+
return join(workspacesDir, `.workspace-migration-${workspaceKey}.json`);
|
|
1347
|
+
}
|
|
1348
|
+
function worktreeClaimPath(claimsDir, worktreeId) {
|
|
1349
|
+
if (!/^[0-9a-f]{32}$/.test(worktreeId)) {
|
|
1350
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `worktree claim identity is invalid: ${worktreeId}`);
|
|
1351
|
+
}
|
|
1352
|
+
return join(claimsDir, `${worktreeId}.json`);
|
|
1353
|
+
}
|
|
1354
|
+
async function worktreeClaimIds(claimsDir) {
|
|
1355
|
+
const ids = [];
|
|
1356
|
+
for (const filename of await safeFileNames(claimsDir)) {
|
|
1357
|
+
if (filename.startsWith('.') && filename.endsWith('.tmp'))
|
|
1358
|
+
continue;
|
|
1359
|
+
if (!/^[0-9a-f]{32}\.json$/.test(filename)) {
|
|
1360
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `worktree claim filename is invalid: ${filename}`);
|
|
1361
|
+
}
|
|
1362
|
+
ids.push(filename.slice(0, -'.json'.length));
|
|
1363
|
+
}
|
|
1364
|
+
return ids.sort();
|
|
1365
|
+
}
|
|
1366
|
+
async function readWorktreeClaim(claimsDir, worktreeId) {
|
|
1367
|
+
let value;
|
|
1368
|
+
try {
|
|
1369
|
+
value = await readJson(worktreeClaimPath(claimsDir, worktreeId));
|
|
1370
|
+
}
|
|
1371
|
+
catch (error) {
|
|
1372
|
+
if (isMissingStateRead(error))
|
|
1373
|
+
return undefined;
|
|
1374
|
+
throw error;
|
|
1375
|
+
}
|
|
1376
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1377
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'worktree claim must be an object');
|
|
1378
|
+
}
|
|
1379
|
+
const record = value;
|
|
1380
|
+
if (record.version !== 1
|
|
1381
|
+
|| record.worktreeId !== worktreeId
|
|
1382
|
+
|| typeof record.workspaceKey !== 'string'
|
|
1383
|
+
|| !/^git-[0-9a-f]{64}$/.test(record.workspaceKey)
|
|
1384
|
+
|| typeof record.commonDir !== 'string'
|
|
1385
|
+
|| !isAbsolute(record.commonDir)
|
|
1386
|
+
|| typeof record.gitDir !== 'string'
|
|
1387
|
+
|| !isAbsolute(record.gitDir)
|
|
1388
|
+
|| gitWorkspaceKey(record.commonDir, worktreeId) !== record.workspaceKey) {
|
|
1389
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `worktree claim ${worktreeId} is invalid`);
|
|
1390
|
+
}
|
|
1391
|
+
return {
|
|
1392
|
+
version: 1,
|
|
1393
|
+
worktreeId,
|
|
1394
|
+
workspaceKey: record.workspaceKey,
|
|
1395
|
+
commonDir: record.commonDir,
|
|
1396
|
+
gitDir: record.gitDir,
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
async function deleteWorkspaceMigration(workspacesDir, workspaceKey) {
|
|
1400
|
+
try {
|
|
1401
|
+
await unlink(workspaceMigrationPath(workspacesDir, workspaceKey));
|
|
1402
|
+
}
|
|
1403
|
+
catch (error) {
|
|
1404
|
+
if (isNodeError(error, 'ENOENT'))
|
|
1405
|
+
return;
|
|
1406
|
+
throw error;
|
|
1407
|
+
}
|
|
1408
|
+
await syncDirectory(workspacesDir);
|
|
1409
|
+
}
|
|
1410
|
+
/** Create or read the durable owner of one storage root's private Git namespace. */
|
|
1411
|
+
export async function ensureStoreId(storageDir) {
|
|
1412
|
+
await mkdir(storageDir, { recursive: true, mode: 0o700 });
|
|
1413
|
+
const primaryPath = join(storageDir, 'store-id');
|
|
1414
|
+
const repairPath = join(storageDir, 'store-id.repair');
|
|
1415
|
+
const [primary, repair] = await Promise.all([readStoreIdCopy(primaryPath), readStoreIdCopy(repairPath)]);
|
|
1416
|
+
const valid = [primary.value, repair.value].filter((value) => value !== undefined);
|
|
1417
|
+
if (new Set(valid).size > 1)
|
|
1418
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'store-id copies disagree');
|
|
1419
|
+
const durable = valid[0];
|
|
1420
|
+
if (durable !== undefined) {
|
|
1421
|
+
if (await publishStoreIdFile(repairPath, durable, true) !== durable
|
|
1422
|
+
|| await publishStoreIdFile(primaryPath, durable, true) !== durable) {
|
|
1423
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'store-id copies disagree during repair');
|
|
1424
|
+
}
|
|
1425
|
+
return durable;
|
|
1426
|
+
}
|
|
1427
|
+
if (repair.malformed) {
|
|
1428
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'store-id repair copy is malformed');
|
|
1429
|
+
}
|
|
1430
|
+
const containsV2 = await storageContainsV2State(storageDir);
|
|
1431
|
+
if (containsV2) {
|
|
1432
|
+
throw new ChangeLedgerError('STATE_CORRUPT', primary.malformed
|
|
1433
|
+
? 'store-id is malformed in a storage root that already contains Git-native checkpoints'
|
|
1434
|
+
: 'store-id is missing from a storage root that already contains Git-native checkpoints');
|
|
1435
|
+
}
|
|
1436
|
+
const candidate = randomUUID().replaceAll('-', '');
|
|
1437
|
+
const chosen = await publishStoreIdFile(repairPath, candidate, false);
|
|
1438
|
+
if (await publishStoreIdFile(primaryPath, chosen, true) !== chosen) {
|
|
1439
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'store-id copies disagree during initialization');
|
|
1440
|
+
}
|
|
1441
|
+
return chosen;
|
|
1442
|
+
}
|
|
1443
|
+
async function readStoreIdCopy(path) {
|
|
1444
|
+
try {
|
|
1445
|
+
const value = (await readFile(path, 'utf8')).trim();
|
|
1446
|
+
return /^[0-9a-f]{32}$/.test(value) ? { value, malformed: false } : { malformed: true };
|
|
1447
|
+
}
|
|
1448
|
+
catch (error) {
|
|
1449
|
+
if (isNodeError(error, 'ENOENT'))
|
|
1450
|
+
return { malformed: false };
|
|
1451
|
+
throw error;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
async function publishStoreIdFile(path, value, repairMalformed) {
|
|
1455
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1456
|
+
if (await publishExclusiveFile(path, `${value}\n`))
|
|
1457
|
+
return value;
|
|
1458
|
+
const existing = await readStoreIdCopy(path);
|
|
1459
|
+
if (existing.value !== undefined)
|
|
1460
|
+
return existing.value;
|
|
1461
|
+
if (!existing.malformed)
|
|
1462
|
+
continue;
|
|
1463
|
+
if (!repairMalformed)
|
|
1464
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `store-id copy is malformed at ${JSON.stringify(path)}`);
|
|
1465
|
+
try {
|
|
1466
|
+
await unlink(path);
|
|
1467
|
+
await syncDirectory(dirname(path));
|
|
1468
|
+
}
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
1471
|
+
throw error;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `could not repair store-id copy at ${JSON.stringify(path)}`);
|
|
1475
|
+
}
|
|
1476
|
+
async function storageContainsV2State(storageDir) {
|
|
1477
|
+
if ((await worktreeClaimIds(join(storageDir, 'worktree-claims'))).length !== 0)
|
|
1478
|
+
return true;
|
|
1479
|
+
const workspacesDir = join(storageDir, 'workspaces');
|
|
1480
|
+
for (const workspaceKey of await safeDirectoryNames(workspacesDir)) {
|
|
1481
|
+
const workspaceDir = join(workspacesDir, workspaceKey);
|
|
1482
|
+
if (await pathExists(join(workspaceDir, 'git-cache.json')))
|
|
1483
|
+
return true;
|
|
1484
|
+
if ((await safeFileNames(join(workspaceDir, 'git-journals'))).length !== 0)
|
|
1485
|
+
return true;
|
|
1486
|
+
for (const filename of await safeJsonNames(join(workspaceDir, 'manifests'))) {
|
|
1487
|
+
const value = await readJson(join(workspaceDir, 'manifests', filename));
|
|
1488
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value) && value.version === 2) {
|
|
1489
|
+
return true;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
function gitWorkspaceKey(commonDir, worktreeId) {
|
|
1496
|
+
return `git-${createHash('sha256').update(`${commonDir}\0${worktreeId}`).digest('hex')}`;
|
|
1497
|
+
}
|
|
1498
|
+
async function publishExclusiveFile(path, body) {
|
|
1499
|
+
const directory = dirname(path);
|
|
1500
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
1501
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
1502
|
+
try {
|
|
1503
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
1504
|
+
try {
|
|
1505
|
+
await handle.writeFile(body);
|
|
1506
|
+
await handle.sync();
|
|
1507
|
+
}
|
|
1508
|
+
finally {
|
|
1509
|
+
await handle.close();
|
|
1510
|
+
}
|
|
1511
|
+
try {
|
|
1512
|
+
await link(temporary, path);
|
|
1513
|
+
await syncDirectory(directory);
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
catch (error) {
|
|
1517
|
+
if (!isNodeError(error, 'EEXIST'))
|
|
1518
|
+
throw error;
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
finally {
|
|
1523
|
+
try {
|
|
1524
|
+
await unlink(temporary);
|
|
1525
|
+
}
|
|
1526
|
+
catch (error) {
|
|
1527
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
1528
|
+
throw error;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
360
1531
|
}
|
|
361
1532
|
async function safeJsonNames(path) {
|
|
362
1533
|
return (await safeFileNames(path)).filter((name) => name.endsWith('.json')).sort();
|
|
@@ -389,3 +1560,7 @@ function isMissingCause(error) {
|
|
|
389
1560
|
function isMissingStateRead(error) {
|
|
390
1561
|
return error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error);
|
|
391
1562
|
}
|
|
1563
|
+
function throwIfAborted(signal) {
|
|
1564
|
+
if (signal?.aborted === true)
|
|
1565
|
+
throw signal.reason;
|
|
1566
|
+
}
|