@anionex/dsh-turn-rewind 0.1.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 (45) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +182 -0
  3. package/README.zh.md +180 -0
  4. package/SECURITY.md +53 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/FORMAT.md +101 -0
  7. package/docs/IMPLEMENTATION_PLAN.md +56 -0
  8. package/docs/assets/turn-rewind-action.png +0 -0
  9. package/docs/assets/turn-rewind-dialog.png +0 -0
  10. package/lib/client.js +473 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/engine.js +616 -0
  13. package/lib/errors.js +20 -0
  14. package/lib/git.js +155 -0
  15. package/lib/index.js +71 -0
  16. package/lib/path-utils.js +262 -0
  17. package/lib/rewind-host.js +421 -0
  18. package/lib/snapshot.js +183 -0
  19. package/lib/store.js +391 -0
  20. package/lib/types/client/index.d.ts +81 -0
  21. package/lib/types/engine.d.ts +90 -0
  22. package/lib/types/errors.d.ts +15 -0
  23. package/lib/types/git.d.ts +14 -0
  24. package/lib/types/index.d.ts +43 -0
  25. package/lib/types/path-utils.d.ts +34 -0
  26. package/lib/types/rewind-host.d.ts +137 -0
  27. package/lib/types/snapshot.d.ts +35 -0
  28. package/lib/types/store.d.ts +39 -0
  29. package/lib/types/types.d.ts +177 -0
  30. package/lib/types/validate.d.ts +11 -0
  31. package/lib/types.js +2 -0
  32. package/lib/validate.js +242 -0
  33. package/package.json +91 -0
  34. package/src/client/index.tsx +648 -0
  35. package/src/client/platform.d.ts +27 -0
  36. package/src/engine.ts +770 -0
  37. package/src/errors.ts +23 -0
  38. package/src/git.ts +178 -0
  39. package/src/index.ts +100 -0
  40. package/src/path-utils.ts +255 -0
  41. package/src/rewind-host.ts +584 -0
  42. package/src/snapshot.ts +229 -0
  43. package/src/store.ts +383 -0
  44. package/src/types.ts +195 -0
  45. package/src/validate.ts +245 -0
@@ -0,0 +1,229 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { BigIntStats } from 'node:fs'
3
+ import { lstat, readFile, readlink } from 'node:fs/promises'
4
+ import { ChangeLedgerError } from './errors.js'
5
+ import { discoverRepository, sameRepositoryFence, type RepositorySnapshotSource } from './git.js'
6
+ import { isNodeError, resolveWorkspacePath } from './path-utils.js'
7
+ import type { LedgerStore } from './store.js'
8
+ import type {
9
+ ResolvedChangeLedgerConfig,
10
+ SnapshotEntry,
11
+ WorkspaceChange,
12
+ } from './types.js'
13
+
14
+ /** One captured tree, optionally persisted into the blob store. */
15
+ export interface CapturedTree {
16
+ readonly source: RepositorySnapshotSource
17
+ readonly entries: Readonly<Record<string, SnapshotEntry>>
18
+ readonly treeHash: string
19
+ readonly fileCount: number
20
+ readonly totalBytes: number
21
+ }
22
+
23
+ /** Capture the current tracked and non-ignored Git working tree. */
24
+ export async function captureTree(options: {
25
+ readonly cwd: string
26
+ readonly config: ResolvedChangeLedgerConfig
27
+ readonly store?: LedgerStore
28
+ readonly signal?: AbortSignal
29
+ }): Promise<CapturedTree> {
30
+ throwIfAborted(options.signal)
31
+ const source = await discoverRepository(options.cwd, options.signal)
32
+ if (source.paths.length > options.config.maxFiles) {
33
+ throw new ChangeLedgerError(
34
+ 'TOO_MANY_FILES',
35
+ `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`,
36
+ )
37
+ }
38
+
39
+ const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
40
+ let totalBytes = 0
41
+ for (const path of source.paths) {
42
+ throwIfAborted(options.signal)
43
+ const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal)
44
+ if (entry === undefined) continue
45
+ if (entry.kind === 'file') {
46
+ totalBytes += entry.content.length
47
+ if (totalBytes > options.config.maxSnapshotBytes) {
48
+ throw new ChangeLedgerError(
49
+ 'SNAPSHOT_TOO_LARGE',
50
+ `eligible files exceed configured aggregate limit of ${options.config.maxSnapshotBytes} bytes`,
51
+ )
52
+ }
53
+ if (options.store !== undefined) {
54
+ await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content)
55
+ }
56
+ entries[path] = entry.snapshot
57
+ continue
58
+ }
59
+ entries[path] = entry.snapshot
60
+ }
61
+
62
+ return {
63
+ source,
64
+ entries,
65
+ treeHash: hashTree(entries),
66
+ fileCount: Object.keys(entries).length,
67
+ totalBytes,
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Capture the complete tree twice and accept it only when both path/content and
73
+ * repository fences agree. This prevents a point from silently mixing files
74
+ * observed at incompatible moments while another process is editing the tree.
75
+ */
76
+ export async function captureStableTree(options: {
77
+ readonly cwd: string
78
+ readonly config: ResolvedChangeLedgerConfig
79
+ readonly store?: LedgerStore
80
+ readonly signal?: AbortSignal
81
+ }): Promise<CapturedTree> {
82
+ for (let attempt = 0; attempt < 3; attempt += 1) {
83
+ const first = await captureTree({
84
+ cwd: options.cwd,
85
+ config: options.config,
86
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
87
+ })
88
+ const second = await captureTree(options)
89
+ if (first.treeHash === second.treeHash
90
+ && sameRepositoryFence(first.source.state, second.source.state)
91
+ && arraysEqual(first.source.state.stagedPaths, second.source.state.stagedPaths)
92
+ && arraysEqual(first.source.paths, second.source.paths)) {
93
+ return second
94
+ }
95
+ }
96
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures')
97
+ }
98
+
99
+ /** Compute stable path-level differences between two captured trees. */
100
+ export function diffTrees(
101
+ before: Readonly<Record<string, SnapshotEntry>>,
102
+ after: Readonly<Record<string, SnapshotEntry>>,
103
+ ): WorkspaceChange[] {
104
+ const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths)
105
+ const changes: WorkspaceChange[] = []
106
+ for (const path of paths) {
107
+ const left = before[path]
108
+ const right = after[path]
109
+ if (left === undefined && right !== undefined) {
110
+ changes.push({ path, kind: 'added', after: right })
111
+ continue
112
+ }
113
+ if (left !== undefined && right === undefined) {
114
+ changes.push({ path, kind: 'deleted', before: left })
115
+ continue
116
+ }
117
+ if (left === undefined || right === undefined || entriesEqual(left, right)) continue
118
+ if (left.kind !== right.kind) {
119
+ changes.push({ path, kind: 'type-changed', before: left, after: right })
120
+ continue
121
+ }
122
+ if (left.kind === 'file' && right.kind === 'file' && left.blob === right.blob && left.mode !== right.mode) {
123
+ changes.push({ path, kind: 'mode-changed', before: left, after: right })
124
+ continue
125
+ }
126
+ if (left.kind === 'symlink' && right.kind === 'symlink' && left.target === right.target && left.mode !== right.mode) {
127
+ changes.push({ path, kind: 'mode-changed', before: left, after: right })
128
+ continue
129
+ }
130
+ changes.push({ path, kind: 'modified', before: left, after: right })
131
+ }
132
+ return changes
133
+ }
134
+
135
+ /** Return whether two snapshot entries are byte/type/mode equivalent. */
136
+ export function entriesEqual(left: SnapshotEntry | undefined, right: SnapshotEntry | undefined): boolean {
137
+ if (left === undefined || right === undefined) return left === right
138
+ if (left.kind !== right.kind || left.mode !== right.mode) return false
139
+ if (left.kind === 'file' && right.kind === 'file') {
140
+ return left.blob === right.blob && left.size === right.size
141
+ }
142
+ return left.kind === 'symlink' && right.kind === 'symlink' && left.target === right.target
143
+ }
144
+
145
+ /** Hash a complete path map into a deterministic tree identity. */
146
+ export function hashTree(entries: Readonly<Record<string, SnapshotEntry>>): string {
147
+ const hash = createHash('sha256')
148
+ for (const path of Object.keys(entries).sort(comparePaths)) {
149
+ const entry = entries[path]
150
+ if (entry === undefined) continue
151
+ hash.update(path)
152
+ hash.update('\0')
153
+ if (entry.kind === 'file') {
154
+ hash.update(`file\0${entry.blob}\0${entry.size}\0${entry.mode}\0`)
155
+ } else {
156
+ hash.update(`symlink\0${entry.target}\0${entry.mode}\0`)
157
+ }
158
+ }
159
+ return hash.digest('hex')
160
+ }
161
+
162
+ async function captureEntry(
163
+ root: string,
164
+ path: string,
165
+ maxFileBytes: number,
166
+ signal?: AbortSignal,
167
+ ): Promise<{ readonly kind: 'file'; readonly snapshot: SnapshotEntry & { readonly kind: 'file' }; readonly content: Buffer }
168
+ | { readonly kind: 'symlink'; readonly snapshot: SnapshotEntry & { readonly kind: 'symlink' } }
169
+ | undefined> {
170
+ const target = resolveWorkspacePath(root, path)
171
+ for (let attempt = 0; attempt < 3; attempt += 1) {
172
+ throwIfAborted(signal)
173
+ let before
174
+ try {
175
+ before = await lstat(target, { bigint: true })
176
+ } catch (error) {
177
+ if (isNodeError(error, 'ENOENT')) return undefined
178
+ throw error
179
+ }
180
+ const mode = Number(before.mode & 0o777n)
181
+ if (before.isSymbolicLink()) {
182
+ const linkTarget = await readlink(target)
183
+ const after = await lstat(target, { bigint: true })
184
+ if (!sameStat(before, after)) continue
185
+ return { kind: 'symlink', snapshot: { kind: 'symlink', target: linkTarget, mode } }
186
+ }
187
+ if (!before.isFile()) {
188
+ throw new ChangeLedgerError('UNSUPPORTED_FILE_TYPE', `eligible path is not a regular file or symlink: ${JSON.stringify(path)}`)
189
+ }
190
+ if (before.size > BigInt(maxFileBytes)) {
191
+ throw new ChangeLedgerError(
192
+ 'FILE_TOO_LARGE',
193
+ `${JSON.stringify(path)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`,
194
+ )
195
+ }
196
+ const content = await readFile(target)
197
+ throwIfAborted(signal)
198
+ const after = await lstat(target, { bigint: true })
199
+ if (!sameStat(before, after) || BigInt(content.length) !== after.size) continue
200
+ const blob = createHash('sha256').update(content).digest('hex')
201
+ return {
202
+ kind: 'file',
203
+ snapshot: { kind: 'file', blob, size: content.length, mode },
204
+ content,
205
+ }
206
+ }
207
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`)
208
+ }
209
+
210
+ function sameStat(left: BigIntStats, right: BigIntStats): boolean {
211
+ return left.dev === right.dev
212
+ && left.ino === right.ino
213
+ && left.mode === right.mode
214
+ && left.size === right.size
215
+ && left.mtimeNs === right.mtimeNs
216
+ && left.ctimeNs === right.ctimeNs
217
+ }
218
+
219
+ function throwIfAborted(signal?: AbortSignal): void {
220
+ if (signal?.aborted === true) throw signal.reason
221
+ }
222
+
223
+ function comparePaths(left: string, right: string): number {
224
+ return Buffer.from(left).compare(Buffer.from(right))
225
+ }
226
+
227
+ function arraysEqual(left: readonly string[], right: readonly string[]): boolean {
228
+ return left.length === right.length && left.every((value, index) => value === right[index])
229
+ }
package/src/store.ts ADDED
@@ -0,0 +1,383 @@
1
+ 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'
4
+ 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'
7
+ import { parseManifest, parseOperation, validateBlobHash, validateOperationId, validateRestorePointId } from './validate.js'
8
+
9
+ interface LockRecord {
10
+ readonly pid: number
11
+ readonly createdAt: number
12
+ readonly nonce: string
13
+ }
14
+
15
+ /** Durable content-addressed storage and per-workspace locking. */
16
+ export class LedgerStore {
17
+ constructor(readonly config: ResolvedChangeLedgerConfig) {}
18
+
19
+ /** Create the state root and reconcile crash-interrupted operations. */
20
+ 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'))) {
24
+ 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}`)
32
+ }
33
+ if (this.workspaceDir(operation.workspace) !== workspaceDir) {
34
+ throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} is stored under the wrong workspace key`)
35
+ }
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
43
+ }
44
+ }
45
+ return reconciled
46
+ }
47
+
48
+ /** 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 })
52
+ 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
61
+ throw new ChangeLedgerError('WORKSPACE_LOCKED', `another change-ledger operation owns ${JSON.stringify(workspace)}`)
62
+ }
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()
69
+ }
70
+ await syncDirectory(this.workspaceDir(workspace))
71
+ 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
+ }
83
+ }
84
+ }
85
+ throw new ChangeLedgerError('WORKSPACE_LOCKED', `could not acquire change-ledger lock for ${JSON.stringify(workspace)}`)
86
+ }
87
+
88
+ /** Persist a blob if it is not already present, and verify existing content. */
89
+ async putBlob(workspace: string, hash: string, content: Buffer): Promise<void> {
90
+ validateBlobHash(hash)
91
+ const contentHash = createHash('sha256').update(content).digest('hex')
92
+ if (contentHash !== hash) {
93
+ throw new ChangeLedgerError('BLOB_HASH_MISMATCH', `refusing to store content whose SHA-256 does not match ${hash}`)
94
+ }
95
+ const path = this.blobPath(workspace, hash)
96
+ const directory = join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2))
97
+ await mkdir(directory, { recursive: true, mode: 0o700 })
98
+ const temporary = join(directory, `.${randomUUID()}.tmp`)
99
+ try {
100
+ const handle = await open(temporary, 'wx', 0o600)
101
+ try {
102
+ await handle.writeFile(content)
103
+ await handle.sync()
104
+ } finally {
105
+ await handle.close()
106
+ }
107
+ try {
108
+ await link(temporary, path)
109
+ await syncDirectory(directory)
110
+ return
111
+ } catch (error) {
112
+ if (!isNodeError(error, 'EEXIST')) throw error
113
+ }
114
+ } finally {
115
+ try {
116
+ await unlink(temporary)
117
+ } catch (error) {
118
+ if (!isNodeError(error, 'ENOENT')) throw error
119
+ }
120
+ }
121
+ const existing = await readFile(path)
122
+ const existingHash = createHash('sha256').update(existing).digest('hex')
123
+ if (existingHash !== hash || !existing.equals(content)) {
124
+ throw new ChangeLedgerError('BLOB_COLLISION', `stored blob ${hash} does not match its content hash`)
125
+ }
126
+ }
127
+
128
+ /** Read and verify one content-addressed blob. */
129
+ async readBlob(workspace: string, hash: string): Promise<Buffer> {
130
+ validateBlobHash(hash)
131
+ const content = await readFile(this.blobPath(workspace, hash))
132
+ const actual = createHash('sha256').update(content).digest('hex')
133
+ if (actual !== hash) throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${hash} failed SHA-256 verification`)
134
+ return content
135
+ }
136
+
137
+ /** Write one restore-point manifest atomically. */
138
+ async writeManifest(manifest: RestorePointManifest): Promise<void> {
139
+ parseManifest(manifest)
140
+ await writeJsonAtomic(this.manifestPath(manifest.workspace, manifest.id), manifest)
141
+ }
142
+
143
+ /** Load and validate one restore-point manifest. */
144
+ async readManifest(workspace: string, id: string): Promise<RestorePointManifest> {
145
+ validateRestorePointId(id)
146
+ let raw: unknown
147
+ try {
148
+ raw = await readJson(this.manifestPath(workspace, id))
149
+ } catch (error) {
150
+ if (isMissingStateRead(error)) {
151
+ throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `restore point ${id} does not exist`, { cause: error })
152
+ }
153
+ throw error
154
+ }
155
+ const manifest = parseManifest(raw)
156
+ if (manifest.id !== id) {
157
+ throw new ChangeLedgerError('STATE_CORRUPT', `restore point file ${id}.json contains id ${manifest.id}`)
158
+ }
159
+ if (manifest.workspace !== workspace) {
160
+ throw new ChangeLedgerError('STATE_CORRUPT', `restore point ${id} belongs to a different workspace`)
161
+ }
162
+ return manifest
163
+ }
164
+
165
+ /** List all validated restore points for one workspace, newest first. */
166
+ async listManifests(workspace: string): Promise<RestorePointManifest[]> {
167
+ 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)))
170
+ if (filename !== `${manifest.id}.json`) {
171
+ throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} does not match its persisted id ${manifest.id}`)
172
+ }
173
+ if (manifest.workspace !== workspace) {
174
+ throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} belongs to a different workspace`)
175
+ }
176
+ manifests.push(manifest)
177
+ }
178
+ return manifests.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
179
+ }
180
+
181
+ /** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
182
+ async deleteManifest(workspace: string, id: string): Promise<void> {
183
+ validateRestorePointId(id)
184
+ const directory = join(this.workspaceDir(workspace), 'manifests')
185
+ try {
186
+ await unlink(this.manifestPath(workspace, id))
187
+ } catch (error) {
188
+ if (isNodeError(error, 'ENOENT')) {
189
+ throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `restore point ${id} does not exist`)
190
+ }
191
+ throw error
192
+ }
193
+ await syncDirectory(directory)
194
+ }
195
+
196
+ /** Persist one restore-operation journal. */
197
+ async writeOperation(operation: RestoreOperation): Promise<void> {
198
+ parseOperation(operation)
199
+ await writeJsonAtomic(this.operationPath(operation.workspace, operation.id), operation)
200
+ }
201
+
202
+ /** List validated restore operations for one workspace. */
203
+ async listOperations(workspace: string): Promise<RestoreOperation[]> {
204
+ 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)))
207
+ if (filename !== `${operation.id}.json`) {
208
+ throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`)
209
+ }
210
+ if (operation.workspace !== workspace) {
211
+ throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} belongs to a different workspace`)
212
+ }
213
+ operations.push(operation)
214
+ }
215
+ return operations.sort((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))
216
+ }
217
+
218
+ /** Return whether an incomplete operation still references a restore point. */
219
+ async isReferencedByRecovery(workspace: string, restorePointId: string): Promise<boolean> {
220
+ return (await this.listOperations(workspace)).some((operation) =>
221
+ (operation.state === 'interrupted' || operation.state === 'recovery-required')
222
+ && (operation.restorePointId === restorePointId || operation.rescuePointId === restorePointId))
223
+ }
224
+
225
+ /** Delete blobs not referenced by any remaining manifest. */
226
+ async collectGarbage(
227
+ workspace: string,
228
+ additionalReferenced: Iterable<string> = [],
229
+ ): Promise<{ deletedBlobs: number; retainedBlobs: number }> {
230
+ const referenced = new Set<string>(additionalReferenced)
231
+ for (const hash of referenced) validateBlobHash(hash)
232
+ for (const manifest of await this.listManifests(workspace)) {
233
+ for (const entry of Object.values(manifest.entries)) {
234
+ if (entry.kind === 'file') referenced.add(entry.blob)
235
+ }
236
+ }
237
+ let deletedBlobs = 0
238
+ let retainedBlobs = 0
239
+ const blobsRoot = join(this.workspaceDir(workspace), 'blobs')
240
+ for (const prefix of await safeDirectoryNames(blobsRoot)) {
241
+ const prefixPath = join(blobsRoot, prefix)
242
+ for (const filename of await safeFileNames(prefixPath)) {
243
+ if (filename.startsWith('.') && filename.endsWith('.tmp')) {
244
+ await unlink(join(prefixPath, filename))
245
+ deletedBlobs += 1
246
+ continue
247
+ }
248
+ validateBlobHash(filename)
249
+ if (filename.slice(0, 2) !== prefix) {
250
+ throw new ChangeLedgerError('STATE_CORRUPT', `blob ${filename} is stored under the wrong prefix directory ${prefix}`)
251
+ }
252
+ if (referenced.has(filename)) {
253
+ retainedBlobs += 1
254
+ continue
255
+ }
256
+ await unlink(join(prefixPath, filename))
257
+ deletedBlobs += 1
258
+ }
259
+ try {
260
+ await rmdir(prefixPath)
261
+ } catch (error) {
262
+ if (!isNodeError(error, 'ENOTEMPTY') && !isNodeError(error, 'EEXIST') && !isNodeError(error, 'ENOENT')) throw error
263
+ }
264
+ }
265
+ return { deletedBlobs, retainedBlobs }
266
+ }
267
+
268
+ private workspaceDir(workspace: string): string {
269
+ const key = createHash('sha256').update(workspace).digest('hex')
270
+ return join(this.config.storageDir, 'workspaces', key)
271
+ }
272
+
273
+ private manifestPath(workspace: string, id: string): string {
274
+ return join(this.workspaceDir(workspace), 'manifests', `${validateRestorePointId(id)}.json`)
275
+ }
276
+
277
+ private operationPath(workspace: string, id: string): string {
278
+ return join(this.workspaceDir(workspace), 'operations', `${validateOperationId(id)}.json`)
279
+ }
280
+
281
+ private blobPath(workspace: string, hash: string): string {
282
+ validateBlobHash(hash)
283
+ return join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2), hash)
284
+ }
285
+
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
292
+ try {
293
+ age = Date.now() - (await stat(lockPath)).mtimeMs
294
+ } catch (statError) {
295
+ if (isNodeError(statError, 'ENOENT')) return true
296
+ throw statError
297
+ }
298
+ if (age < this.config.staleLockMs) {
299
+ throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', `active lock is unreadable: ${errorMessage(error)}`)
300
+ }
301
+ await unlink(lockPath)
302
+ return true
303
+ }
304
+ if (processExists(lock.pid)) return false
305
+ if (Date.now() - lock.createdAt < this.config.staleLockMs) return false
306
+ try {
307
+ const current = parseLock(await readJson(lockPath))
308
+ if (current.nonce !== lock.nonce) return false
309
+ await unlink(lockPath)
310
+ return true
311
+ } catch (error) {
312
+ if (isNodeError(error, 'ENOENT')) return true
313
+ throw error
314
+ }
315
+ }
316
+
317
+ private async workspaceAppearsActive(workspaceDir: string): Promise<boolean> {
318
+ const lockPath = join(workspaceDir, 'lock.json')
319
+ try {
320
+ const lock = parseLock(await readJson(lockPath))
321
+ if (processExists(lock.pid)) return true
322
+ return Date.now() - lock.createdAt < this.config.staleLockMs
323
+ } catch (error) {
324
+ if (error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error.cause)) return false
325
+ if (isNodeError(error, 'ENOENT')) return false
326
+ 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
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ function parseLock(value: unknown): LockRecord {
337
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
338
+ throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock must be an object')
339
+ }
340
+ const record = value as Record<string, unknown>
341
+ if (!Number.isSafeInteger(record.pid) || (record.pid as number) <= 0) {
342
+ throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock pid is invalid')
343
+ }
344
+ if (!Number.isSafeInteger(record.createdAt) || (record.createdAt as number) < 0) {
345
+ throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock createdAt is invalid')
346
+ }
347
+ if (typeof record.nonce !== 'string' || record.nonce === '') {
348
+ throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock nonce is invalid')
349
+ }
350
+ return { pid: record.pid as number, createdAt: record.createdAt as number, nonce: record.nonce }
351
+ }
352
+
353
+ async function safeJsonNames(path: string): Promise<string[]> {
354
+ return (await safeFileNames(path)).filter((name) => name.endsWith('.json')).sort()
355
+ }
356
+
357
+ async function safeDirectoryNames(path: string): Promise<string[]> {
358
+ try {
359
+ const entries = await readdir(path, { withFileTypes: true })
360
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
361
+ } catch (error) {
362
+ if (isNodeError(error, 'ENOENT')) return []
363
+ throw error
364
+ }
365
+ }
366
+
367
+ async function safeFileNames(path: string): Promise<string[]> {
368
+ try {
369
+ const entries = await readdir(path, { withFileTypes: true })
370
+ return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort()
371
+ } catch (error) {
372
+ if (isNodeError(error, 'ENOENT')) return []
373
+ throw error
374
+ }
375
+ }
376
+
377
+ function isMissingCause(error: unknown): boolean {
378
+ return error instanceof Error && 'cause' in error && isNodeError(error.cause, 'ENOENT')
379
+ }
380
+
381
+ function isMissingStateRead(error: unknown): boolean {
382
+ return error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error)
383
+ }