@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.
@@ -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.pending.add(key)
157
- this.failures.delete(key)
158
- const capture = (async () => {
159
- try {
160
- const workspace = await discoverRepositoryRoot(cwd, signal)
161
- await this.serializeWorkspace(workspace, signal, async () => {
162
- await this.engine.createTurnCheckpoint({
163
- cwd: workspace,
164
- sessionId: agent.id,
165
- turn,
166
- turnStartSeq: start.seq,
167
- signal,
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
- this.recordFailure(ctx, agent.id, turn, error)
172
- } finally {
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
- this.captures.set(key, capture)
177
- await capture
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 = previous.catch(() => undefined).then(async () => {
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
- ): void {
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
- json(response, 200, coordinator.state(sessionId, target.turn))
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 { BigIntStats } from 'node:fs'
3
- import { lstat, readFile, readlink } from 'node:fs/promises'
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
- for (const path of source.paths) {
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) continue
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
- continue
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(left, right)) continue
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' && right.kind === 'file' && left.blob === right.blob && left.mode !== right.mode) {
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' && right.kind === 'symlink' && left.target === right.target && left.mode !== right.mode) {
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
- const content = await readFile(target)
197
- throwIfAborted(signal)
198
- const after = await lstat(target, { bigint: true })
199
- if (!sameStat(before, after) || BigInt(content.length) !== after.size) continue
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