@anionex/dsh-turn-rewind 0.1.1 → 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 +295 -22
- 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 +337 -22
- 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/rewind-host.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import { createDeadline } from './deadline.js'
|
|
3
4
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
4
5
|
import type { ChangeLedgerEngine } from './engine.js'
|
|
5
6
|
import { discoverRepositoryRoot } from './git.js'
|
|
@@ -114,6 +115,7 @@ export class TurnCheckpointCoordinator {
|
|
|
114
115
|
private readonly captures = new Map<string, Promise<void>>()
|
|
115
116
|
private readonly pending = new Set<string>()
|
|
116
117
|
private readonly failures = new Map<string, string>()
|
|
118
|
+
private readonly skips = new Map<string, string>()
|
|
117
119
|
private readonly workspaceTails = new Map<string, Promise<void>>()
|
|
118
120
|
|
|
119
121
|
constructor(private readonly engine: ChangeLedgerEngine) {}
|
|
@@ -127,9 +129,11 @@ export class TurnCheckpointCoordinator {
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
/** Current capture state for a session turn when no durable checkpoint exists yet. */
|
|
130
|
-
state(sessionId: string, turn: number): { readonly status: 'pending' | 'failed' | 'missing'; readonly error?: string } {
|
|
132
|
+
state(sessionId: string, turn: number): { readonly status: 'pending' | 'failed' | 'skipped' | 'missing'; readonly error?: string; readonly reason?: string } {
|
|
131
133
|
const key = checkpointKey(sessionId, turn)
|
|
132
134
|
if (this.pending.has(key)) return { status: 'pending' }
|
|
135
|
+
const reason = this.skips.get(key)
|
|
136
|
+
if (reason !== undefined) return { status: 'skipped', reason }
|
|
133
137
|
const error = this.failures.get(key)
|
|
134
138
|
return error === undefined ? { status: 'missing' } : { status: 'failed', error }
|
|
135
139
|
}
|
|
@@ -141,48 +145,88 @@ export class TurnCheckpointCoordinator {
|
|
|
141
145
|
signal: AbortSignal,
|
|
142
146
|
): Promise<void> {
|
|
143
147
|
const key = checkpointKey(agent.id, turn)
|
|
144
|
-
const existing = this.captures.get(key)
|
|
145
|
-
if (existing !== undefined) {
|
|
146
|
-
await existing
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
148
|
const cwd = agent.session.header.cwd
|
|
150
149
|
if (cwd === undefined) return
|
|
151
150
|
const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn)
|
|
152
151
|
if (start === undefined) {
|
|
153
|
-
this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
|
|
152
|
+
await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
|
|
154
153
|
return
|
|
155
154
|
}
|
|
156
|
-
this.
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
155
|
+
const timeoutMs = this.engine.config.turnCheckpointTimeoutMs
|
|
156
|
+
const outcomeDeadline = createDeadline(timeoutMs)
|
|
157
|
+
const outcomeSignal = AbortSignal.any([signal, outcomeDeadline.signal])
|
|
158
|
+
const reserveMs = Math.min(250, Math.max(10, Math.ceil(timeoutMs / 5)))
|
|
159
|
+
const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs))
|
|
160
|
+
const captureSignal = AbortSignal.any([signal, captureDeadline.signal])
|
|
161
|
+
try {
|
|
162
|
+
const existing = this.captures.get(key)
|
|
163
|
+
if (existing !== undefined) {
|
|
164
|
+
await waitWithSignal(existing, captureSignal).catch(() => undefined)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
this.pending.add(key)
|
|
168
|
+
this.failures.delete(key)
|
|
169
|
+
this.skips.delete(key)
|
|
170
|
+
const capture = (async () => {
|
|
171
|
+
try {
|
|
172
|
+
const workspace = await discoverRepositoryRoot(cwd, captureSignal)
|
|
173
|
+
await this.serializeWorkspace(workspace, captureSignal, async () => {
|
|
174
|
+
try {
|
|
175
|
+
await this.engine.createTurnCheckpoint({
|
|
176
|
+
cwd: workspace,
|
|
177
|
+
sessionId: agent.id,
|
|
178
|
+
turn,
|
|
179
|
+
turnStartSeq: start.seq,
|
|
180
|
+
signal: captureSignal,
|
|
181
|
+
})
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await this.recordFailure(
|
|
184
|
+
ctx,
|
|
185
|
+
agent.id,
|
|
186
|
+
turn,
|
|
187
|
+
checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal),
|
|
188
|
+
{ cwd: workspace, turnStartSeq: start.seq },
|
|
189
|
+
)
|
|
190
|
+
}
|
|
168
191
|
})
|
|
169
|
-
})
|
|
192
|
+
} catch (error) {
|
|
193
|
+
await this.recordFailure(
|
|
194
|
+
ctx,
|
|
195
|
+
agent.id,
|
|
196
|
+
turn,
|
|
197
|
+
checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal),
|
|
198
|
+
{ cwd, turnStartSeq: start.seq },
|
|
199
|
+
)
|
|
200
|
+
} finally {
|
|
201
|
+
this.pending.delete(key)
|
|
202
|
+
}
|
|
203
|
+
})()
|
|
204
|
+
this.captures.set(key, capture)
|
|
205
|
+
try {
|
|
206
|
+
await waitWithSignal(capture, outcomeSignal)
|
|
170
207
|
} catch (error) {
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
const bounded = checkpointTimeoutError(error, timeoutMs, outcomeDeadline.signal, signal)
|
|
209
|
+
const message = errorMessage(bounded)
|
|
173
210
|
this.pending.delete(key)
|
|
211
|
+
if (bounded instanceof ChangeLedgerError && isCheckpointSkip(bounded.code)) {
|
|
212
|
+
this.skips.set(key, message)
|
|
213
|
+
} else {
|
|
214
|
+
this.failures.set(key, message)
|
|
215
|
+
}
|
|
174
216
|
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
217
|
+
} finally {
|
|
218
|
+
captureDeadline.cancel()
|
|
219
|
+
outcomeDeadline.cancel()
|
|
220
|
+
}
|
|
178
221
|
}
|
|
179
222
|
|
|
180
223
|
private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
|
|
181
224
|
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
|
|
182
|
-
const current =
|
|
225
|
+
const current = (async () => {
|
|
226
|
+
await waitWithSignal(previous.catch(() => undefined), signal)
|
|
183
227
|
signal.throwIfAborted()
|
|
184
228
|
await task()
|
|
185
|
-
})
|
|
229
|
+
})()
|
|
186
230
|
this.workspaceTails.set(workspace, current)
|
|
187
231
|
try {
|
|
188
232
|
await current
|
|
@@ -191,18 +235,70 @@ export class TurnCheckpointCoordinator {
|
|
|
191
235
|
}
|
|
192
236
|
}
|
|
193
237
|
|
|
194
|
-
private recordFailure(
|
|
238
|
+
private async recordFailure(
|
|
195
239
|
ctx: Pick<Context, 'logger'>,
|
|
196
240
|
sessionId: string,
|
|
197
241
|
turn: number,
|
|
198
242
|
error: unknown,
|
|
199
|
-
|
|
243
|
+
skipContext?: { readonly cwd: string; readonly turnStartSeq: number },
|
|
244
|
+
): Promise<void> {
|
|
200
245
|
const message = errorMessage(error)
|
|
246
|
+
if (error instanceof ChangeLedgerError && isCheckpointSkip(error.code)) {
|
|
247
|
+
this.skips.set(checkpointKey(sessionId, turn), message)
|
|
248
|
+
if (skipContext !== undefined) {
|
|
249
|
+
try {
|
|
250
|
+
await this.engine.recordTurnCheckpointSkip({
|
|
251
|
+
cwd: skipContext.cwd,
|
|
252
|
+
sessionId,
|
|
253
|
+
turn,
|
|
254
|
+
turnStartSeq: skipContext.turnStartSeq,
|
|
255
|
+
reason: message,
|
|
256
|
+
})
|
|
257
|
+
} catch (persistError) {
|
|
258
|
+
ctx.logger.warn(`[turn-rewind] could not persist checkpoint skip for ${sessionId} turn ${String(turn)}: ${errorMessage(persistError)}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
ctx.logger.warn(`[turn-rewind] checkpoint skipped for ${sessionId} turn ${String(turn)}: ${message}`)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
201
264
|
this.failures.set(checkpointKey(sessionId, turn), message)
|
|
202
265
|
ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`)
|
|
203
266
|
}
|
|
204
267
|
}
|
|
205
268
|
|
|
269
|
+
function isCheckpointSkip(code: string): boolean {
|
|
270
|
+
return code === 'TURN_CHECKPOINT_DISABLED'
|
|
271
|
+
|| code === 'TURN_CHECKPOINT_TIMEOUT'
|
|
272
|
+
|| code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT'
|
|
273
|
+
|| code === 'GIT_OBJECT_STORE_UNSUPPORTED'
|
|
274
|
+
|| code === 'GIT_INDEX_UNSUPPORTED'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
278
|
+
signal.throwIfAborted()
|
|
279
|
+
let rejectAbort: ((reason?: unknown) => void) | undefined
|
|
280
|
+
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
|
281
|
+
const onAbort = () => rejectAbort?.(signal.reason)
|
|
282
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
283
|
+
try {
|
|
284
|
+
return await Promise.race([promise, aborted])
|
|
285
|
+
} finally {
|
|
286
|
+
signal.removeEventListener('abort', onAbort)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function checkpointTimeoutError(
|
|
291
|
+
error: unknown,
|
|
292
|
+
timeoutMs: number,
|
|
293
|
+
deadline: AbortSignal,
|
|
294
|
+
callerSignal: AbortSignal,
|
|
295
|
+
): unknown {
|
|
296
|
+
if (!callerSignal.aborted && deadline.aborted) {
|
|
297
|
+
return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error })
|
|
298
|
+
}
|
|
299
|
+
return error
|
|
300
|
+
}
|
|
301
|
+
|
|
206
302
|
/** Register the same-origin preview/apply endpoint consumed by the browser half. */
|
|
207
303
|
export function installRewindHttp(
|
|
208
304
|
ctx: Context,
|
|
@@ -233,7 +329,15 @@ export function createRewindHttpHandler(
|
|
|
233
329
|
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
|
|
234
330
|
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
|
|
235
331
|
if (checkpoint === undefined) {
|
|
236
|
-
|
|
332
|
+
const durableSkip = await engine.findTurnCheckpointSkip({
|
|
333
|
+
cwd: target.cwd,
|
|
334
|
+
sessionId,
|
|
335
|
+
turn: target.turn,
|
|
336
|
+
turnStartSeq: target.turnStartSeq,
|
|
337
|
+
})
|
|
338
|
+
json(response, 200, durableSkip === undefined
|
|
339
|
+
? coordinator.state(sessionId, target.turn)
|
|
340
|
+
: { status: 'skipped', reason: durableSkip.reason })
|
|
237
341
|
return
|
|
238
342
|
}
|
|
239
343
|
const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id })
|
package/src/snapshot.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import type
|
|
3
|
-
import { lstat,
|
|
2
|
+
import { constants, type BigIntStats } from 'node:fs'
|
|
3
|
+
import { lstat, open, readlink } from 'node:fs/promises'
|
|
4
4
|
import { ChangeLedgerError } from './errors.js'
|
|
5
5
|
import { discoverRepository, sameRepositoryFence, type RepositorySnapshotSource } from './git.js'
|
|
6
6
|
import { isNodeError, resolveWorkspacePath } from './path-utils.js'
|
|
@@ -11,10 +11,13 @@ import type {
|
|
|
11
11
|
WorkspaceChange,
|
|
12
12
|
} from './types.js'
|
|
13
13
|
|
|
14
|
+
const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024
|
|
15
|
+
|
|
14
16
|
/** One captured tree, optionally persisted into the blob store. */
|
|
15
17
|
export interface CapturedTree {
|
|
16
18
|
readonly source: RepositorySnapshotSource
|
|
17
19
|
readonly entries: Readonly<Record<string, SnapshotEntry>>
|
|
20
|
+
readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>
|
|
18
21
|
readonly treeHash: string
|
|
19
22
|
readonly fileCount: number
|
|
20
23
|
readonly totalBytes: number
|
|
@@ -25,6 +28,7 @@ export async function captureTree(options: {
|
|
|
25
28
|
readonly cwd: string
|
|
26
29
|
readonly config: ResolvedChangeLedgerConfig
|
|
27
30
|
readonly store?: LedgerStore
|
|
31
|
+
readonly gitObjectFormat?: 'sha1' | 'sha256'
|
|
28
32
|
readonly signal?: AbortSignal
|
|
29
33
|
}): Promise<CapturedTree> {
|
|
30
34
|
throwIfAborted(options.signal)
|
|
@@ -37,11 +41,18 @@ export async function captureTree(options: {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
|
|
44
|
+
const gitObjectFormat = options.gitObjectFormat
|
|
45
|
+
const gitCapture = gitObjectFormat === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: {
|
|
48
|
+
objectFormat: gitObjectFormat,
|
|
49
|
+
entries: Object.create(null) as Record<string, SnapshotEntry>,
|
|
50
|
+
}
|
|
40
51
|
let totalBytes = 0
|
|
41
|
-
|
|
52
|
+
const capturePath = async (path: string): Promise<void> => {
|
|
42
53
|
throwIfAborted(options.signal)
|
|
43
54
|
const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal)
|
|
44
|
-
if (entry === undefined)
|
|
55
|
+
if (entry === undefined) return
|
|
45
56
|
if (entry.kind === 'file') {
|
|
46
57
|
totalBytes += entry.content.length
|
|
47
58
|
if (totalBytes > options.config.maxSnapshotBytes) {
|
|
@@ -54,14 +65,49 @@ export async function captureTree(options: {
|
|
|
54
65
|
await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content)
|
|
55
66
|
}
|
|
56
67
|
entries[path] = entry.snapshot
|
|
57
|
-
|
|
68
|
+
if (gitCapture !== undefined) {
|
|
69
|
+
gitCapture.entries[path] = gitFileSnapshot(entry.snapshot, entry.content, gitCapture.objectFormat)
|
|
70
|
+
}
|
|
71
|
+
return
|
|
58
72
|
}
|
|
59
73
|
entries[path] = entry.snapshot
|
|
74
|
+
if (gitCapture !== undefined) gitCapture.entries[path] = entry.snapshot
|
|
75
|
+
}
|
|
76
|
+
if (gitCapture === undefined) {
|
|
77
|
+
for (const path of source.paths) await capturePath(path)
|
|
78
|
+
} else {
|
|
79
|
+
let next = 0
|
|
80
|
+
let failed = false
|
|
81
|
+
let firstError: unknown = new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'parallel comparison capture failed')
|
|
82
|
+
const concurrency = Math.min(
|
|
83
|
+
32,
|
|
84
|
+
source.paths.length,
|
|
85
|
+
Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)),
|
|
86
|
+
)
|
|
87
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
88
|
+
while (!failed && next < source.paths.length) {
|
|
89
|
+
const index = next
|
|
90
|
+
next += 1
|
|
91
|
+
const path = source.paths[index]
|
|
92
|
+
if (path === undefined) continue
|
|
93
|
+
try {
|
|
94
|
+
await capturePath(path)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!failed) {
|
|
97
|
+
failed = true
|
|
98
|
+
firstError = error
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
await Promise.all(workers)
|
|
104
|
+
if (failed) throw firstError
|
|
60
105
|
}
|
|
61
106
|
|
|
62
107
|
return {
|
|
63
108
|
source,
|
|
64
109
|
entries,
|
|
110
|
+
...(gitCapture === undefined ? {} : { gitEntries: gitCapture.entries }),
|
|
65
111
|
treeHash: hashTree(entries),
|
|
66
112
|
fileCount: Object.keys(entries).length,
|
|
67
113
|
totalBytes,
|
|
@@ -77,12 +123,14 @@ export async function captureStableTree(options: {
|
|
|
77
123
|
readonly cwd: string
|
|
78
124
|
readonly config: ResolvedChangeLedgerConfig
|
|
79
125
|
readonly store?: LedgerStore
|
|
126
|
+
readonly gitObjectFormat?: 'sha1' | 'sha256'
|
|
80
127
|
readonly signal?: AbortSignal
|
|
81
128
|
}): Promise<CapturedTree> {
|
|
82
129
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
83
130
|
const first = await captureTree({
|
|
84
131
|
cwd: options.cwd,
|
|
85
132
|
config: options.config,
|
|
133
|
+
...(options.gitObjectFormat === undefined ? {} : { gitObjectFormat: options.gitObjectFormat }),
|
|
86
134
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
87
135
|
})
|
|
88
136
|
const second = await captureTree(options)
|
|
@@ -100,12 +148,16 @@ export async function captureStableTree(options: {
|
|
|
100
148
|
export function diffTrees(
|
|
101
149
|
before: Readonly<Record<string, SnapshotEntry>>,
|
|
102
150
|
after: Readonly<Record<string, SnapshotEntry>>,
|
|
151
|
+
comparisonBefore: Readonly<Record<string, SnapshotEntry>> = before,
|
|
152
|
+
comparisonAfter: Readonly<Record<string, SnapshotEntry>> = after,
|
|
103
153
|
): WorkspaceChange[] {
|
|
104
154
|
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths)
|
|
105
155
|
const changes: WorkspaceChange[] = []
|
|
106
156
|
for (const path of paths) {
|
|
107
157
|
const left = before[path]
|
|
108
158
|
const right = after[path]
|
|
159
|
+
const comparisonLeft = comparisonBefore[path]
|
|
160
|
+
const comparisonRight = comparisonAfter[path]
|
|
109
161
|
if (left === undefined && right !== undefined) {
|
|
110
162
|
changes.push({ path, kind: 'added', after: right })
|
|
111
163
|
continue
|
|
@@ -114,16 +166,26 @@ export function diffTrees(
|
|
|
114
166
|
changes.push({ path, kind: 'deleted', before: left })
|
|
115
167
|
continue
|
|
116
168
|
}
|
|
117
|
-
if (left === undefined || right === undefined || entriesEqual(
|
|
169
|
+
if (left === undefined || right === undefined || entriesEqual(comparisonLeft, comparisonRight)) continue
|
|
118
170
|
if (left.kind !== right.kind) {
|
|
119
171
|
changes.push({ path, kind: 'type-changed', before: left, after: right })
|
|
120
172
|
continue
|
|
121
173
|
}
|
|
122
|
-
if (left.kind === 'file'
|
|
174
|
+
if (left.kind === 'file'
|
|
175
|
+
&& right.kind === 'file'
|
|
176
|
+
&& comparisonLeft?.kind === 'file'
|
|
177
|
+
&& comparisonRight?.kind === 'file'
|
|
178
|
+
&& comparisonLeft.blob === comparisonRight.blob
|
|
179
|
+
&& left.mode !== right.mode) {
|
|
123
180
|
changes.push({ path, kind: 'mode-changed', before: left, after: right })
|
|
124
181
|
continue
|
|
125
182
|
}
|
|
126
|
-
if (left.kind === 'symlink'
|
|
183
|
+
if (left.kind === 'symlink'
|
|
184
|
+
&& right.kind === 'symlink'
|
|
185
|
+
&& comparisonLeft?.kind === 'symlink'
|
|
186
|
+
&& comparisonRight?.kind === 'symlink'
|
|
187
|
+
&& comparisonLeft.target === comparisonRight.target
|
|
188
|
+
&& left.mode !== right.mode) {
|
|
127
189
|
changes.push({ path, kind: 'mode-changed', before: left, after: right })
|
|
128
190
|
continue
|
|
129
191
|
}
|
|
@@ -159,6 +221,29 @@ export function hashTree(entries: Readonly<Record<string, SnapshotEntry>>): stri
|
|
|
159
221
|
return hash.digest('hex')
|
|
160
222
|
}
|
|
161
223
|
|
|
224
|
+
/** Byte-verify one workspace-relative path without following a final symlink. */
|
|
225
|
+
export async function captureSnapshotEntry(
|
|
226
|
+
root: string,
|
|
227
|
+
path: string,
|
|
228
|
+
maxFileBytes: number,
|
|
229
|
+
signal?: AbortSignal,
|
|
230
|
+
gitObjectFormat?: 'sha1' | 'sha256',
|
|
231
|
+
): Promise<SnapshotEntry | undefined> {
|
|
232
|
+
const entry = await captureEntry(root, path, maxFileBytes, signal)
|
|
233
|
+
if (entry === undefined || entry.kind === 'symlink' || gitObjectFormat === undefined) return entry?.snapshot
|
|
234
|
+
return gitFileSnapshot(entry.snapshot, entry.content, gitObjectFormat)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function gitFileSnapshot(
|
|
238
|
+
snapshot: SnapshotEntry & { readonly kind: 'file' },
|
|
239
|
+
content: Buffer,
|
|
240
|
+
objectFormat: 'sha1' | 'sha256',
|
|
241
|
+
): SnapshotEntry {
|
|
242
|
+
const header = Buffer.from(`blob ${String(content.length)}\0`)
|
|
243
|
+
const blob = createHash(objectFormat).update(header).update(content).digest('hex')
|
|
244
|
+
return { ...snapshot, blob, provider: 'git' }
|
|
245
|
+
}
|
|
246
|
+
|
|
162
247
|
async function captureEntry(
|
|
163
248
|
root: string,
|
|
164
249
|
path: string,
|
|
@@ -193,10 +278,24 @@ async function captureEntry(
|
|
|
193
278
|
`${JSON.stringify(path)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`,
|
|
194
279
|
)
|
|
195
280
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
281
|
+
let handle
|
|
282
|
+
try {
|
|
283
|
+
handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isNodeError(error, 'ELOOP')) continue
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
let content: Buffer
|
|
289
|
+
try {
|
|
290
|
+
const opened = await handle.stat({ bigint: true })
|
|
291
|
+
if (!opened.isFile() || !sameStat(before, opened)) continue
|
|
292
|
+
content = await readBoundedFile(handle, Number(opened.size))
|
|
293
|
+
throwIfAborted(signal)
|
|
294
|
+
const after = await handle.stat({ bigint: true })
|
|
295
|
+
if (!sameStat(opened, after) || BigInt(content.length) !== after.size) continue
|
|
296
|
+
} finally {
|
|
297
|
+
await handle.close()
|
|
298
|
+
}
|
|
200
299
|
const blob = createHash('sha256').update(content).digest('hex')
|
|
201
300
|
return {
|
|
202
301
|
kind: 'file',
|
|
@@ -207,6 +306,17 @@ async function captureEntry(
|
|
|
207
306
|
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`)
|
|
208
307
|
}
|
|
209
308
|
|
|
309
|
+
async function readBoundedFile(handle: Awaited<ReturnType<typeof open>>, expectedSize: number): Promise<Buffer> {
|
|
310
|
+
const buffer = Buffer.allocUnsafe(expectedSize + 1)
|
|
311
|
+
let offset = 0
|
|
312
|
+
while (offset < buffer.length) {
|
|
313
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
|
|
314
|
+
if (bytesRead === 0) break
|
|
315
|
+
offset += bytesRead
|
|
316
|
+
}
|
|
317
|
+
return buffer.subarray(0, offset)
|
|
318
|
+
}
|
|
319
|
+
|
|
210
320
|
function sameStat(left: BigIntStats, right: BigIntStats): boolean {
|
|
211
321
|
return left.dev === right.dev
|
|
212
322
|
&& left.ino === right.ino
|