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