@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/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/lib/client.js +3 -1
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +482 -118
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +303 -23
- package/lib/index.js +1 -1
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +119 -29
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1281 -106
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +27 -0
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +3 -1
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +70 -6
- package/lib/types/types.d.ts +44 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +3 -1
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +3 -0
- package/src/deadline.ts +18 -0
- package/src/engine.ts +542 -124
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +345 -23
- package/src/index.ts +1 -1
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +134 -30
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1403 -91
- package/src/types.ts +48 -1
- package/src/validate.ts +67 -7
package/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 {
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { link, mkdir, open, readFile, readdir, rename, rmdir, unlink } from 'node:fs/promises'
|
|
5
|
+
import { arch, platform } from 'node:os'
|
|
6
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
4
7
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
5
|
-
import { isNodeError,
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
await
|
|
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(
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await
|
|
67
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
await
|
|
310
|
-
|
|
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 (
|
|
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
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
} catch (
|
|
329
|
-
if (
|
|
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 {
|
|
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
|
+
}
|