@anionex/dsh-turn-rewind 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- package/src/validate.ts +67 -7
package/src/engine.ts
CHANGED
|
@@ -2,9 +2,25 @@ import { randomBytes } from 'node:crypto'
|
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
4
|
import { lstat, realpath } from 'node:fs/promises'
|
|
5
|
+
import { createDeadline } from './deadline.js'
|
|
5
6
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
6
|
-
import { discoverRepository, sameRepositoryFence } from './git.js'
|
|
7
7
|
import {
|
|
8
|
+
discoverRepository,
|
|
9
|
+
discoverRepositoryRoot,
|
|
10
|
+
ensureGitWorktreeIdentity,
|
|
11
|
+
resolveGitWorktreeRoot,
|
|
12
|
+
sameRepositoryFence,
|
|
13
|
+
} from './git.js'
|
|
14
|
+
import {
|
|
15
|
+
captureGitTurnCheckpoint,
|
|
16
|
+
deleteGitCheckpoint,
|
|
17
|
+
deleteGitCheckpointRef,
|
|
18
|
+
publishGitCheckpoint,
|
|
19
|
+
readGitBlob,
|
|
20
|
+
verifyGitCheckpoint,
|
|
21
|
+
} from './git-checkpoint.js'
|
|
22
|
+
import {
|
|
23
|
+
assertSafeParents,
|
|
8
24
|
ensureSafeParents,
|
|
9
25
|
expandHome,
|
|
10
26
|
isNodeError,
|
|
@@ -16,8 +32,8 @@ import {
|
|
|
16
32
|
resolveWorkspacePath,
|
|
17
33
|
validateRelativePath,
|
|
18
34
|
} from './path-utils.js'
|
|
19
|
-
import { captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
|
|
20
|
-
import { LedgerStore } from './store.js'
|
|
35
|
+
import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
|
|
36
|
+
import { LedgerStore, type GitCheckpointJournal } from './store.js'
|
|
21
37
|
import {
|
|
22
38
|
LEDGER_FORMAT_VERSION,
|
|
23
39
|
type ChangeLedgerConfig,
|
|
@@ -28,10 +44,13 @@ import {
|
|
|
28
44
|
type RestorePointInspection,
|
|
29
45
|
type RestorePointKind,
|
|
30
46
|
type RestorePointManifest,
|
|
47
|
+
type RestorePointManifestV2,
|
|
31
48
|
type RestorePointSummary,
|
|
32
49
|
type RestoreResult,
|
|
33
50
|
type SnapshotEntry,
|
|
34
51
|
type WorkspaceChange,
|
|
52
|
+
type WorkspaceOverview,
|
|
53
|
+
type WorkspacePurgeReport,
|
|
35
54
|
} from './types.js'
|
|
36
55
|
|
|
37
56
|
const DEFAULTS = {
|
|
@@ -42,11 +61,15 @@ const DEFAULTS = {
|
|
|
42
61
|
maxSnapshotBytes: 512 * 1024 * 1024,
|
|
43
62
|
planTtlMs: 15 * 60 * 1_000,
|
|
44
63
|
staleLockMs: 30_000,
|
|
64
|
+
turnCheckpointMode: 'legacy',
|
|
65
|
+
turnCheckpointTimeoutMs: 5_000,
|
|
66
|
+
turnCheckpointMaxNewBytes: 32 * 1024 * 1024,
|
|
67
|
+
turnCheckpointTrust: 'fast',
|
|
45
68
|
} as const
|
|
46
69
|
|
|
47
70
|
/** Persistent workspace change-set engine, independent of the DSH tool adapter. */
|
|
48
71
|
export class ChangeLedgerEngine {
|
|
49
|
-
readonly
|
|
72
|
+
private readonly currentConfig: ResolvedChangeLedgerConfig
|
|
50
73
|
readonly store: LedgerStore
|
|
51
74
|
private readonly plans = new Map<string, RestorePlan>()
|
|
52
75
|
private readonly activePlans = new Set<string>()
|
|
@@ -54,9 +77,26 @@ export class ChangeLedgerEngine {
|
|
|
54
77
|
|
|
55
78
|
/** Build an engine and start crash-journal reconciliation. */
|
|
56
79
|
constructor(config: ChangeLedgerConfig = {}) {
|
|
57
|
-
this.
|
|
58
|
-
this.store = new LedgerStore(this.
|
|
59
|
-
this.ready = this.
|
|
80
|
+
this.currentConfig = resolveConfig(config)
|
|
81
|
+
this.store = new LedgerStore(this.currentConfig)
|
|
82
|
+
this.ready = this.initializeStore()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Currently resolved configuration; runtime-tunable fields update in place. */
|
|
86
|
+
get config(): ResolvedChangeLedgerConfig {
|
|
87
|
+
return this.currentConfig
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Swap runtime-tunable configuration (limits, modes, timeouts, trust) in place.
|
|
92
|
+
* The storage root must never move once the engine owns locks and journals.
|
|
93
|
+
*/
|
|
94
|
+
updateConfig(config: ChangeLedgerConfig): void {
|
|
95
|
+
const resolved = resolveConfig(config)
|
|
96
|
+
if (resolved.storageDir !== this.currentConfig.storageDir) {
|
|
97
|
+
throw new ChangeLedgerError('INVALID_CONFIG', 'storageDir cannot change while the engine is running')
|
|
98
|
+
}
|
|
99
|
+
Object.assign(this.currentConfig as WritableResolvedConfig, resolved)
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
@@ -64,6 +104,117 @@ export class ChangeLedgerEngine {
|
|
|
64
104
|
return this.ready
|
|
65
105
|
}
|
|
66
106
|
|
|
107
|
+
private async initializeStore(): Promise<number> {
|
|
108
|
+
const restoredOperations = await this.store.initialize()
|
|
109
|
+
return restoredOperations + await this.reconcileGitCheckpointJournals()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async acquireWorkspace(workspace: string, signal?: AbortSignal): Promise<() => Promise<void>> {
|
|
113
|
+
const identity = await ensureGitWorktreeIdentity(workspace, signal)
|
|
114
|
+
if (identity.root !== workspace) {
|
|
115
|
+
throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`)
|
|
116
|
+
}
|
|
117
|
+
return this.store.acquire(workspace, identity.lockPath, {
|
|
118
|
+
commonDir: identity.commonDir,
|
|
119
|
+
gitDir: identity.gitDir,
|
|
120
|
+
worktreeId: identity.worktreeId,
|
|
121
|
+
}, signal)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async reconcileGitCheckpointJournals(): Promise<number> {
|
|
125
|
+
let reconciled = 0
|
|
126
|
+
for (const journal of await this.store.listGitCheckpointJournals()) {
|
|
127
|
+
const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace)
|
|
128
|
+
const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace }
|
|
129
|
+
const release = await this.acquireWorkspace(workspace)
|
|
130
|
+
try {
|
|
131
|
+
const manifest = await this.tryReadManifest(workspace, journal.restorePointId)
|
|
132
|
+
if (journal.action === 'publish') {
|
|
133
|
+
if (manifest === undefined) {
|
|
134
|
+
await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
|
|
135
|
+
} else {
|
|
136
|
+
assertJournalMatchesManifest(activeJournal, manifest)
|
|
137
|
+
await verifyGitCheckpoint(manifest)
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
if (manifest !== undefined) {
|
|
141
|
+
assertJournalMatchesManifest(activeJournal, manifest)
|
|
142
|
+
await this.store.deleteManifest(workspace, journal.restorePointId)
|
|
143
|
+
}
|
|
144
|
+
await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
|
|
145
|
+
}
|
|
146
|
+
await this.store.deleteGitCheckpointJournal(workspace, journal.restorePointId)
|
|
147
|
+
reconciled += 1
|
|
148
|
+
} finally {
|
|
149
|
+
await release()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return reconciled
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async publishGitManifest(manifest: RestorePointManifestV2, signal?: AbortSignal): Promise<void> {
|
|
156
|
+
let journalWritten = false
|
|
157
|
+
try {
|
|
158
|
+
await this.store.writeGitCheckpointJournal('publish', manifest)
|
|
159
|
+
journalWritten = true
|
|
160
|
+
await publishGitCheckpoint(manifest, this.config.storageDir, signal)
|
|
161
|
+
await verifyGitCheckpoint(manifest, signal)
|
|
162
|
+
await this.store.writeManifest(manifest)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (!journalWritten) throw error
|
|
165
|
+
const persisted = await this.tryReadManifest(manifest.workspace, manifest.id)
|
|
166
|
+
if (persisted !== undefined) {
|
|
167
|
+
assertJournalMatchesManifest({
|
|
168
|
+
version: 1,
|
|
169
|
+
action: 'publish',
|
|
170
|
+
storeId: manifest.git.storeId,
|
|
171
|
+
workspace: manifest.workspace,
|
|
172
|
+
commonDir: manifest.repository.commonDir,
|
|
173
|
+
worktreeId: manifest.git.worktreeId,
|
|
174
|
+
restorePointId: manifest.id,
|
|
175
|
+
ref: manifest.git.ref,
|
|
176
|
+
commit: manifest.git.commit,
|
|
177
|
+
createdAt: manifest.createdAt,
|
|
178
|
+
}, persisted)
|
|
179
|
+
await verifyGitCheckpoint(persisted, signal)
|
|
180
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
await deleteGitCheckpoint(manifest, this.config.storageDir)
|
|
185
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
|
|
186
|
+
} catch (cleanupError) {
|
|
187
|
+
throw new ChangeLedgerError(
|
|
188
|
+
'SNAPSHOT_CLEANUP_FAILED',
|
|
189
|
+
`Git checkpoint publication failed (${errorMessage(error)}) and journaled ref cleanup also failed (${errorMessage(cleanupError)})`,
|
|
190
|
+
{ cause: error },
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
throw error
|
|
194
|
+
}
|
|
195
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async deleteManifestWithGit(manifest: RestorePointManifest, signal?: AbortSignal): Promise<void> {
|
|
199
|
+
if (manifest.version === LEDGER_FORMAT_VERSION) {
|
|
200
|
+
await this.store.deleteManifest(manifest.workspace, manifest.id)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
await this.store.writeGitCheckpointJournal('delete', manifest)
|
|
204
|
+
await this.store.deleteManifest(manifest.workspace, manifest.id)
|
|
205
|
+
await deleteGitCheckpoint(manifest, this.config.storageDir, signal)
|
|
206
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async tryReadManifest(workspace: string, id: string): Promise<RestorePointManifest | undefined> {
|
|
210
|
+
try {
|
|
211
|
+
return await this.store.readManifest(workspace, id)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND') return undefined
|
|
214
|
+
throw error
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
67
218
|
/** Create a durable restore point for the current Git worktree. */
|
|
68
219
|
async create(options: {
|
|
69
220
|
readonly cwd: string
|
|
@@ -74,7 +225,7 @@ export class ChangeLedgerEngine {
|
|
|
74
225
|
await this.ready
|
|
75
226
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
76
227
|
await this.assertStorageSeparated(source.state.root)
|
|
77
|
-
const release = await this.
|
|
228
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
78
229
|
try {
|
|
79
230
|
const label = normalizeLabel(options.label)
|
|
80
231
|
const manifest = await this.createLocked({
|
|
@@ -98,41 +249,156 @@ export class ChangeLedgerEngine {
|
|
|
98
249
|
readonly turnStartSeq: number
|
|
99
250
|
readonly signal?: AbortSignal
|
|
100
251
|
}): Promise<RestorePointSummary> {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
252
|
+
const deadline = createDeadline(this.config.turnCheckpointTimeoutMs)
|
|
253
|
+
const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal])
|
|
254
|
+
try {
|
|
255
|
+
try {
|
|
256
|
+
await waitWithSignal(this.ready, signal)
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
|
|
259
|
+
}
|
|
260
|
+
if (this.config.turnCheckpointMode === 'off') {
|
|
261
|
+
throw new ChangeLedgerError('TURN_CHECKPOINT_DISABLED', 'automatic turn checkpoints are disabled')
|
|
262
|
+
}
|
|
263
|
+
if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
|
|
264
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer')
|
|
265
|
+
}
|
|
266
|
+
if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
|
|
267
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer')
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const source = await discoverRepository(options.cwd, signal)
|
|
271
|
+
await this.assertStorageSeparated(source.state.root)
|
|
272
|
+
const release = await this.acquireWorkspace(source.state.root, signal)
|
|
273
|
+
try {
|
|
274
|
+
const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal)
|
|
275
|
+
const duplicate = existing.find(manifest => manifest.kind === 'turn'
|
|
276
|
+
&& manifest.sessionId === options.sessionId
|
|
277
|
+
&& manifest.turn === options.turn
|
|
278
|
+
&& manifest.turnStartSeq === options.turnStartSeq)
|
|
279
|
+
if (duplicate !== undefined) {
|
|
280
|
+
if (duplicate.version === 2) await verifyGitCheckpoint(duplicate, signal)
|
|
281
|
+
await this.store.deleteTurnCheckpointSkip(
|
|
282
|
+
source.state.root,
|
|
283
|
+
options.sessionId,
|
|
284
|
+
options.turn,
|
|
285
|
+
options.turnStartSeq,
|
|
286
|
+
).catch(() => undefined)
|
|
287
|
+
return summarize(duplicate)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
throwIfAborted(signal)
|
|
291
|
+
let manifest: RestorePointManifest
|
|
292
|
+
if (this.config.turnCheckpointMode === 'legacy') {
|
|
293
|
+
manifest = await this.createLocked({
|
|
294
|
+
cwd: source.state.root,
|
|
295
|
+
kind: 'turn',
|
|
296
|
+
sessionId: options.sessionId,
|
|
297
|
+
label: `Before turn ${String(options.turn)} checkpoint`,
|
|
298
|
+
turn: options.turn,
|
|
299
|
+
turnStartSeq: options.turnStartSeq,
|
|
300
|
+
signal,
|
|
301
|
+
deferGarbageCollection: true,
|
|
302
|
+
})
|
|
303
|
+
} else {
|
|
304
|
+
const id = makeId('rp')
|
|
305
|
+
const captured = (await captureGitTurnCheckpoint({
|
|
306
|
+
cwd: source.state.root,
|
|
307
|
+
id,
|
|
308
|
+
sessionId: options.sessionId,
|
|
309
|
+
turn: options.turn,
|
|
310
|
+
turnStartSeq: options.turnStartSeq,
|
|
311
|
+
config: this.config,
|
|
312
|
+
signal,
|
|
313
|
+
})).manifest
|
|
314
|
+
await this.publishGitManifest(captured, signal)
|
|
315
|
+
manifest = captured
|
|
316
|
+
}
|
|
317
|
+
await this.store.deleteTurnCheckpointSkip(
|
|
318
|
+
source.state.root,
|
|
319
|
+
options.sessionId,
|
|
320
|
+
options.turn,
|
|
321
|
+
options.turnStartSeq,
|
|
322
|
+
).catch(() => undefined)
|
|
323
|
+
|
|
324
|
+
if (!signal.aborted) {
|
|
325
|
+
const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
|
|
326
|
+
.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
|
|
327
|
+
for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
|
|
328
|
+
if (signal.aborted) break
|
|
329
|
+
if (await this.store.isReferencedByRecovery(source.state.root, stale.id)) continue
|
|
330
|
+
await this.deleteManifestWithGit(stale, signal).catch(() => undefined)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return summarize(manifest)
|
|
334
|
+
} finally {
|
|
335
|
+
await release()
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
deadline.cancel()
|
|
107
342
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
|
|
346
|
+
async recordTurnCheckpointSkip(options: {
|
|
347
|
+
readonly cwd: string
|
|
348
|
+
readonly sessionId: string
|
|
349
|
+
readonly turn: number
|
|
350
|
+
readonly turnStartSeq: number
|
|
351
|
+
readonly reason: string
|
|
352
|
+
readonly signal?: AbortSignal
|
|
353
|
+
}): Promise<void> {
|
|
354
|
+
await waitWithSignal(this.ready, options.signal)
|
|
355
|
+
const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
|
|
356
|
+
await this.assertStorageSeparated(workspace)
|
|
357
|
+
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
111
358
|
try {
|
|
112
|
-
|
|
113
|
-
const
|
|
359
|
+
throwIfAborted(options.signal)
|
|
360
|
+
const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest =>
|
|
361
|
+
manifest.kind === 'turn'
|
|
114
362
|
&& manifest.sessionId === options.sessionId
|
|
115
363
|
&& manifest.turn === options.turn
|
|
116
364
|
&& manifest.turnStartSeq === options.turnStartSeq)
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
365
|
+
if (existing) {
|
|
366
|
+
await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
await this.store.writeTurnCheckpointSkip(workspace, {
|
|
370
|
+
version: 1,
|
|
122
371
|
sessionId: options.sessionId,
|
|
123
|
-
label: `Before turn ${String(options.turn)} checkpoint`,
|
|
124
372
|
turn: options.turn,
|
|
125
373
|
turnStartSeq: options.turnStartSeq,
|
|
126
|
-
|
|
374
|
+
reason: options.reason.slice(0, 2_000),
|
|
375
|
+
createdAt: Date.now(),
|
|
127
376
|
})
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
377
|
+
} finally {
|
|
378
|
+
await release()
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Read a durable skip marker for one exact prompt boundary. */
|
|
383
|
+
async findTurnCheckpointSkip(options: {
|
|
384
|
+
readonly cwd: string
|
|
385
|
+
readonly sessionId: string
|
|
386
|
+
readonly turn: number
|
|
387
|
+
readonly turnStartSeq: number
|
|
388
|
+
readonly signal?: AbortSignal
|
|
389
|
+
}): Promise<{ readonly reason: string } | undefined> {
|
|
390
|
+
await this.ready
|
|
391
|
+
const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
|
|
392
|
+
await this.assertStorageSeparated(workspace)
|
|
393
|
+
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
394
|
+
try {
|
|
395
|
+
const skip = await this.store.readTurnCheckpointSkip(
|
|
396
|
+
workspace,
|
|
397
|
+
options.sessionId,
|
|
398
|
+
options.turn,
|
|
399
|
+
options.turnStartSeq,
|
|
400
|
+
)
|
|
401
|
+
return skip === undefined ? undefined : { reason: skip.reason }
|
|
136
402
|
} finally {
|
|
137
403
|
await release()
|
|
138
404
|
}
|
|
@@ -148,12 +414,18 @@ export class ChangeLedgerEngine {
|
|
|
148
414
|
await this.ready
|
|
149
415
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
150
416
|
await this.assertStorageSeparated(source.state.root)
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
417
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
418
|
+
try {
|
|
419
|
+
const manifest = (await this.store.listManifests(source.state.root)).find(point =>
|
|
420
|
+
point.kind === 'turn'
|
|
421
|
+
&& point.sessionId === options.sessionId
|
|
422
|
+
&& point.turn === options.turn
|
|
423
|
+
&& point.turnStartSeq !== undefined)
|
|
424
|
+
if (manifest?.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
425
|
+
return manifest === undefined ? undefined : summarize(manifest)
|
|
426
|
+
} finally {
|
|
427
|
+
await release()
|
|
428
|
+
}
|
|
157
429
|
}
|
|
158
430
|
|
|
159
431
|
/** List restore points for the current worktree. */
|
|
@@ -166,12 +438,17 @@ export class ChangeLedgerEngine {
|
|
|
166
438
|
await this.ready
|
|
167
439
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
168
440
|
await this.assertStorageSeparated(source.state.root)
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
441
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
442
|
+
try {
|
|
443
|
+
const manifests = await this.store.listManifests(source.state.root)
|
|
444
|
+
return manifests
|
|
445
|
+
.filter((manifest) => manifest.kind === 'user'
|
|
446
|
+
|| (manifest.kind === 'rescue' && options.includeRescue === true)
|
|
447
|
+
|| (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
|
|
448
|
+
.map(summarize)
|
|
449
|
+
} finally {
|
|
450
|
+
await release()
|
|
451
|
+
}
|
|
175
452
|
}
|
|
176
453
|
|
|
177
454
|
/** Compare one restore point with the current worktree. */
|
|
@@ -183,18 +460,31 @@ export class ChangeLedgerEngine {
|
|
|
183
460
|
await this.ready
|
|
184
461
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
185
462
|
await this.assertStorageSeparated(source.state.root)
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
463
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
464
|
+
try {
|
|
465
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
466
|
+
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
467
|
+
const current = await captureStableTree({
|
|
468
|
+
cwd: source.state.root,
|
|
469
|
+
config: this.config,
|
|
470
|
+
...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
|
|
471
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
472
|
+
})
|
|
473
|
+
const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
|
|
474
|
+
if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
|
|
475
|
+
return {
|
|
476
|
+
restorePoint: summarize(manifest),
|
|
477
|
+
currentTreeHash: current.treeHash,
|
|
478
|
+
currentRepository: current.source.state,
|
|
479
|
+
...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
|
|
480
|
+
...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
|
|
481
|
+
...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
|
|
482
|
+
headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
|
|
483
|
+
operationChanged: manifest.repository.operation !== current.source.state.operation,
|
|
484
|
+
changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
|
|
485
|
+
}
|
|
486
|
+
} finally {
|
|
487
|
+
await release()
|
|
198
488
|
}
|
|
199
489
|
}
|
|
200
490
|
|
|
@@ -213,45 +503,58 @@ export class ChangeLedgerEngine {
|
|
|
213
503
|
this.expirePlans()
|
|
214
504
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
215
505
|
await this.assertStorageSeparated(source.state.root)
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
throw new ChangeLedgerError('
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
source.state
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
506
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
507
|
+
try {
|
|
508
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
509
|
+
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
510
|
+
const current = await captureStableTree({
|
|
511
|
+
cwd: source.state.root,
|
|
512
|
+
config: this.config,
|
|
513
|
+
...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
|
|
514
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
515
|
+
})
|
|
516
|
+
const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
|
|
517
|
+
if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
|
|
518
|
+
if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
|
|
519
|
+
throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again')
|
|
520
|
+
}
|
|
521
|
+
if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
|
|
522
|
+
throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again')
|
|
523
|
+
}
|
|
524
|
+
assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
|
|
525
|
+
const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)
|
|
526
|
+
if (changes.length === 0) {
|
|
527
|
+
throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`)
|
|
528
|
+
}
|
|
529
|
+
const selected = selectChanges(changes, options.paths)
|
|
530
|
+
await assertNoUnmanagedRestoreConflicts(
|
|
531
|
+
source.state.root,
|
|
532
|
+
manifest.entries,
|
|
533
|
+
current.entries,
|
|
534
|
+
selected.map((change) => change.path),
|
|
535
|
+
)
|
|
536
|
+
const expected: Record<string, SnapshotEntry | null> = Object.create(null) as Record<string, SnapshotEntry | null>
|
|
537
|
+
for (const change of selected) expected[change.path] = current.entries[change.path] ?? null
|
|
538
|
+
const now = Date.now()
|
|
539
|
+
const plan: RestorePlan = {
|
|
540
|
+
id: makeId('plan'),
|
|
541
|
+
restorePointId: manifest.id,
|
|
542
|
+
workspace: manifest.workspace,
|
|
543
|
+
repository: current.source.state,
|
|
544
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
545
|
+
createdAt: now,
|
|
546
|
+
expiresAt: now + this.config.planTtlMs,
|
|
547
|
+
confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
|
|
548
|
+
allowHeadChange: options.allowHeadChange === true,
|
|
549
|
+
paths: selected.map((change) => change.path),
|
|
550
|
+
changes: selected,
|
|
551
|
+
expected,
|
|
552
|
+
}
|
|
553
|
+
this.plans.set(plan.id, plan)
|
|
554
|
+
return clonePlan(plan)
|
|
555
|
+
} finally {
|
|
556
|
+
await release()
|
|
557
|
+
}
|
|
255
558
|
}
|
|
256
559
|
|
|
257
560
|
/** Apply one approved restore plan, creating a durable rescue point first. */
|
|
@@ -278,9 +581,10 @@ export class ChangeLedgerEngine {
|
|
|
278
581
|
|
|
279
582
|
try {
|
|
280
583
|
await this.assertStorageSeparated(plan.workspace)
|
|
281
|
-
const release = await this.
|
|
584
|
+
const release = await this.acquireWorkspace(plan.workspace, options.signal)
|
|
282
585
|
try {
|
|
283
586
|
const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId)
|
|
587
|
+
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
284
588
|
const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) })
|
|
285
589
|
assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange)
|
|
286
590
|
assertPlanRepositoryFresh(plan.repository, current.source.state)
|
|
@@ -317,8 +621,14 @@ export class ChangeLedgerEngine {
|
|
|
317
621
|
await this.store.writeOperation(operation)
|
|
318
622
|
|
|
319
623
|
try {
|
|
320
|
-
await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal)
|
|
321
|
-
await this.verifyPaths(
|
|
624
|
+
await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal, plan.expected)
|
|
625
|
+
await this.verifyPaths(
|
|
626
|
+
plan.workspace,
|
|
627
|
+
manifest.entries,
|
|
628
|
+
plan.paths,
|
|
629
|
+
options.signal,
|
|
630
|
+
manifest.version === 2 ? manifest.git.objectFormat : undefined,
|
|
631
|
+
)
|
|
322
632
|
} catch (error) {
|
|
323
633
|
const primaryError = errorMessage(error)
|
|
324
634
|
let journalWarning: string | undefined
|
|
@@ -413,13 +723,13 @@ export class ChangeLedgerEngine {
|
|
|
413
723
|
}
|
|
414
724
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
415
725
|
await this.assertStorageSeparated(source.state.root)
|
|
416
|
-
const release = await this.
|
|
726
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
417
727
|
try {
|
|
418
|
-
await this.store.readManifest(source.state.root, options.restorePointId)
|
|
728
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
419
729
|
if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
|
|
420
730
|
throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal')
|
|
421
731
|
}
|
|
422
|
-
await this.
|
|
732
|
+
await this.deleteManifestWithGit(manifest, options.signal)
|
|
423
733
|
const gc = await this.store.collectGarbage(source.state.root)
|
|
424
734
|
return { restorePointId: options.restorePointId, ...gc }
|
|
425
735
|
} finally {
|
|
@@ -432,19 +742,153 @@ export class ChangeLedgerEngine {
|
|
|
432
742
|
await this.ready
|
|
433
743
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
434
744
|
await this.assertStorageSeparated(source.state.root)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
745
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
746
|
+
try {
|
|
747
|
+
return (await this.store.listOperations(source.state.root))
|
|
748
|
+
.filter((operation): operation is RestoreOperation & { state: 'interrupted' | 'recovery-required' } =>
|
|
749
|
+
operation.state === 'interrupted' || operation.state === 'recovery-required')
|
|
750
|
+
.map((operation) => ({
|
|
751
|
+
operationId: operation.id,
|
|
752
|
+
restorePointId: operation.restorePointId,
|
|
753
|
+
rescuePointId: operation.rescuePointId,
|
|
754
|
+
state: operation.state,
|
|
755
|
+
paths: operation.paths,
|
|
756
|
+
startedAt: operation.startedAt,
|
|
757
|
+
...(operation.error === undefined ? {} : { error: operation.error }),
|
|
758
|
+
...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
|
|
759
|
+
}))
|
|
760
|
+
} finally {
|
|
761
|
+
await release()
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Inventory every workspace this storage root has ever persisted state for. */
|
|
766
|
+
async listWorkspaces(options: { readonly signal?: AbortSignal } = {}): Promise<WorkspaceOverview[]> {
|
|
767
|
+
await this.ready
|
|
768
|
+
const groups = new Map<string, { restorePoints: RestorePointSummary[]; recoveryCount: number }>()
|
|
769
|
+
const groupOf = (workspace: string): { restorePoints: RestorePointSummary[]; recoveryCount: number } => {
|
|
770
|
+
const existing = groups.get(workspace)
|
|
771
|
+
if (existing !== undefined) return existing
|
|
772
|
+
const created = { restorePoints: [], recoveryCount: 0 }
|
|
773
|
+
groups.set(workspace, created)
|
|
774
|
+
return created
|
|
775
|
+
}
|
|
776
|
+
for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
|
|
777
|
+
for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
|
|
778
|
+
groupOf(manifest.workspace).restorePoints.push(summarize(manifest))
|
|
779
|
+
}
|
|
780
|
+
for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
|
|
781
|
+
if (operation.state === 'interrupted' || operation.state === 'recovery-required') {
|
|
782
|
+
groupOf(operation.workspace).recoveryCount += 1
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return [...groups.entries()]
|
|
787
|
+
.map(([workspace, group]) => ({
|
|
788
|
+
workspace,
|
|
789
|
+
restorePoints: [...group.restorePoints].sort(
|
|
790
|
+
(left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id),
|
|
791
|
+
),
|
|
792
|
+
totalBytes: group.restorePoints.reduce((total, point) => total + point.totalBytes, 0),
|
|
793
|
+
recoveryCount: group.recoveryCount,
|
|
447
794
|
}))
|
|
795
|
+
.sort((left, right) => left.workspace.localeCompare(right.workspace))
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Delete unprotected restore points recorded for one workspace and collect unused blobs. */
|
|
799
|
+
async purgeWorkspace(options: {
|
|
800
|
+
readonly workspace: string
|
|
801
|
+
/** Restrict the purge to these restore points; protection rules still apply. */
|
|
802
|
+
readonly restorePointIds?: readonly string[]
|
|
803
|
+
readonly signal?: AbortSignal
|
|
804
|
+
}): Promise<WorkspacePurgeReport> {
|
|
805
|
+
await this.ready
|
|
806
|
+
const restorePointIds = options.restorePointIds === undefined ? undefined : new Set(options.restorePointIds)
|
|
807
|
+
const targetDirs = new Set<string>()
|
|
808
|
+
for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
|
|
809
|
+
for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
|
|
810
|
+
if (manifest.workspace === options.workspace
|
|
811
|
+
&& (restorePointIds === undefined || restorePointIds.has(manifest.id))) {
|
|
812
|
+
targetDirs.add(workspaceDir)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
|
|
816
|
+
if (operation.workspace === options.workspace) targetDirs.add(workspaceDir)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
let deletedRestorePoints = 0
|
|
820
|
+
let retainedRestorePoints = 0
|
|
821
|
+
let deletedBlobs = 0
|
|
822
|
+
let retainedBlobs = 0
|
|
823
|
+
for (const workspaceDir of targetDirs) {
|
|
824
|
+
const result = await this.purgeWorkspaceDir(workspaceDir, options.workspace, restorePointIds, options.signal)
|
|
825
|
+
deletedRestorePoints += result.deletedRestorePoints
|
|
826
|
+
retainedRestorePoints += result.retainedRestorePoints
|
|
827
|
+
deletedBlobs += result.deletedBlobs
|
|
828
|
+
retainedBlobs += result.retainedBlobs
|
|
829
|
+
}
|
|
830
|
+
return { workspace: options.workspace, deletedRestorePoints, retainedRestorePoints, deletedBlobs, retainedBlobs }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private async purgeWorkspaceDir(
|
|
834
|
+
workspaceDir: string,
|
|
835
|
+
workspace: string,
|
|
836
|
+
restorePointIds: ReadonlySet<string> | undefined,
|
|
837
|
+
signal?: AbortSignal,
|
|
838
|
+
): Promise<{ deletedRestorePoints: number; retainedRestorePoints: number; deletedBlobs: number; retainedBlobs: number }> {
|
|
839
|
+
// A live repository needs both its shared Git lock and durable directory lock.
|
|
840
|
+
// A missing path, or one now nested under a different repository, cannot expose
|
|
841
|
+
// its old Git namespace but still takes the durable lock before storage cleanup.
|
|
842
|
+
let liveRepository = false
|
|
843
|
+
let release: () => Promise<void>
|
|
844
|
+
try {
|
|
845
|
+
const root = await discoverRepositoryRoot(workspace, signal)
|
|
846
|
+
if (root === workspace) {
|
|
847
|
+
await this.assertStorageSeparated(root)
|
|
848
|
+
release = await this.acquireWorkspace(root, signal)
|
|
849
|
+
liveRepository = true
|
|
850
|
+
} else {
|
|
851
|
+
release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
|
|
852
|
+
}
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (!(error instanceof ChangeLedgerError) || error.code !== 'WORKSPACE_NOT_FOUND') throw error
|
|
855
|
+
release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
|
|
856
|
+
}
|
|
857
|
+
let deletedRestorePoints = 0
|
|
858
|
+
let retainedRestorePoints = 0
|
|
859
|
+
try {
|
|
860
|
+
const manifests = (await this.store.listManifestsInDir(workspaceDir, signal))
|
|
861
|
+
.filter((manifest) => manifest.workspace === workspace
|
|
862
|
+
&& (restorePointIds === undefined || restorePointIds.has(manifest.id)))
|
|
863
|
+
const protectedIds = new Set<string>()
|
|
864
|
+
for (const operation of await this.store.listOperationsInDir(workspaceDir, signal)) {
|
|
865
|
+
if (operation.state === 'running' || operation.state === 'rollback-running'
|
|
866
|
+
|| operation.state === 'interrupted' || operation.state === 'recovery-required') {
|
|
867
|
+
protectedIds.add(operation.restorePointId)
|
|
868
|
+
protectedIds.add(operation.rescuePointId)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
for (const journal of await this.store.listGitCheckpointJournalsInDir(workspaceDir, signal)) {
|
|
872
|
+
protectedIds.add(journal.restorePointId)
|
|
873
|
+
}
|
|
874
|
+
for (const manifest of manifests) {
|
|
875
|
+
if (protectedIds.has(manifest.id)) {
|
|
876
|
+
retainedRestorePoints += 1
|
|
877
|
+
continue
|
|
878
|
+
}
|
|
879
|
+
if (liveRepository) {
|
|
880
|
+
await this.deleteManifestWithGit(manifest, signal)
|
|
881
|
+
} else {
|
|
882
|
+
await this.store.deleteManifestInDir(workspaceDir, manifest.id)
|
|
883
|
+
}
|
|
884
|
+
deletedRestorePoints += 1
|
|
885
|
+
}
|
|
886
|
+
if (restorePointIds === undefined) await this.store.clearTurnOutcomesInDir(workspaceDir)
|
|
887
|
+
const gc = await this.store.collectGarbageInDir(workspaceDir, signal)
|
|
888
|
+
return { deletedRestorePoints, retainedRestorePoints, ...gc }
|
|
889
|
+
} finally {
|
|
890
|
+
await release()
|
|
891
|
+
}
|
|
448
892
|
}
|
|
449
893
|
|
|
450
894
|
private async createLocked(options: {
|
|
@@ -457,6 +901,7 @@ export class ChangeLedgerEngine {
|
|
|
457
901
|
readonly turnStartSeq?: number
|
|
458
902
|
readonly turnEndSeq?: number
|
|
459
903
|
readonly signal?: AbortSignal
|
|
904
|
+
readonly deferGarbageCollection?: boolean
|
|
460
905
|
}): Promise<RestorePointManifest> {
|
|
461
906
|
const existing = await this.store.listManifests(options.cwd)
|
|
462
907
|
const durableUserPoints = existing.filter(point => point.kind !== 'turn')
|
|
@@ -466,6 +911,11 @@ export class ChangeLedgerEngine {
|
|
|
466
911
|
`workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`,
|
|
467
912
|
)
|
|
468
913
|
}
|
|
914
|
+
if (options.deferGarbageCollection === true) {
|
|
915
|
+
await this.store.reconcileSnapshotCleanup(options.cwd, options.signal)
|
|
916
|
+
throwIfAborted(options.signal)
|
|
917
|
+
await this.store.writeSnapshotCleanup(options.cwd)
|
|
918
|
+
}
|
|
469
919
|
try {
|
|
470
920
|
const tree = await captureStableTree({
|
|
471
921
|
cwd: options.cwd,
|
|
@@ -492,15 +942,21 @@ export class ChangeLedgerEngine {
|
|
|
492
942
|
entries: tree.entries,
|
|
493
943
|
restoreCount: 0,
|
|
494
944
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
.
|
|
499
|
-
|
|
500
|
-
|
|
945
|
+
if (options.deferGarbageCollection !== true) {
|
|
946
|
+
await this.store.collectGarbage(
|
|
947
|
+
options.cwd,
|
|
948
|
+
Object.values(manifest.entries)
|
|
949
|
+
.filter((entry): entry is SnapshotEntry & { readonly kind: 'file' } => entry.kind === 'file' && entry.provider !== 'git')
|
|
950
|
+
.map((entry) => entry.blob),
|
|
951
|
+
)
|
|
952
|
+
}
|
|
501
953
|
await this.store.writeManifest(manifest)
|
|
954
|
+
if (options.deferGarbageCollection === true) {
|
|
955
|
+
await this.store.deleteSnapshotCleanup(options.cwd).catch(() => undefined)
|
|
956
|
+
}
|
|
502
957
|
return manifest
|
|
503
958
|
} catch (error) {
|
|
959
|
+
if (options.deferGarbageCollection === true) throw error
|
|
504
960
|
try {
|
|
505
961
|
await this.store.collectGarbage(options.cwd)
|
|
506
962
|
} catch (cleanupError) {
|
|
@@ -519,11 +975,14 @@ export class ChangeLedgerEngine {
|
|
|
519
975
|
desiredEntries: Readonly<Record<string, SnapshotEntry>>,
|
|
520
976
|
paths: readonly string[],
|
|
521
977
|
signal?: AbortSignal,
|
|
978
|
+
expectedEntries?: Readonly<Record<string, SnapshotEntry | null>>,
|
|
522
979
|
): Promise<void> {
|
|
523
980
|
const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst)
|
|
524
981
|
for (const path of deletions) {
|
|
525
982
|
throwIfAborted(signal)
|
|
526
983
|
const target = resolveWorkspacePath(workspace, path)
|
|
984
|
+
if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
|
|
985
|
+
await assertSafeParents(workspace, target)
|
|
527
986
|
await removeRestoreTarget(target)
|
|
528
987
|
await pruneEmptyParents(workspace, target)
|
|
529
988
|
}
|
|
@@ -534,6 +993,7 @@ export class ChangeLedgerEngine {
|
|
|
534
993
|
const entry = desiredEntries[path]
|
|
535
994
|
if (entry === undefined) continue
|
|
536
995
|
const target = resolveWorkspacePath(workspace, path)
|
|
996
|
+
if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
|
|
537
997
|
await ensureSafeParents(workspace, target)
|
|
538
998
|
try {
|
|
539
999
|
const info = await lstat(target)
|
|
@@ -542,10 +1002,14 @@ export class ChangeLedgerEngine {
|
|
|
542
1002
|
if (!isNodeError(error, 'ENOENT')) throw error
|
|
543
1003
|
}
|
|
544
1004
|
if (entry.kind === 'file') {
|
|
545
|
-
const content =
|
|
1005
|
+
const content = entry.provider === 'git'
|
|
1006
|
+
? await readGitBlob(workspace, entry.blob, entry.size, signal)
|
|
1007
|
+
: await this.store.readBlob(workspace, entry.blob)
|
|
546
1008
|
if (content.length !== entry.size) {
|
|
547
1009
|
throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`)
|
|
548
1010
|
}
|
|
1011
|
+
if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
|
|
1012
|
+
else await assertSafeParents(workspace, target)
|
|
549
1013
|
await replaceRegularFile(target, content, entry.mode)
|
|
550
1014
|
} else {
|
|
551
1015
|
await replaceSymbolicLink(target, entry.target)
|
|
@@ -553,18 +1017,60 @@ export class ChangeLedgerEngine {
|
|
|
553
1017
|
}
|
|
554
1018
|
}
|
|
555
1019
|
|
|
1020
|
+
private async assertPathFresh(
|
|
1021
|
+
workspace: string,
|
|
1022
|
+
path: string,
|
|
1023
|
+
expected: SnapshotEntry | null | undefined,
|
|
1024
|
+
signal?: AbortSignal,
|
|
1025
|
+
): Promise<void> {
|
|
1026
|
+
const target = resolveWorkspacePath(workspace, path)
|
|
1027
|
+
await assertSafeParents(workspace, target)
|
|
1028
|
+
let current: SnapshotEntry | undefined
|
|
1029
|
+
try {
|
|
1030
|
+
current = await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal)
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
|
|
1033
|
+
throw new ChangeLedgerError('PLAN_STALE', `workspace path changed type after planning: ${JSON.stringify(path)}`, { cause: error })
|
|
1034
|
+
}
|
|
1035
|
+
throw error
|
|
1036
|
+
}
|
|
1037
|
+
if (!entriesEqual(expected === null ? undefined : expected, current)) {
|
|
1038
|
+
throw new ChangeLedgerError('PLAN_STALE', `workspace changed immediately before restore at ${JSON.stringify(path)}; inspect and plan again`)
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
556
1042
|
private async verifyPaths(
|
|
557
1043
|
workspace: string,
|
|
558
1044
|
desiredEntries: Readonly<Record<string, SnapshotEntry>>,
|
|
559
1045
|
paths: readonly string[],
|
|
560
1046
|
signal?: AbortSignal,
|
|
1047
|
+
gitObjectFormat?: 'sha1' | 'sha256',
|
|
561
1048
|
): Promise<void> {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1049
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1050
|
+
const first = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
|
|
1051
|
+
const second = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
|
|
1052
|
+
if (!paths.every(path => entriesEqual(first.get(path), second.get(path)))) continue
|
|
1053
|
+
for (const path of paths) {
|
|
1054
|
+
if (!entriesEqual(desiredEntries[path], second.get(path))) {
|
|
1055
|
+
throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`)
|
|
1056
|
+
}
|
|
566
1057
|
}
|
|
1058
|
+
return
|
|
567
1059
|
}
|
|
1060
|
+
throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', 'restored paths did not remain stable during post-verification')
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private async captureSelectedEntries(
|
|
1064
|
+
workspace: string,
|
|
1065
|
+
paths: readonly string[],
|
|
1066
|
+
signal?: AbortSignal,
|
|
1067
|
+
gitObjectFormat?: 'sha1' | 'sha256',
|
|
1068
|
+
): Promise<ReadonlyMap<string, SnapshotEntry | undefined>> {
|
|
1069
|
+
const entries = new Map<string, SnapshotEntry | undefined>()
|
|
1070
|
+
for (const path of paths) {
|
|
1071
|
+
entries.set(path, await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal, gitObjectFormat))
|
|
1072
|
+
}
|
|
1073
|
+
return entries
|
|
568
1074
|
}
|
|
569
1075
|
|
|
570
1076
|
private async assertStorageSeparated(workspace: string): Promise<void> {
|
|
@@ -585,6 +1091,9 @@ export class ChangeLedgerEngine {
|
|
|
585
1091
|
}
|
|
586
1092
|
}
|
|
587
1093
|
|
|
1094
|
+
/** Mutable view used only by `updateConfig` to swap fields on the shared resolved object. */
|
|
1095
|
+
type WritableResolvedConfig = { -readonly [K in keyof ResolvedChangeLedgerConfig]: ResolvedChangeLedgerConfig[K] }
|
|
1096
|
+
|
|
588
1097
|
/** Resolve and validate every deployment-varying configuration value. */
|
|
589
1098
|
export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerConfig {
|
|
590
1099
|
const home = homedir()
|
|
@@ -606,11 +1115,22 @@ export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerC
|
|
|
606
1115
|
maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
|
|
607
1116
|
planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
|
|
608
1117
|
staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
|
|
1118
|
+
turnCheckpointMode: checkpointMode(config.turnCheckpointMode ?? DEFAULTS.turnCheckpointMode),
|
|
1119
|
+
turnCheckpointTimeoutMs: positiveInteger(
|
|
1120
|
+
config.turnCheckpointTimeoutMs ?? DEFAULTS.turnCheckpointTimeoutMs,
|
|
1121
|
+
'turnCheckpointTimeoutMs',
|
|
1122
|
+
),
|
|
1123
|
+
turnCheckpointMaxNewBytes: positiveInteger(
|
|
1124
|
+
config.turnCheckpointMaxNewBytes ?? DEFAULTS.turnCheckpointMaxNewBytes,
|
|
1125
|
+
'turnCheckpointMaxNewBytes',
|
|
1126
|
+
),
|
|
1127
|
+
turnCheckpointTrust: checkpointTrust(config.turnCheckpointTrust ?? DEFAULTS.turnCheckpointTrust),
|
|
609
1128
|
}
|
|
610
1129
|
}
|
|
611
1130
|
|
|
612
1131
|
function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
613
1132
|
return {
|
|
1133
|
+
format: manifest.version,
|
|
614
1134
|
id: manifest.id,
|
|
615
1135
|
kind: manifest.kind,
|
|
616
1136
|
workspace: manifest.workspace,
|
|
@@ -630,6 +1150,7 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
|
630
1150
|
...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
|
|
631
1151
|
...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
|
|
632
1152
|
stagedPathCount: manifest.repository.stagedPaths.length,
|
|
1153
|
+
...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
|
|
633
1154
|
}
|
|
634
1155
|
}
|
|
635
1156
|
|
|
@@ -675,6 +1196,19 @@ function assertPlanRepositoryFresh(
|
|
|
675
1196
|
}
|
|
676
1197
|
}
|
|
677
1198
|
|
|
1199
|
+
function assertJournalMatchesManifest(journal: GitCheckpointJournal, manifest: RestorePointManifest): asserts manifest is RestorePointManifestV2 {
|
|
1200
|
+
if (manifest.version !== 2
|
|
1201
|
+
|| manifest.workspace !== journal.workspace
|
|
1202
|
+
|| manifest.id !== journal.restorePointId
|
|
1203
|
+
|| manifest.repository.commonDir !== journal.commonDir
|
|
1204
|
+
|| manifest.git.storeId !== journal.storeId
|
|
1205
|
+
|| manifest.git.worktreeId !== journal.worktreeId
|
|
1206
|
+
|| manifest.git.ref !== journal.ref
|
|
1207
|
+
|| manifest.git.commit !== journal.commit) {
|
|
1208
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal does not match restore point ${journal.restorePointId}`)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
678
1212
|
function selectChanges(changes: readonly WorkspaceChange[], requested?: readonly string[]): WorkspaceChange[] {
|
|
679
1213
|
if (requested === undefined || requested.length === 0) return [...changes]
|
|
680
1214
|
const normalized = requested.map(validateRelativePath)
|
|
@@ -753,10 +1287,45 @@ function requireNonEmptyString(value: string, name: string): string {
|
|
|
753
1287
|
return value
|
|
754
1288
|
}
|
|
755
1289
|
|
|
1290
|
+
function checkpointMode(value: ChangeLedgerConfig['turnCheckpointMode']): ResolvedChangeLedgerConfig['turnCheckpointMode'] {
|
|
1291
|
+
if (value !== 'off' && value !== 'git-native' && value !== 'legacy') {
|
|
1292
|
+
throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointMode must be off, git-native, or legacy')
|
|
1293
|
+
}
|
|
1294
|
+
return value
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function checkpointTrust(value: ChangeLedgerConfig['turnCheckpointTrust']): ResolvedChangeLedgerConfig['turnCheckpointTrust'] {
|
|
1298
|
+
if (value !== 'fast' && value !== 'strict') {
|
|
1299
|
+
throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointTrust must be fast or strict')
|
|
1300
|
+
}
|
|
1301
|
+
return value
|
|
1302
|
+
}
|
|
1303
|
+
|
|
756
1304
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
757
1305
|
if (signal?.aborted === true) throw signal.reason
|
|
758
1306
|
}
|
|
759
1307
|
|
|
1308
|
+
async function waitWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
1309
|
+
if (signal === undefined) return promise
|
|
1310
|
+
signal.throwIfAborted()
|
|
1311
|
+
let rejectAbort: ((reason?: unknown) => void) | undefined
|
|
1312
|
+
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
|
1313
|
+
const onAbort = () => rejectAbort?.(signal.reason)
|
|
1314
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
1315
|
+
try {
|
|
1316
|
+
return await Promise.race([promise, aborted])
|
|
1317
|
+
} finally {
|
|
1318
|
+
signal.removeEventListener('abort', onAbort)
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function checkpointDeadlineError(error: unknown, timeoutMs: number, deadline: AbortSignal, callerSignal?: AbortSignal): unknown {
|
|
1323
|
+
if (callerSignal?.aborted !== true && deadline.aborted) {
|
|
1324
|
+
return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error })
|
|
1325
|
+
}
|
|
1326
|
+
return error
|
|
1327
|
+
}
|
|
1328
|
+
|
|
760
1329
|
function depth(path: string): number {
|
|
761
1330
|
return path.split('/').length
|
|
762
1331
|
}
|