@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.
Files changed (47) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +19 -6
  3. package/SECURITY.md +3 -1
  4. package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
  5. package/docs/assets/turn-rewind-dialog.png +0 -0
  6. package/lib/client.js +291 -22
  7. package/lib/client.js.map +1 -1
  8. package/lib/deadline.js +13 -0
  9. package/lib/engine.js +622 -121
  10. package/lib/git-checkpoint.js +739 -0
  11. package/lib/git.js +295 -22
  12. package/lib/index.js +18 -2
  13. package/lib/path-utils.js +9 -0
  14. package/lib/rewind-host.js +250 -48
  15. package/lib/settings.js +58 -0
  16. package/lib/snapshot.js +113 -13
  17. package/lib/store.js +1361 -119
  18. package/lib/types/client/index.d.ts +74 -7
  19. package/lib/types/deadline.d.ts +6 -0
  20. package/lib/types/engine.d.ts +48 -2
  21. package/lib/types/git-checkpoint.d.ts +46 -0
  22. package/lib/types/git.d.ts +31 -0
  23. package/lib/types/index.d.ts +7 -0
  24. package/lib/types/path-utils.d.ts +2 -0
  25. package/lib/types/rewind-host.d.ts +8 -1
  26. package/lib/types/settings.d.ts +44 -0
  27. package/lib/types/snapshot.d.ts +6 -1
  28. package/lib/types/store.d.ts +90 -6
  29. package/lib/types/types.d.ts +63 -1
  30. package/lib/types.js +2 -0
  31. package/lib/validate.js +68 -9
  32. package/package.json +21 -3
  33. package/scripts/benchmark-git-checkpoint.mjs +149 -0
  34. package/scripts/wrap-client.mjs +18 -0
  35. package/src/client/index.tsx +536 -41
  36. package/src/deadline.ts +18 -0
  37. package/src/engine.ts +695 -126
  38. package/src/git-checkpoint.ts +853 -0
  39. package/src/git.ts +337 -22
  40. package/src/index.ts +21 -2
  41. package/src/path-utils.ts +10 -0
  42. package/src/rewind-host.ts +278 -50
  43. package/src/settings.ts +93 -0
  44. package/src/snapshot.ts +122 -12
  45. package/src/store.ts +1502 -104
  46. package/src/types.ts +69 -1
  47. package/src/validate.ts +67 -7
@@ -1,8 +1,10 @@
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'
7
+ import type { WorkspacePurgeReport } from './types.js'
6
8
 
7
9
  interface SessionEventLike {
8
10
  readonly type: string
@@ -114,6 +116,7 @@ export class TurnCheckpointCoordinator {
114
116
  private readonly captures = new Map<string, Promise<void>>()
115
117
  private readonly pending = new Set<string>()
116
118
  private readonly failures = new Map<string, string>()
119
+ private readonly skips = new Map<string, string>()
117
120
  private readonly workspaceTails = new Map<string, Promise<void>>()
118
121
 
119
122
  constructor(private readonly engine: ChangeLedgerEngine) {}
@@ -127,9 +130,11 @@ export class TurnCheckpointCoordinator {
127
130
  }
128
131
 
129
132
  /** 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 } {
133
+ state(sessionId: string, turn: number): { readonly status: 'pending' | 'failed' | 'skipped' | 'missing'; readonly error?: string; readonly reason?: string } {
131
134
  const key = checkpointKey(sessionId, turn)
132
135
  if (this.pending.has(key)) return { status: 'pending' }
136
+ const reason = this.skips.get(key)
137
+ if (reason !== undefined) return { status: 'skipped', reason }
133
138
  const error = this.failures.get(key)
134
139
  return error === undefined ? { status: 'missing' } : { status: 'failed', error }
135
140
  }
@@ -141,48 +146,88 @@ export class TurnCheckpointCoordinator {
141
146
  signal: AbortSignal,
142
147
  ): Promise<void> {
143
148
  const key = checkpointKey(agent.id, turn)
144
- const existing = this.captures.get(key)
145
- if (existing !== undefined) {
146
- await existing
147
- return
148
- }
149
149
  const cwd = agent.session.header.cwd
150
150
  if (cwd === undefined) return
151
151
  const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn)
152
152
  if (start === undefined) {
153
- this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
153
+ await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
154
154
  return
155
155
  }
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,
156
+ const timeoutMs = this.engine.config.turnCheckpointTimeoutMs
157
+ const outcomeDeadline = createDeadline(timeoutMs)
158
+ const outcomeSignal = AbortSignal.any([signal, outcomeDeadline.signal])
159
+ const reserveMs = Math.min(250, Math.max(10, Math.ceil(timeoutMs / 5)))
160
+ const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs))
161
+ const captureSignal = AbortSignal.any([signal, captureDeadline.signal])
162
+ try {
163
+ const existing = this.captures.get(key)
164
+ if (existing !== undefined) {
165
+ await waitWithSignal(existing, captureSignal).catch(() => undefined)
166
+ return
167
+ }
168
+ this.pending.add(key)
169
+ this.failures.delete(key)
170
+ this.skips.delete(key)
171
+ const capture = (async () => {
172
+ try {
173
+ const workspace = await discoverRepositoryRoot(cwd, captureSignal)
174
+ await this.serializeWorkspace(workspace, captureSignal, async () => {
175
+ try {
176
+ await this.engine.createTurnCheckpoint({
177
+ cwd: workspace,
178
+ sessionId: agent.id,
179
+ turn,
180
+ turnStartSeq: start.seq,
181
+ signal: captureSignal,
182
+ })
183
+ } catch (error) {
184
+ await this.recordFailure(
185
+ ctx,
186
+ agent.id,
187
+ turn,
188
+ checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal),
189
+ { cwd: workspace, turnStartSeq: start.seq },
190
+ )
191
+ }
168
192
  })
169
- })
193
+ } catch (error) {
194
+ await this.recordFailure(
195
+ ctx,
196
+ agent.id,
197
+ turn,
198
+ checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal),
199
+ { cwd, turnStartSeq: start.seq },
200
+ )
201
+ } finally {
202
+ this.pending.delete(key)
203
+ }
204
+ })()
205
+ this.captures.set(key, capture)
206
+ try {
207
+ await waitWithSignal(capture, outcomeSignal)
170
208
  } catch (error) {
171
- this.recordFailure(ctx, agent.id, turn, error)
172
- } finally {
209
+ const bounded = checkpointTimeoutError(error, timeoutMs, outcomeDeadline.signal, signal)
210
+ const message = errorMessage(bounded)
173
211
  this.pending.delete(key)
212
+ if (bounded instanceof ChangeLedgerError && isCheckpointSkip(bounded.code)) {
213
+ this.skips.set(key, message)
214
+ } else {
215
+ this.failures.set(key, message)
216
+ }
174
217
  }
175
- })()
176
- this.captures.set(key, capture)
177
- await capture
218
+ } finally {
219
+ captureDeadline.cancel()
220
+ outcomeDeadline.cancel()
221
+ }
178
222
  }
179
223
 
180
224
  private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
181
225
  const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
182
- const current = previous.catch(() => undefined).then(async () => {
226
+ const current = (async () => {
227
+ await waitWithSignal(previous.catch(() => undefined), signal)
183
228
  signal.throwIfAborted()
184
229
  await task()
185
- })
230
+ })()
186
231
  this.workspaceTails.set(workspace, current)
187
232
  try {
188
233
  await current
@@ -191,18 +236,70 @@ export class TurnCheckpointCoordinator {
191
236
  }
192
237
  }
193
238
 
194
- private recordFailure(
239
+ private async recordFailure(
195
240
  ctx: Pick<Context, 'logger'>,
196
241
  sessionId: string,
197
242
  turn: number,
198
243
  error: unknown,
199
- ): void {
244
+ skipContext?: { readonly cwd: string; readonly turnStartSeq: number },
245
+ ): Promise<void> {
200
246
  const message = errorMessage(error)
247
+ if (error instanceof ChangeLedgerError && isCheckpointSkip(error.code)) {
248
+ this.skips.set(checkpointKey(sessionId, turn), message)
249
+ if (skipContext !== undefined) {
250
+ try {
251
+ await this.engine.recordTurnCheckpointSkip({
252
+ cwd: skipContext.cwd,
253
+ sessionId,
254
+ turn,
255
+ turnStartSeq: skipContext.turnStartSeq,
256
+ reason: message,
257
+ })
258
+ } catch (persistError) {
259
+ ctx.logger.warn(`[turn-rewind] could not persist checkpoint skip for ${sessionId} turn ${String(turn)}: ${errorMessage(persistError)}`)
260
+ }
261
+ }
262
+ ctx.logger.warn(`[turn-rewind] checkpoint skipped for ${sessionId} turn ${String(turn)}: ${message}`)
263
+ return
264
+ }
201
265
  this.failures.set(checkpointKey(sessionId, turn), message)
202
266
  ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`)
203
267
  }
204
268
  }
205
269
 
270
+ function isCheckpointSkip(code: string): boolean {
271
+ return code === 'TURN_CHECKPOINT_DISABLED'
272
+ || code === 'TURN_CHECKPOINT_TIMEOUT'
273
+ || code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT'
274
+ || code === 'GIT_OBJECT_STORE_UNSUPPORTED'
275
+ || code === 'GIT_INDEX_UNSUPPORTED'
276
+ }
277
+
278
+ async function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
279
+ signal.throwIfAborted()
280
+ let rejectAbort: ((reason?: unknown) => void) | undefined
281
+ const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
282
+ const onAbort = () => rejectAbort?.(signal.reason)
283
+ signal.addEventListener('abort', onAbort, { once: true })
284
+ try {
285
+ return await Promise.race([promise, aborted])
286
+ } finally {
287
+ signal.removeEventListener('abort', onAbort)
288
+ }
289
+ }
290
+
291
+ function checkpointTimeoutError(
292
+ error: unknown,
293
+ timeoutMs: number,
294
+ deadline: AbortSignal,
295
+ callerSignal: AbortSignal,
296
+ ): unknown {
297
+ if (!callerSignal.aborted && deadline.aborted) {
298
+ return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error })
299
+ }
300
+ return error
301
+ }
302
+
206
303
  /** Register the same-origin preview/apply endpoint consumed by the browser half. */
207
304
  export function installRewindHttp(
208
305
  ctx: Context,
@@ -216,6 +313,101 @@ export function installRewindHttp(
216
313
  }), 'change-ledger.rewindHttp')
217
314
  }
218
315
 
316
+ export const MANAGE_HTTP_PATH = '/turn-rewind/manage'
317
+
318
+ /** Register the same-origin storage-management endpoint consumed by the settings card. */
319
+ export function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void {
320
+ ctx.effect(() => ctx.webServer.register({
321
+ kind: 'exact',
322
+ path: MANAGE_HTTP_PATH,
323
+ handler: createManageHttpHandler(engine),
324
+ }), 'change-ledger.manageHttp')
325
+ }
326
+
327
+ /** Build the storage-management route as a testable unit. */
328
+ export function createManageHttpHandler(
329
+ engine: ChangeLedgerEngine,
330
+ ): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
331
+ return async (request, response) => {
332
+ try {
333
+ if (request.method === 'GET') {
334
+ const workspaces = await engine.listWorkspaces()
335
+ json(response, 200, {
336
+ storageDir: engine.config.storageDir,
337
+ totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
338
+ workspaces: workspaces.map((workspace) => ({
339
+ workspace: workspace.workspace,
340
+ totalBytes: workspace.totalBytes,
341
+ recoveryCount: workspace.recoveryCount,
342
+ restorePoints: workspace.restorePoints.map((point) => ({
343
+ id: point.id,
344
+ kind: point.kind,
345
+ format: point.format,
346
+ createdAt: point.createdAt,
347
+ totalBytes: point.totalBytes,
348
+ fileCount: point.fileCount,
349
+ ...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
350
+ ...(point.turn === undefined ? {} : { turn: point.turn }),
351
+ ...(point.label === undefined ? {} : { label: point.label }),
352
+ })),
353
+ })),
354
+ })
355
+ return
356
+ }
357
+ if (request.method === 'POST') {
358
+ const body = objectBody(await readBody(request))
359
+ const action = requiredText(body.action, 'action')
360
+ if (action === 'delete') {
361
+ const workspace = requiredText(body.workspace, 'workspace')
362
+ const restorePointId = requiredText(body.restorePointId, 'restorePointId')
363
+ const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] })
364
+ if (report.deletedRestorePoints === 0) {
365
+ throw new ChangeLedgerError(
366
+ report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND',
367
+ report.retainedRestorePoints > 0
368
+ ? 'restore point is required by an incomplete recovery journal'
369
+ : `restore point ${restorePointId} does not exist`,
370
+ )
371
+ }
372
+ json(response, 200, { status: 'completed', action, ...report })
373
+ return
374
+ }
375
+ if (action === 'clear-workspace') {
376
+ const workspace = requiredText(body.workspace, 'workspace')
377
+ json(response, 200, { status: 'completed', action, ...await engine.purgeWorkspace({ workspace }) })
378
+ return
379
+ }
380
+ if (action === 'clear-all') {
381
+ const reports: WorkspacePurgeReport[] = []
382
+ const failures: { readonly workspace: string; readonly error: string }[] = []
383
+ for (const overview of await engine.listWorkspaces()) {
384
+ try {
385
+ reports.push(await engine.purgeWorkspace({ workspace: overview.workspace }))
386
+ } catch (error) {
387
+ failures.push({ workspace: overview.workspace, error: errorMessage(error) })
388
+ }
389
+ }
390
+ json(response, 200, {
391
+ status: failures.length > 0 ? 'partial' : 'completed',
392
+ action,
393
+ reports,
394
+ ...(failures.length > 0 ? { failures } : {}),
395
+ })
396
+ return
397
+ }
398
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'action must be "delete", "clear-workspace", or "clear-all"')
399
+ }
400
+ json(response, 405, { error: 'method not allowed' })
401
+ } catch (error) {
402
+ const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
403
+ json(response, status, {
404
+ error: errorMessage(error),
405
+ code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED',
406
+ })
407
+ }
408
+ }
409
+ }
410
+
219
411
  /** Build the exact-route handler as a testable unit. */
220
412
  export function createRewindHttpHandler(
221
413
  ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & { readonly agents?: AgentsLike },
@@ -231,9 +423,26 @@ export function createRewindHttpHandler(
231
423
  const detailsOnly = url.searchParams.get('details') === '1'
232
424
  const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset')
233
425
  const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
234
- const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
426
+ const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
427
+ let checkpoint: MessageCheckpoint | undefined
428
+ try {
429
+ checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target)
430
+ } catch (error) {
431
+ // File-checkpoint discovery is allowed to fail independently: the dialog
432
+ // must still expose message-only rewind for a valid Session boundary.
433
+ json(response, 200, { status: 'failed', error: errorMessage(error) })
434
+ return
435
+ }
235
436
  if (checkpoint === undefined) {
236
- json(response, 200, coordinator.state(sessionId, target.turn))
437
+ const durableSkip = await engine.findTurnCheckpointSkip({
438
+ cwd: target.cwd,
439
+ sessionId,
440
+ turn: target.turn,
441
+ turnStartSeq: target.turnStartSeq,
442
+ })
443
+ json(response, 200, durableSkip === undefined
444
+ ? coordinator.state(sessionId, target.turn)
445
+ : { status: 'skipped', reason: durableSkip.reason })
237
446
  return
238
447
  }
239
448
  const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id })
@@ -279,11 +488,20 @@ export function createRewindHttpHandler(
279
488
  if (request.method === 'POST') {
280
489
  const body = objectBody(await readBody(request))
281
490
  const mode = body.mode
282
- if (mode !== 'code' && mode !== 'both') {
283
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"')
491
+ if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
492
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"')
284
493
  }
285
494
  const sessionId = requiredText(body.sessionId, 'sessionId')
286
495
  const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq')
496
+ if (mode === 'messages') {
497
+ // Message-only rewind never mutates project files, so no checkpoint, no
498
+ // restore plan, and no shared-workspace gate apply; the conversation
499
+ // restart revalidates the message boundary itself.
500
+ const { target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
501
+ const fork = await createConversationRestart(ctx, sessionId, target)
502
+ json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId })
503
+ return
504
+ }
287
505
  const checkpointId = requiredText(body.checkpointId, 'checkpointId')
288
506
  const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
289
507
  const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
@@ -361,20 +579,29 @@ interface MessageCheckpoint extends MessageTarget {
361
579
  readonly id: string
362
580
  }
363
581
 
582
+ async function resolveMessageTarget(
583
+ ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
584
+ sessionId: string,
585
+ messageSeq: number,
586
+ ): Promise<{ readonly session: SessionLike; readonly target: MessageTarget }> {
587
+ const session = await readSession(ctx, sessionId)
588
+ return { session, target: messageTarget(session, messageSeq) }
589
+ }
590
+
364
591
  async function resolveMessageCheckpoint(
365
592
  ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
366
593
  engine: ChangeLedgerEngine,
367
594
  sessionId: string,
368
- messageSeq: number,
369
- ): Promise<{ readonly target: MessageTarget; readonly checkpoint?: MessageCheckpoint }> {
370
- let current = await readSession(ctx, sessionId)
371
- const target = messageTarget(current, messageSeq)
595
+ source: SessionLike,
596
+ target: MessageTarget,
597
+ ): Promise<MessageCheckpoint | undefined> {
598
+ let current = source
372
599
  const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn })
373
600
  if (direct !== undefined) {
374
601
  if (direct.turnStartSeq !== target.turnStartSeq) {
375
602
  throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start')
376
603
  }
377
- return { target, checkpoint: { ...target, id: direct.id } }
604
+ return { ...target, id: direct.id }
378
605
  }
379
606
 
380
607
  const seen = new Set<string>([sessionId])
@@ -386,7 +613,7 @@ async function resolveMessageCheckpoint(
386
613
  }
387
614
  if (parentId === undefined || seedLength === undefined
388
615
  || target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
389
- return { target }
616
+ return undefined
390
617
  }
391
618
  if (seen.has(parentId)) {
392
619
  throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle')
@@ -397,7 +624,7 @@ async function resolveMessageCheckpoint(
397
624
  } catch (error) {
398
625
  throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error })
399
626
  }
400
- const parentTarget = messageTarget(current, messageSeq)
627
+ const parentTarget = messageTarget(current, target.messageSeq)
401
628
  if (parentTarget.turn !== target.turn
402
629
  || parentTarget.turnStartSeq !== target.turnStartSeq
403
630
  || parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
@@ -408,7 +635,7 @@ async function resolveMessageCheckpoint(
408
635
  if (inherited.turnStartSeq !== target.turnStartSeq) {
409
636
  throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary')
410
637
  }
411
- return { target, checkpoint: { ...target, id: inherited.id } }
638
+ return { ...target, id: inherited.id }
412
639
  }
413
640
  }
414
641
 
@@ -419,7 +646,8 @@ async function checkpointForRequest(
419
646
  messageSeq: number,
420
647
  requestedId: string,
421
648
  ): Promise<MessageCheckpoint> {
422
- const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
649
+ const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
650
+ const checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target)
423
651
  if (checkpoint === undefined) {
424
652
  throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`)
425
653
  }
@@ -432,23 +660,23 @@ async function checkpointForRequest(
432
660
  async function createConversationRestart(
433
661
  ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'>,
434
662
  sourceId: string,
435
- checkpoint: MessageCheckpoint,
663
+ target: MessageTarget,
436
664
  ): Promise<{ readonly sessionId: string }> {
437
665
  const source = await readSession(ctx, sourceId)
438
- const current = messageTarget(source, checkpoint.messageSeq)
439
- if (current.turn !== checkpoint.turn
440
- || current.turnStartSeq !== checkpoint.turnStartSeq
441
- || current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
666
+ const current = messageTarget(source, target.messageSeq)
667
+ if (current.turn !== target.turn
668
+ || current.turnStartSeq !== target.turnStartSeq
669
+ || current.previousTurnEndSeq !== target.previousTurnEndSeq) {
442
670
  throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
443
671
  }
444
- const response = checkpoint.previousTurnEndSeq === undefined
672
+ const response = target.previousTurnEndSeq === undefined
445
673
  ? await ctx.apiProxy.sessions.create({
446
674
  rpcId: randomUUID(),
447
- payload: { cwd: checkpoint.cwd },
675
+ payload: { cwd: target.cwd },
448
676
  })
449
677
  : await ctx.apiProxy.sessions.fork({
450
678
  rpcId: randomUUID(),
451
- payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
679
+ payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
452
680
  })
453
681
  if (!response.result.ok) {
454
682
  throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message)
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Runtime settings surface for Turn Rewind on the DSH web settings page.
3
+ * @module @anionex/dsh-turn-rewind
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis'
6
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
7
+ import z from '@deepseek-ai/schemastery'
8
+ import { resolveConfig, type ChangeLedgerEngine } from './engine.js'
9
+ import type { ChangeLedgerConfig, ResolvedChangeLedgerConfig } from './types.js'
10
+
11
+ /** Namespace join key shared by the host section and the browser settings card. */
12
+ export const TURN_REWIND_SETTINGS_NAMESPACE = settingsNamespace('turn-rewind')
13
+
14
+ /** Every runtime-tunable field of {@link ChangeLedgerConfig}; `storageDir` stays config-layer only. */
15
+ export interface TurnRewindSettings {
16
+ /** Maximum user and rescue restore points retained per workspace. */
17
+ maxRestorePoints: number
18
+ /** Maximum automatic turn checkpoints retained per session. */
19
+ maxTurnCheckpointsPerSession: number
20
+ /** Maximum number of files in one restore point. */
21
+ maxFiles: number
22
+ /** Maximum bytes read from one regular file. */
23
+ maxFileBytes: number
24
+ /** Maximum aggregate regular-file bytes in one restore point. */
25
+ maxSnapshotBytes: number
26
+ /** Restore-plan lifetime in milliseconds. */
27
+ planTtlMs: number
28
+ /** Age after which a lock whose owner is gone may be reclaimed. */
29
+ staleLockMs: number
30
+ /** Automatic turn-checkpoint implementation; `off` records durable skips instead. */
31
+ turnCheckpointMode: 'off' | 'git-native' | 'legacy'
32
+ /** Maximum time one automatic checkpoint may block the first Agent step. */
33
+ turnCheckpointTimeoutMs: number
34
+ /** Maximum uncached worktree bytes read by one automatic Git-native checkpoint. */
35
+ turnCheckpointMaxNewBytes: number
36
+ /** Fast trusts fenced Git/stat metadata; strict rereads every eligible path. */
37
+ turnCheckpointTrust: 'fast' | 'strict'
38
+ }
39
+
40
+ /** Schemastery schema for the `turn-rewind` settings namespace. */
41
+ export const TurnRewindSettingsSchema: z<TurnRewindSettings> = (() => {
42
+ const defaults = tunableSettings(resolveConfig({}))
43
+ return z.object({
44
+ maxRestorePoints: z.number().step(1).min(1).default(defaults.maxRestorePoints),
45
+ maxTurnCheckpointsPerSession: z.number().step(1).min(1).default(defaults.maxTurnCheckpointsPerSession),
46
+ maxFiles: z.number().step(1).min(1).default(defaults.maxFiles),
47
+ maxFileBytes: z.number().step(1).min(1).default(defaults.maxFileBytes),
48
+ maxSnapshotBytes: z.number().step(1).min(1).default(defaults.maxSnapshotBytes),
49
+ planTtlMs: z.number().step(1).min(1).default(defaults.planTtlMs),
50
+ staleLockMs: z.number().step(1).min(1).default(defaults.staleLockMs),
51
+ turnCheckpointMode: z.union(['off', 'git-native', 'legacy']).default(defaults.turnCheckpointMode),
52
+ turnCheckpointTimeoutMs: z.number().step(1).min(1).default(defaults.turnCheckpointTimeoutMs),
53
+ turnCheckpointMaxNewBytes: z.number().step(1).min(1).default(defaults.turnCheckpointMaxNewBytes),
54
+ turnCheckpointTrust: z.union(['fast', 'strict']).default(defaults.turnCheckpointTrust),
55
+ })
56
+ })()
57
+
58
+ /** Project one resolved configuration onto the settings namespace subset. */
59
+ function tunableSettings(resolved: ResolvedChangeLedgerConfig): TurnRewindSettings {
60
+ return {
61
+ maxRestorePoints: resolved.maxRestorePoints,
62
+ maxTurnCheckpointsPerSession: resolved.maxTurnCheckpointsPerSession,
63
+ maxFiles: resolved.maxFiles,
64
+ maxFileBytes: resolved.maxFileBytes,
65
+ maxSnapshotBytes: resolved.maxSnapshotBytes,
66
+ planTtlMs: resolved.planTtlMs,
67
+ staleLockMs: resolved.staleLockMs,
68
+ turnCheckpointMode: resolved.turnCheckpointMode,
69
+ turnCheckpointTimeoutMs: resolved.turnCheckpointTimeoutMs,
70
+ turnCheckpointMaxNewBytes: resolved.turnCheckpointMaxNewBytes,
71
+ turnCheckpointTrust: resolved.turnCheckpointTrust,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Register the `turn-rewind` settings namespace and apply its resolved value to the
77
+ * running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
78
+ * the user layer persists through the DSH settings provider. `storageDir` is never
79
+ * carried by the namespace: the storage root must not move while the engine runs.
80
+ */
81
+ export function installTurnRewindSettings(ctx: Context, config: ChangeLedgerConfig, engine: ChangeLedgerEngine): void {
82
+ let source: () => TurnRewindSettings = () => tunableSettings(resolveConfig(config))
83
+ installSettingsSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, source(), {
84
+ setSource: (current) => { source = current },
85
+ onChange: () => {
86
+ try {
87
+ engine.updateConfig({ ...config, ...source() })
88
+ } catch (error) {
89
+ ctx.logger.warn(`[turn-rewind] could not apply settings update: ${error instanceof Error ? error.message : String(error)}`)
90
+ }
91
+ },
92
+ })
93
+ }