@anionex/dsh-turn-rewind 0.1.0 → 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/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 { link, mkdir, open, readFile, readdir, rmdir, stat, unlink } from 'node:fs/promises';
3
- import { join } from 'node:path';
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, openExclusive, processExists, readJson, syncDirectory, writeJsonAtomic } from './path-utils.js';
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 mkdir(join(this.config.storageDir, 'workspaces'), { recursive: true, mode: 0o700 });
16
- let reconciled = 0;
17
- for (const workspaceKey of await safeDirectoryNames(join(this.config.storageDir, 'workspaces'))) {
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
- if (await this.workspaceAppearsActive(workspaceDir))
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
- handle = await openExclusive(lockPath);
30
+ lease = await this.acquireOne(join(workspaceDir, 'lock.json'), workspaceKey, false, true);
52
31
  }
53
32
  catch (error) {
54
- if (!isNodeError(error, 'EEXIST'))
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 new ChangeLedgerError('WORKSPACE_LOCKED', `another change-ledger operation owns ${JSON.stringify(workspace)}`);
35
+ throw error;
60
36
  }
61
- const record = { pid: process.pid, createdAt: Date.now(), nonce };
62
37
  try {
63
- await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8');
64
- await handle.sync();
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 handle.close();
71
+ await lease.release();
68
72
  }
69
- await syncDirectory(this.workspaceDir(workspace));
70
- let released = false;
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
- if (released)
73
- return;
74
- released = true;
96
+ let releaseError;
75
97
  try {
76
- const current = parseLock(await readJson(lockPath));
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
- if (!isNodeError(error, 'ENOENT') && !isMissingStateRead(error))
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 reclaimStaleLock(lockPath) {
284
- let lock;
285
- try {
286
- lock = parseLock(await readJson(lockPath));
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
- catch (error) {
289
- let age = 0;
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
- age = Date.now() - (await stat(lockPath)).mtimeMs;
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 (statError) {
294
- if (isNodeError(statError, 'ENOENT'))
295
- return true;
296
- throw statError;
567
+ catch (error) {
568
+ await localLease.release();
569
+ throw error;
297
570
  }
298
- if (age < this.config.staleLockMs) {
299
- throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', `active lock is unreadable: ${errorMessage(error)}`);
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
- if (processExists(lock.pid))
305
- return false;
306
- if (Date.now() - lock.createdAt < this.config.staleLockMs)
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
- const current = parseLock(await readJson(lockPath));
310
- if (current.nonce !== lock.nonce)
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
- await unlink(lockPath);
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
- return true;
318
- throw error;
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 workspaceAppearsActive(workspaceDir) {
322
- const lockPath = join(workspaceDir, 'lock.json');
814
+ async prepareLockReclaim(lockPath, shared) {
815
+ let lock;
323
816
  try {
324
- const lock = parseLock(await readJson(lockPath));
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 (error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error.cause))
331
- return false;
332
- if (isNodeError(error, 'ENOENT'))
333
- return false;
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
- return Date.now() - (await stat(lockPath)).mtimeMs < this.config.staleLockMs;
863
+ current = parseLock(await readJson(lockPath), !shared);
336
864
  }
337
- catch (statError) {
338
- if (isNodeError(statError, 'ENOENT'))
339
- return false;
340
- throw statError;
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 { pid: record.pid, createdAt: record.createdAt, nonce: record.nonce };
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
+ }