@anionex/dsh-turn-rewind 0.1.1 → 0.2.0

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