@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/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,6 +44,7 @@ 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,
|
|
@@ -42,6 +59,10 @@ const DEFAULTS = {
|
|
|
42
59
|
maxSnapshotBytes: 512 * 1024 * 1024,
|
|
43
60
|
planTtlMs: 15 * 60 * 1_000,
|
|
44
61
|
staleLockMs: 30_000,
|
|
62
|
+
turnCheckpointMode: 'legacy',
|
|
63
|
+
turnCheckpointTimeoutMs: 5_000,
|
|
64
|
+
turnCheckpointMaxNewBytes: 32 * 1024 * 1024,
|
|
65
|
+
turnCheckpointTrust: 'fast',
|
|
45
66
|
} as const
|
|
46
67
|
|
|
47
68
|
/** Persistent workspace change-set engine, independent of the DSH tool adapter. */
|
|
@@ -56,7 +77,7 @@ export class ChangeLedgerEngine {
|
|
|
56
77
|
constructor(config: ChangeLedgerConfig = {}) {
|
|
57
78
|
this.config = resolveConfig(config)
|
|
58
79
|
this.store = new LedgerStore(this.config)
|
|
59
|
-
this.ready = this.
|
|
80
|
+
this.ready = this.initializeStore()
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
@@ -64,6 +85,117 @@ export class ChangeLedgerEngine {
|
|
|
64
85
|
return this.ready
|
|
65
86
|
}
|
|
66
87
|
|
|
88
|
+
private async initializeStore(): Promise<number> {
|
|
89
|
+
const restoredOperations = await this.store.initialize()
|
|
90
|
+
return restoredOperations + await this.reconcileGitCheckpointJournals()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async acquireWorkspace(workspace: string, signal?: AbortSignal): Promise<() => Promise<void>> {
|
|
94
|
+
const identity = await ensureGitWorktreeIdentity(workspace, signal)
|
|
95
|
+
if (identity.root !== workspace) {
|
|
96
|
+
throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`)
|
|
97
|
+
}
|
|
98
|
+
return this.store.acquire(workspace, identity.lockPath, {
|
|
99
|
+
commonDir: identity.commonDir,
|
|
100
|
+
gitDir: identity.gitDir,
|
|
101
|
+
worktreeId: identity.worktreeId,
|
|
102
|
+
}, signal)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async reconcileGitCheckpointJournals(): Promise<number> {
|
|
106
|
+
let reconciled = 0
|
|
107
|
+
for (const journal of await this.store.listGitCheckpointJournals()) {
|
|
108
|
+
const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace)
|
|
109
|
+
const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace }
|
|
110
|
+
const release = await this.acquireWorkspace(workspace)
|
|
111
|
+
try {
|
|
112
|
+
const manifest = await this.tryReadManifest(workspace, journal.restorePointId)
|
|
113
|
+
if (journal.action === 'publish') {
|
|
114
|
+
if (manifest === undefined) {
|
|
115
|
+
await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
|
|
116
|
+
} else {
|
|
117
|
+
assertJournalMatchesManifest(activeJournal, manifest)
|
|
118
|
+
await verifyGitCheckpoint(manifest)
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
if (manifest !== undefined) {
|
|
122
|
+
assertJournalMatchesManifest(activeJournal, manifest)
|
|
123
|
+
await this.store.deleteManifest(workspace, journal.restorePointId)
|
|
124
|
+
}
|
|
125
|
+
await deleteGitCheckpointRef(workspace, journal.ref, journal.commit, this.config.storageDir)
|
|
126
|
+
}
|
|
127
|
+
await this.store.deleteGitCheckpointJournal(workspace, journal.restorePointId)
|
|
128
|
+
reconciled += 1
|
|
129
|
+
} finally {
|
|
130
|
+
await release()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return reconciled
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async publishGitManifest(manifest: RestorePointManifestV2, signal?: AbortSignal): Promise<void> {
|
|
137
|
+
let journalWritten = false
|
|
138
|
+
try {
|
|
139
|
+
await this.store.writeGitCheckpointJournal('publish', manifest)
|
|
140
|
+
journalWritten = true
|
|
141
|
+
await publishGitCheckpoint(manifest, this.config.storageDir, signal)
|
|
142
|
+
await verifyGitCheckpoint(manifest, signal)
|
|
143
|
+
await this.store.writeManifest(manifest)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!journalWritten) throw error
|
|
146
|
+
const persisted = await this.tryReadManifest(manifest.workspace, manifest.id)
|
|
147
|
+
if (persisted !== undefined) {
|
|
148
|
+
assertJournalMatchesManifest({
|
|
149
|
+
version: 1,
|
|
150
|
+
action: 'publish',
|
|
151
|
+
storeId: manifest.git.storeId,
|
|
152
|
+
workspace: manifest.workspace,
|
|
153
|
+
commonDir: manifest.repository.commonDir,
|
|
154
|
+
worktreeId: manifest.git.worktreeId,
|
|
155
|
+
restorePointId: manifest.id,
|
|
156
|
+
ref: manifest.git.ref,
|
|
157
|
+
commit: manifest.git.commit,
|
|
158
|
+
createdAt: manifest.createdAt,
|
|
159
|
+
}, persisted)
|
|
160
|
+
await verifyGitCheckpoint(persisted, signal)
|
|
161
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await deleteGitCheckpoint(manifest, this.config.storageDir)
|
|
166
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
|
|
167
|
+
} catch (cleanupError) {
|
|
168
|
+
throw new ChangeLedgerError(
|
|
169
|
+
'SNAPSHOT_CLEANUP_FAILED',
|
|
170
|
+
`Git checkpoint publication failed (${errorMessage(error)}) and journaled ref cleanup also failed (${errorMessage(cleanupError)})`,
|
|
171
|
+
{ cause: error },
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
throw error
|
|
175
|
+
}
|
|
176
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id).catch(() => undefined)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async deleteManifestWithGit(manifest: RestorePointManifest, signal?: AbortSignal): Promise<void> {
|
|
180
|
+
if (manifest.version === LEDGER_FORMAT_VERSION) {
|
|
181
|
+
await this.store.deleteManifest(manifest.workspace, manifest.id)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
await this.store.writeGitCheckpointJournal('delete', manifest)
|
|
185
|
+
await this.store.deleteManifest(manifest.workspace, manifest.id)
|
|
186
|
+
await deleteGitCheckpoint(manifest, this.config.storageDir, signal)
|
|
187
|
+
await this.store.deleteGitCheckpointJournal(manifest.workspace, manifest.id)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async tryReadManifest(workspace: string, id: string): Promise<RestorePointManifest | undefined> {
|
|
191
|
+
try {
|
|
192
|
+
return await this.store.readManifest(workspace, id)
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND') return undefined
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
67
199
|
/** Create a durable restore point for the current Git worktree. */
|
|
68
200
|
async create(options: {
|
|
69
201
|
readonly cwd: string
|
|
@@ -74,7 +206,7 @@ export class ChangeLedgerEngine {
|
|
|
74
206
|
await this.ready
|
|
75
207
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
76
208
|
await this.assertStorageSeparated(source.state.root)
|
|
77
|
-
const release = await this.
|
|
209
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
78
210
|
try {
|
|
79
211
|
const label = normalizeLabel(options.label)
|
|
80
212
|
const manifest = await this.createLocked({
|
|
@@ -98,41 +230,156 @@ export class ChangeLedgerEngine {
|
|
|
98
230
|
readonly turnStartSeq: number
|
|
99
231
|
readonly signal?: AbortSignal
|
|
100
232
|
}): Promise<RestorePointSummary> {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
233
|
+
const deadline = createDeadline(this.config.turnCheckpointTimeoutMs)
|
|
234
|
+
const signal = options.signal === undefined ? deadline.signal : AbortSignal.any([options.signal, deadline.signal])
|
|
235
|
+
try {
|
|
236
|
+
try {
|
|
237
|
+
await waitWithSignal(this.ready, signal)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
|
|
240
|
+
}
|
|
241
|
+
if (this.config.turnCheckpointMode === 'off') {
|
|
242
|
+
throw new ChangeLedgerError('TURN_CHECKPOINT_DISABLED', 'automatic turn checkpoints are disabled')
|
|
243
|
+
}
|
|
244
|
+
if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
|
|
245
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer')
|
|
246
|
+
}
|
|
247
|
+
if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
|
|
248
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer')
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const source = await discoverRepository(options.cwd, signal)
|
|
252
|
+
await this.assertStorageSeparated(source.state.root)
|
|
253
|
+
const release = await this.acquireWorkspace(source.state.root, signal)
|
|
254
|
+
try {
|
|
255
|
+
const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal)
|
|
256
|
+
const duplicate = existing.find(manifest => manifest.kind === 'turn'
|
|
257
|
+
&& manifest.sessionId === options.sessionId
|
|
258
|
+
&& manifest.turn === options.turn
|
|
259
|
+
&& manifest.turnStartSeq === options.turnStartSeq)
|
|
260
|
+
if (duplicate !== undefined) {
|
|
261
|
+
if (duplicate.version === 2) await verifyGitCheckpoint(duplicate, signal)
|
|
262
|
+
await this.store.deleteTurnCheckpointSkip(
|
|
263
|
+
source.state.root,
|
|
264
|
+
options.sessionId,
|
|
265
|
+
options.turn,
|
|
266
|
+
options.turnStartSeq,
|
|
267
|
+
).catch(() => undefined)
|
|
268
|
+
return summarize(duplicate)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
throwIfAborted(signal)
|
|
272
|
+
let manifest: RestorePointManifest
|
|
273
|
+
if (this.config.turnCheckpointMode === 'legacy') {
|
|
274
|
+
manifest = await this.createLocked({
|
|
275
|
+
cwd: source.state.root,
|
|
276
|
+
kind: 'turn',
|
|
277
|
+
sessionId: options.sessionId,
|
|
278
|
+
label: `Before turn ${String(options.turn)} checkpoint`,
|
|
279
|
+
turn: options.turn,
|
|
280
|
+
turnStartSeq: options.turnStartSeq,
|
|
281
|
+
signal,
|
|
282
|
+
deferGarbageCollection: true,
|
|
283
|
+
})
|
|
284
|
+
} else {
|
|
285
|
+
const id = makeId('rp')
|
|
286
|
+
const captured = (await captureGitTurnCheckpoint({
|
|
287
|
+
cwd: source.state.root,
|
|
288
|
+
id,
|
|
289
|
+
sessionId: options.sessionId,
|
|
290
|
+
turn: options.turn,
|
|
291
|
+
turnStartSeq: options.turnStartSeq,
|
|
292
|
+
config: this.config,
|
|
293
|
+
signal,
|
|
294
|
+
})).manifest
|
|
295
|
+
await this.publishGitManifest(captured, signal)
|
|
296
|
+
manifest = captured
|
|
297
|
+
}
|
|
298
|
+
await this.store.deleteTurnCheckpointSkip(
|
|
299
|
+
source.state.root,
|
|
300
|
+
options.sessionId,
|
|
301
|
+
options.turn,
|
|
302
|
+
options.turnStartSeq,
|
|
303
|
+
).catch(() => undefined)
|
|
304
|
+
|
|
305
|
+
if (!signal.aborted) {
|
|
306
|
+
const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
|
|
307
|
+
.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
|
|
308
|
+
for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
|
|
309
|
+
if (signal.aborted) break
|
|
310
|
+
if (await this.store.isReferencedByRecovery(source.state.root, stale.id)) continue
|
|
311
|
+
await this.deleteManifestWithGit(stale, signal).catch(() => undefined)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return summarize(manifest)
|
|
315
|
+
} finally {
|
|
316
|
+
await release()
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw checkpointDeadlineError(error, this.config.turnCheckpointTimeoutMs, deadline.signal, options.signal)
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
deadline.cancel()
|
|
107
323
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
|
|
327
|
+
async recordTurnCheckpointSkip(options: {
|
|
328
|
+
readonly cwd: string
|
|
329
|
+
readonly sessionId: string
|
|
330
|
+
readonly turn: number
|
|
331
|
+
readonly turnStartSeq: number
|
|
332
|
+
readonly reason: string
|
|
333
|
+
readonly signal?: AbortSignal
|
|
334
|
+
}): Promise<void> {
|
|
335
|
+
await waitWithSignal(this.ready, options.signal)
|
|
336
|
+
const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
|
|
337
|
+
await this.assertStorageSeparated(workspace)
|
|
338
|
+
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
111
339
|
try {
|
|
112
|
-
|
|
113
|
-
const
|
|
340
|
+
throwIfAborted(options.signal)
|
|
341
|
+
const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest =>
|
|
342
|
+
manifest.kind === 'turn'
|
|
114
343
|
&& manifest.sessionId === options.sessionId
|
|
115
344
|
&& manifest.turn === options.turn
|
|
116
345
|
&& manifest.turnStartSeq === options.turnStartSeq)
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
346
|
+
if (existing) {
|
|
347
|
+
await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
await this.store.writeTurnCheckpointSkip(workspace, {
|
|
351
|
+
version: 1,
|
|
122
352
|
sessionId: options.sessionId,
|
|
123
|
-
label: `Before turn ${String(options.turn)} checkpoint`,
|
|
124
353
|
turn: options.turn,
|
|
125
354
|
turnStartSeq: options.turnStartSeq,
|
|
126
|
-
|
|
355
|
+
reason: options.reason.slice(0, 2_000),
|
|
356
|
+
createdAt: Date.now(),
|
|
127
357
|
})
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
358
|
+
} finally {
|
|
359
|
+
await release()
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Read a durable skip marker for one exact prompt boundary. */
|
|
364
|
+
async findTurnCheckpointSkip(options: {
|
|
365
|
+
readonly cwd: string
|
|
366
|
+
readonly sessionId: string
|
|
367
|
+
readonly turn: number
|
|
368
|
+
readonly turnStartSeq: number
|
|
369
|
+
readonly signal?: AbortSignal
|
|
370
|
+
}): Promise<{ readonly reason: string } | undefined> {
|
|
371
|
+
await this.ready
|
|
372
|
+
const workspace = await discoverRepositoryRoot(options.cwd, options.signal)
|
|
373
|
+
await this.assertStorageSeparated(workspace)
|
|
374
|
+
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
375
|
+
try {
|
|
376
|
+
const skip = await this.store.readTurnCheckpointSkip(
|
|
377
|
+
workspace,
|
|
378
|
+
options.sessionId,
|
|
379
|
+
options.turn,
|
|
380
|
+
options.turnStartSeq,
|
|
381
|
+
)
|
|
382
|
+
return skip === undefined ? undefined : { reason: skip.reason }
|
|
136
383
|
} finally {
|
|
137
384
|
await release()
|
|
138
385
|
}
|
|
@@ -148,12 +395,18 @@ export class ChangeLedgerEngine {
|
|
|
148
395
|
await this.ready
|
|
149
396
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
150
397
|
await this.assertStorageSeparated(source.state.root)
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
398
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
399
|
+
try {
|
|
400
|
+
const manifest = (await this.store.listManifests(source.state.root)).find(point =>
|
|
401
|
+
point.kind === 'turn'
|
|
402
|
+
&& point.sessionId === options.sessionId
|
|
403
|
+
&& point.turn === options.turn
|
|
404
|
+
&& point.turnStartSeq !== undefined)
|
|
405
|
+
if (manifest?.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
406
|
+
return manifest === undefined ? undefined : summarize(manifest)
|
|
407
|
+
} finally {
|
|
408
|
+
await release()
|
|
409
|
+
}
|
|
157
410
|
}
|
|
158
411
|
|
|
159
412
|
/** List restore points for the current worktree. */
|
|
@@ -166,12 +419,17 @@ export class ChangeLedgerEngine {
|
|
|
166
419
|
await this.ready
|
|
167
420
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
168
421
|
await this.assertStorageSeparated(source.state.root)
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
422
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
423
|
+
try {
|
|
424
|
+
const manifests = await this.store.listManifests(source.state.root)
|
|
425
|
+
return manifests
|
|
426
|
+
.filter((manifest) => manifest.kind === 'user'
|
|
427
|
+
|| (manifest.kind === 'rescue' && options.includeRescue === true)
|
|
428
|
+
|| (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
|
|
429
|
+
.map(summarize)
|
|
430
|
+
} finally {
|
|
431
|
+
await release()
|
|
432
|
+
}
|
|
175
433
|
}
|
|
176
434
|
|
|
177
435
|
/** Compare one restore point with the current worktree. */
|
|
@@ -183,18 +441,31 @@ export class ChangeLedgerEngine {
|
|
|
183
441
|
await this.ready
|
|
184
442
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
185
443
|
await this.assertStorageSeparated(source.state.root)
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
444
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
445
|
+
try {
|
|
446
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
447
|
+
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
448
|
+
const current = await captureStableTree({
|
|
449
|
+
cwd: source.state.root,
|
|
450
|
+
config: this.config,
|
|
451
|
+
...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
|
|
452
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
453
|
+
})
|
|
454
|
+
const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
|
|
455
|
+
if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
|
|
456
|
+
return {
|
|
457
|
+
restorePoint: summarize(manifest),
|
|
458
|
+
currentTreeHash: current.treeHash,
|
|
459
|
+
currentRepository: current.source.state,
|
|
460
|
+
...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
|
|
461
|
+
...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
|
|
462
|
+
...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
|
|
463
|
+
headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
|
|
464
|
+
operationChanged: manifest.repository.operation !== current.source.state.operation,
|
|
465
|
+
changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
|
|
466
|
+
}
|
|
467
|
+
} finally {
|
|
468
|
+
await release()
|
|
198
469
|
}
|
|
199
470
|
}
|
|
200
471
|
|
|
@@ -213,45 +484,58 @@ export class ChangeLedgerEngine {
|
|
|
213
484
|
this.expirePlans()
|
|
214
485
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
215
486
|
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
|
-
|
|
487
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
488
|
+
try {
|
|
489
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
490
|
+
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
491
|
+
const current = await captureStableTree({
|
|
492
|
+
cwd: source.state.root,
|
|
493
|
+
config: this.config,
|
|
494
|
+
...(manifest.version === 2 ? { gitObjectFormat: manifest.git.objectFormat } : {}),
|
|
495
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
496
|
+
})
|
|
497
|
+
const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
|
|
498
|
+
if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
|
|
499
|
+
if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
|
|
500
|
+
throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again')
|
|
501
|
+
}
|
|
502
|
+
if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
|
|
503
|
+
throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again')
|
|
504
|
+
}
|
|
505
|
+
assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
|
|
506
|
+
const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)
|
|
507
|
+
if (changes.length === 0) {
|
|
508
|
+
throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`)
|
|
509
|
+
}
|
|
510
|
+
const selected = selectChanges(changes, options.paths)
|
|
511
|
+
await assertNoUnmanagedRestoreConflicts(
|
|
512
|
+
source.state.root,
|
|
513
|
+
manifest.entries,
|
|
514
|
+
current.entries,
|
|
515
|
+
selected.map((change) => change.path),
|
|
516
|
+
)
|
|
517
|
+
const expected: Record<string, SnapshotEntry | null> = Object.create(null) as Record<string, SnapshotEntry | null>
|
|
518
|
+
for (const change of selected) expected[change.path] = current.entries[change.path] ?? null
|
|
519
|
+
const now = Date.now()
|
|
520
|
+
const plan: RestorePlan = {
|
|
521
|
+
id: makeId('plan'),
|
|
522
|
+
restorePointId: manifest.id,
|
|
523
|
+
workspace: manifest.workspace,
|
|
524
|
+
repository: current.source.state,
|
|
525
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
526
|
+
createdAt: now,
|
|
527
|
+
expiresAt: now + this.config.planTtlMs,
|
|
528
|
+
confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
|
|
529
|
+
allowHeadChange: options.allowHeadChange === true,
|
|
530
|
+
paths: selected.map((change) => change.path),
|
|
531
|
+
changes: selected,
|
|
532
|
+
expected,
|
|
533
|
+
}
|
|
534
|
+
this.plans.set(plan.id, plan)
|
|
535
|
+
return clonePlan(plan)
|
|
536
|
+
} finally {
|
|
537
|
+
await release()
|
|
538
|
+
}
|
|
255
539
|
}
|
|
256
540
|
|
|
257
541
|
/** Apply one approved restore plan, creating a durable rescue point first. */
|
|
@@ -278,9 +562,10 @@ export class ChangeLedgerEngine {
|
|
|
278
562
|
|
|
279
563
|
try {
|
|
280
564
|
await this.assertStorageSeparated(plan.workspace)
|
|
281
|
-
const release = await this.
|
|
565
|
+
const release = await this.acquireWorkspace(plan.workspace, options.signal)
|
|
282
566
|
try {
|
|
283
567
|
const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId)
|
|
568
|
+
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
284
569
|
const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) })
|
|
285
570
|
assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange)
|
|
286
571
|
assertPlanRepositoryFresh(plan.repository, current.source.state)
|
|
@@ -317,8 +602,14 @@ export class ChangeLedgerEngine {
|
|
|
317
602
|
await this.store.writeOperation(operation)
|
|
318
603
|
|
|
319
604
|
try {
|
|
320
|
-
await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal)
|
|
321
|
-
await this.verifyPaths(
|
|
605
|
+
await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal, plan.expected)
|
|
606
|
+
await this.verifyPaths(
|
|
607
|
+
plan.workspace,
|
|
608
|
+
manifest.entries,
|
|
609
|
+
plan.paths,
|
|
610
|
+
options.signal,
|
|
611
|
+
manifest.version === 2 ? manifest.git.objectFormat : undefined,
|
|
612
|
+
)
|
|
322
613
|
} catch (error) {
|
|
323
614
|
const primaryError = errorMessage(error)
|
|
324
615
|
let journalWarning: string | undefined
|
|
@@ -413,13 +704,13 @@ export class ChangeLedgerEngine {
|
|
|
413
704
|
}
|
|
414
705
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
415
706
|
await this.assertStorageSeparated(source.state.root)
|
|
416
|
-
const release = await this.
|
|
707
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
417
708
|
try {
|
|
418
|
-
await this.store.readManifest(source.state.root, options.restorePointId)
|
|
709
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
419
710
|
if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
|
|
420
711
|
throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal')
|
|
421
712
|
}
|
|
422
|
-
await this.
|
|
713
|
+
await this.deleteManifestWithGit(manifest, options.signal)
|
|
423
714
|
const gc = await this.store.collectGarbage(source.state.root)
|
|
424
715
|
return { restorePointId: options.restorePointId, ...gc }
|
|
425
716
|
} finally {
|
|
@@ -432,19 +723,24 @@ export class ChangeLedgerEngine {
|
|
|
432
723
|
await this.ready
|
|
433
724
|
const source = await discoverRepository(options.cwd, options.signal)
|
|
434
725
|
await this.assertStorageSeparated(source.state.root)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
726
|
+
const release = await this.acquireWorkspace(source.state.root, options.signal)
|
|
727
|
+
try {
|
|
728
|
+
return (await this.store.listOperations(source.state.root))
|
|
729
|
+
.filter((operation): operation is RestoreOperation & { state: 'interrupted' | 'recovery-required' } =>
|
|
730
|
+
operation.state === 'interrupted' || operation.state === 'recovery-required')
|
|
731
|
+
.map((operation) => ({
|
|
732
|
+
operationId: operation.id,
|
|
733
|
+
restorePointId: operation.restorePointId,
|
|
734
|
+
rescuePointId: operation.rescuePointId,
|
|
735
|
+
state: operation.state,
|
|
736
|
+
paths: operation.paths,
|
|
737
|
+
startedAt: operation.startedAt,
|
|
738
|
+
...(operation.error === undefined ? {} : { error: operation.error }),
|
|
739
|
+
...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
|
|
740
|
+
}))
|
|
741
|
+
} finally {
|
|
742
|
+
await release()
|
|
743
|
+
}
|
|
448
744
|
}
|
|
449
745
|
|
|
450
746
|
private async createLocked(options: {
|
|
@@ -457,6 +753,7 @@ export class ChangeLedgerEngine {
|
|
|
457
753
|
readonly turnStartSeq?: number
|
|
458
754
|
readonly turnEndSeq?: number
|
|
459
755
|
readonly signal?: AbortSignal
|
|
756
|
+
readonly deferGarbageCollection?: boolean
|
|
460
757
|
}): Promise<RestorePointManifest> {
|
|
461
758
|
const existing = await this.store.listManifests(options.cwd)
|
|
462
759
|
const durableUserPoints = existing.filter(point => point.kind !== 'turn')
|
|
@@ -466,6 +763,11 @@ export class ChangeLedgerEngine {
|
|
|
466
763
|
`workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`,
|
|
467
764
|
)
|
|
468
765
|
}
|
|
766
|
+
if (options.deferGarbageCollection === true) {
|
|
767
|
+
await this.store.reconcileSnapshotCleanup(options.cwd, options.signal)
|
|
768
|
+
throwIfAborted(options.signal)
|
|
769
|
+
await this.store.writeSnapshotCleanup(options.cwd)
|
|
770
|
+
}
|
|
469
771
|
try {
|
|
470
772
|
const tree = await captureStableTree({
|
|
471
773
|
cwd: options.cwd,
|
|
@@ -492,15 +794,21 @@ export class ChangeLedgerEngine {
|
|
|
492
794
|
entries: tree.entries,
|
|
493
795
|
restoreCount: 0,
|
|
494
796
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
.
|
|
499
|
-
|
|
500
|
-
|
|
797
|
+
if (options.deferGarbageCollection !== true) {
|
|
798
|
+
await this.store.collectGarbage(
|
|
799
|
+
options.cwd,
|
|
800
|
+
Object.values(manifest.entries)
|
|
801
|
+
.filter((entry): entry is SnapshotEntry & { readonly kind: 'file' } => entry.kind === 'file' && entry.provider !== 'git')
|
|
802
|
+
.map((entry) => entry.blob),
|
|
803
|
+
)
|
|
804
|
+
}
|
|
501
805
|
await this.store.writeManifest(manifest)
|
|
806
|
+
if (options.deferGarbageCollection === true) {
|
|
807
|
+
await this.store.deleteSnapshotCleanup(options.cwd).catch(() => undefined)
|
|
808
|
+
}
|
|
502
809
|
return manifest
|
|
503
810
|
} catch (error) {
|
|
811
|
+
if (options.deferGarbageCollection === true) throw error
|
|
504
812
|
try {
|
|
505
813
|
await this.store.collectGarbage(options.cwd)
|
|
506
814
|
} catch (cleanupError) {
|
|
@@ -519,11 +827,14 @@ export class ChangeLedgerEngine {
|
|
|
519
827
|
desiredEntries: Readonly<Record<string, SnapshotEntry>>,
|
|
520
828
|
paths: readonly string[],
|
|
521
829
|
signal?: AbortSignal,
|
|
830
|
+
expectedEntries?: Readonly<Record<string, SnapshotEntry | null>>,
|
|
522
831
|
): Promise<void> {
|
|
523
832
|
const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst)
|
|
524
833
|
for (const path of deletions) {
|
|
525
834
|
throwIfAborted(signal)
|
|
526
835
|
const target = resolveWorkspacePath(workspace, path)
|
|
836
|
+
if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
|
|
837
|
+
await assertSafeParents(workspace, target)
|
|
527
838
|
await removeRestoreTarget(target)
|
|
528
839
|
await pruneEmptyParents(workspace, target)
|
|
529
840
|
}
|
|
@@ -534,6 +845,7 @@ export class ChangeLedgerEngine {
|
|
|
534
845
|
const entry = desiredEntries[path]
|
|
535
846
|
if (entry === undefined) continue
|
|
536
847
|
const target = resolveWorkspacePath(workspace, path)
|
|
848
|
+
if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
|
|
537
849
|
await ensureSafeParents(workspace, target)
|
|
538
850
|
try {
|
|
539
851
|
const info = await lstat(target)
|
|
@@ -542,10 +854,14 @@ export class ChangeLedgerEngine {
|
|
|
542
854
|
if (!isNodeError(error, 'ENOENT')) throw error
|
|
543
855
|
}
|
|
544
856
|
if (entry.kind === 'file') {
|
|
545
|
-
const content =
|
|
857
|
+
const content = entry.provider === 'git'
|
|
858
|
+
? await readGitBlob(workspace, entry.blob, entry.size, signal)
|
|
859
|
+
: await this.store.readBlob(workspace, entry.blob)
|
|
546
860
|
if (content.length !== entry.size) {
|
|
547
861
|
throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`)
|
|
548
862
|
}
|
|
863
|
+
if (expectedEntries !== undefined) await this.assertPathFresh(workspace, path, expectedEntries[path], signal)
|
|
864
|
+
else await assertSafeParents(workspace, target)
|
|
549
865
|
await replaceRegularFile(target, content, entry.mode)
|
|
550
866
|
} else {
|
|
551
867
|
await replaceSymbolicLink(target, entry.target)
|
|
@@ -553,18 +869,60 @@ export class ChangeLedgerEngine {
|
|
|
553
869
|
}
|
|
554
870
|
}
|
|
555
871
|
|
|
872
|
+
private async assertPathFresh(
|
|
873
|
+
workspace: string,
|
|
874
|
+
path: string,
|
|
875
|
+
expected: SnapshotEntry | null | undefined,
|
|
876
|
+
signal?: AbortSignal,
|
|
877
|
+
): Promise<void> {
|
|
878
|
+
const target = resolveWorkspacePath(workspace, path)
|
|
879
|
+
await assertSafeParents(workspace, target)
|
|
880
|
+
let current: SnapshotEntry | undefined
|
|
881
|
+
try {
|
|
882
|
+
current = await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal)
|
|
883
|
+
} catch (error) {
|
|
884
|
+
if (error instanceof ChangeLedgerError && error.code === 'UNSUPPORTED_FILE_TYPE') {
|
|
885
|
+
throw new ChangeLedgerError('PLAN_STALE', `workspace path changed type after planning: ${JSON.stringify(path)}`, { cause: error })
|
|
886
|
+
}
|
|
887
|
+
throw error
|
|
888
|
+
}
|
|
889
|
+
if (!entriesEqual(expected === null ? undefined : expected, current)) {
|
|
890
|
+
throw new ChangeLedgerError('PLAN_STALE', `workspace changed immediately before restore at ${JSON.stringify(path)}; inspect and plan again`)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
556
894
|
private async verifyPaths(
|
|
557
895
|
workspace: string,
|
|
558
896
|
desiredEntries: Readonly<Record<string, SnapshotEntry>>,
|
|
559
897
|
paths: readonly string[],
|
|
560
898
|
signal?: AbortSignal,
|
|
899
|
+
gitObjectFormat?: 'sha1' | 'sha256',
|
|
561
900
|
): Promise<void> {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
901
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
902
|
+
const first = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
|
|
903
|
+
const second = await this.captureSelectedEntries(workspace, paths, signal, gitObjectFormat)
|
|
904
|
+
if (!paths.every(path => entriesEqual(first.get(path), second.get(path)))) continue
|
|
905
|
+
for (const path of paths) {
|
|
906
|
+
if (!entriesEqual(desiredEntries[path], second.get(path))) {
|
|
907
|
+
throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`)
|
|
908
|
+
}
|
|
566
909
|
}
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', 'restored paths did not remain stable during post-verification')
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private async captureSelectedEntries(
|
|
916
|
+
workspace: string,
|
|
917
|
+
paths: readonly string[],
|
|
918
|
+
signal?: AbortSignal,
|
|
919
|
+
gitObjectFormat?: 'sha1' | 'sha256',
|
|
920
|
+
): Promise<ReadonlyMap<string, SnapshotEntry | undefined>> {
|
|
921
|
+
const entries = new Map<string, SnapshotEntry | undefined>()
|
|
922
|
+
for (const path of paths) {
|
|
923
|
+
entries.set(path, await captureSnapshotEntry(workspace, path, this.config.maxFileBytes, signal, gitObjectFormat))
|
|
567
924
|
}
|
|
925
|
+
return entries
|
|
568
926
|
}
|
|
569
927
|
|
|
570
928
|
private async assertStorageSeparated(workspace: string): Promise<void> {
|
|
@@ -606,11 +964,22 @@ export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerC
|
|
|
606
964
|
maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
|
|
607
965
|
planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
|
|
608
966
|
staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
|
|
967
|
+
turnCheckpointMode: checkpointMode(config.turnCheckpointMode ?? DEFAULTS.turnCheckpointMode),
|
|
968
|
+
turnCheckpointTimeoutMs: positiveInteger(
|
|
969
|
+
config.turnCheckpointTimeoutMs ?? DEFAULTS.turnCheckpointTimeoutMs,
|
|
970
|
+
'turnCheckpointTimeoutMs',
|
|
971
|
+
),
|
|
972
|
+
turnCheckpointMaxNewBytes: positiveInteger(
|
|
973
|
+
config.turnCheckpointMaxNewBytes ?? DEFAULTS.turnCheckpointMaxNewBytes,
|
|
974
|
+
'turnCheckpointMaxNewBytes',
|
|
975
|
+
),
|
|
976
|
+
turnCheckpointTrust: checkpointTrust(config.turnCheckpointTrust ?? DEFAULTS.turnCheckpointTrust),
|
|
609
977
|
}
|
|
610
978
|
}
|
|
611
979
|
|
|
612
980
|
function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
613
981
|
return {
|
|
982
|
+
format: manifest.version,
|
|
614
983
|
id: manifest.id,
|
|
615
984
|
kind: manifest.kind,
|
|
616
985
|
workspace: manifest.workspace,
|
|
@@ -630,6 +999,7 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
|
630
999
|
...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
|
|
631
1000
|
...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
|
|
632
1001
|
stagedPathCount: manifest.repository.stagedPaths.length,
|
|
1002
|
+
...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
|
|
633
1003
|
}
|
|
634
1004
|
}
|
|
635
1005
|
|
|
@@ -675,6 +1045,19 @@ function assertPlanRepositoryFresh(
|
|
|
675
1045
|
}
|
|
676
1046
|
}
|
|
677
1047
|
|
|
1048
|
+
function assertJournalMatchesManifest(journal: GitCheckpointJournal, manifest: RestorePointManifest): asserts manifest is RestorePointManifestV2 {
|
|
1049
|
+
if (manifest.version !== 2
|
|
1050
|
+
|| manifest.workspace !== journal.workspace
|
|
1051
|
+
|| manifest.id !== journal.restorePointId
|
|
1052
|
+
|| manifest.repository.commonDir !== journal.commonDir
|
|
1053
|
+
|| manifest.git.storeId !== journal.storeId
|
|
1054
|
+
|| manifest.git.worktreeId !== journal.worktreeId
|
|
1055
|
+
|| manifest.git.ref !== journal.ref
|
|
1056
|
+
|| manifest.git.commit !== journal.commit) {
|
|
1057
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git checkpoint journal does not match restore point ${journal.restorePointId}`)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
678
1061
|
function selectChanges(changes: readonly WorkspaceChange[], requested?: readonly string[]): WorkspaceChange[] {
|
|
679
1062
|
if (requested === undefined || requested.length === 0) return [...changes]
|
|
680
1063
|
const normalized = requested.map(validateRelativePath)
|
|
@@ -753,10 +1136,45 @@ function requireNonEmptyString(value: string, name: string): string {
|
|
|
753
1136
|
return value
|
|
754
1137
|
}
|
|
755
1138
|
|
|
1139
|
+
function checkpointMode(value: ChangeLedgerConfig['turnCheckpointMode']): ResolvedChangeLedgerConfig['turnCheckpointMode'] {
|
|
1140
|
+
if (value !== 'off' && value !== 'git-native' && value !== 'legacy') {
|
|
1141
|
+
throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointMode must be off, git-native, or legacy')
|
|
1142
|
+
}
|
|
1143
|
+
return value
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function checkpointTrust(value: ChangeLedgerConfig['turnCheckpointTrust']): ResolvedChangeLedgerConfig['turnCheckpointTrust'] {
|
|
1147
|
+
if (value !== 'fast' && value !== 'strict') {
|
|
1148
|
+
throw new ChangeLedgerError('INVALID_CONFIG', 'turnCheckpointTrust must be fast or strict')
|
|
1149
|
+
}
|
|
1150
|
+
return value
|
|
1151
|
+
}
|
|
1152
|
+
|
|
756
1153
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
757
1154
|
if (signal?.aborted === true) throw signal.reason
|
|
758
1155
|
}
|
|
759
1156
|
|
|
1157
|
+
async function waitWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
1158
|
+
if (signal === undefined) return promise
|
|
1159
|
+
signal.throwIfAborted()
|
|
1160
|
+
let rejectAbort: ((reason?: unknown) => void) | undefined
|
|
1161
|
+
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
|
1162
|
+
const onAbort = () => rejectAbort?.(signal.reason)
|
|
1163
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
1164
|
+
try {
|
|
1165
|
+
return await Promise.race([promise, aborted])
|
|
1166
|
+
} finally {
|
|
1167
|
+
signal.removeEventListener('abort', onAbort)
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function checkpointDeadlineError(error: unknown, timeoutMs: number, deadline: AbortSignal, callerSignal?: AbortSignal): unknown {
|
|
1172
|
+
if (callerSignal?.aborted !== true && deadline.aborted) {
|
|
1173
|
+
return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error })
|
|
1174
|
+
}
|
|
1175
|
+
return error
|
|
1176
|
+
}
|
|
1177
|
+
|
|
760
1178
|
function depth(path: string): number {
|
|
761
1179
|
return path.split('/').length
|
|
762
1180
|
}
|