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