@anionex/dsh-turn-rewind 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -49,6 +49,8 @@ import {
49
49
  type RestoreResult,
50
50
  type SnapshotEntry,
51
51
  type WorkspaceChange,
52
+ type WorkspaceOverview,
53
+ type WorkspacePurgeReport,
52
54
  } from './types.js'
53
55
 
54
56
  const DEFAULTS = {
@@ -67,7 +69,7 @@ const DEFAULTS = {
67
69
 
68
70
  /** Persistent workspace change-set engine, independent of the DSH tool adapter. */
69
71
  export class ChangeLedgerEngine {
70
- readonly config: ResolvedChangeLedgerConfig
72
+ private readonly currentConfig: ResolvedChangeLedgerConfig
71
73
  readonly store: LedgerStore
72
74
  private readonly plans = new Map<string, RestorePlan>()
73
75
  private readonly activePlans = new Set<string>()
@@ -75,11 +77,28 @@ export class ChangeLedgerEngine {
75
77
 
76
78
  /** Build an engine and start crash-journal reconciliation. */
77
79
  constructor(config: ChangeLedgerConfig = {}) {
78
- this.config = resolveConfig(config)
79
- this.store = new LedgerStore(this.config)
80
+ this.currentConfig = resolveConfig(config)
81
+ this.store = new LedgerStore(this.currentConfig)
80
82
  this.ready = this.initializeStore()
81
83
  }
82
84
 
85
+ /** Currently resolved configuration; runtime-tunable fields update in place. */
86
+ get config(): ResolvedChangeLedgerConfig {
87
+ return this.currentConfig
88
+ }
89
+
90
+ /**
91
+ * Swap runtime-tunable configuration (limits, modes, timeouts, trust) in place.
92
+ * The storage root must never move once the engine owns locks and journals.
93
+ */
94
+ updateConfig(config: ChangeLedgerConfig): void {
95
+ const resolved = resolveConfig(config)
96
+ if (resolved.storageDir !== this.currentConfig.storageDir) {
97
+ throw new ChangeLedgerError('INVALID_CONFIG', 'storageDir cannot change while the engine is running')
98
+ }
99
+ Object.assign(this.currentConfig as WritableResolvedConfig, resolved)
100
+ }
101
+
83
102
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
84
103
  async initialize(): Promise<number> {
85
104
  return this.ready
@@ -743,6 +762,135 @@ export class ChangeLedgerEngine {
743
762
  }
744
763
  }
745
764
 
765
+ /** Inventory every workspace this storage root has ever persisted state for. */
766
+ async listWorkspaces(options: { readonly signal?: AbortSignal } = {}): Promise<WorkspaceOverview[]> {
767
+ await this.ready
768
+ const groups = new Map<string, { restorePoints: RestorePointSummary[]; recoveryCount: number }>()
769
+ const groupOf = (workspace: string): { restorePoints: RestorePointSummary[]; recoveryCount: number } => {
770
+ const existing = groups.get(workspace)
771
+ if (existing !== undefined) return existing
772
+ const created = { restorePoints: [], recoveryCount: 0 }
773
+ groups.set(workspace, created)
774
+ return created
775
+ }
776
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
777
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
778
+ groupOf(manifest.workspace).restorePoints.push(summarize(manifest))
779
+ }
780
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
781
+ if (operation.state === 'interrupted' || operation.state === 'recovery-required') {
782
+ groupOf(operation.workspace).recoveryCount += 1
783
+ }
784
+ }
785
+ }
786
+ return [...groups.entries()]
787
+ .map(([workspace, group]) => ({
788
+ workspace,
789
+ restorePoints: [...group.restorePoints].sort(
790
+ (left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id),
791
+ ),
792
+ totalBytes: group.restorePoints.reduce((total, point) => total + point.totalBytes, 0),
793
+ recoveryCount: group.recoveryCount,
794
+ }))
795
+ .sort((left, right) => left.workspace.localeCompare(right.workspace))
796
+ }
797
+
798
+ /** Delete unprotected restore points recorded for one workspace and collect unused blobs. */
799
+ async purgeWorkspace(options: {
800
+ readonly workspace: string
801
+ /** Restrict the purge to these restore points; protection rules still apply. */
802
+ readonly restorePointIds?: readonly string[]
803
+ readonly signal?: AbortSignal
804
+ }): Promise<WorkspacePurgeReport> {
805
+ await this.ready
806
+ const restorePointIds = options.restorePointIds === undefined ? undefined : new Set(options.restorePointIds)
807
+ const targetDirs = new Set<string>()
808
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
809
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
810
+ if (manifest.workspace === options.workspace
811
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id))) {
812
+ targetDirs.add(workspaceDir)
813
+ }
814
+ }
815
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
816
+ if (operation.workspace === options.workspace) targetDirs.add(workspaceDir)
817
+ }
818
+ }
819
+ let deletedRestorePoints = 0
820
+ let retainedRestorePoints = 0
821
+ let deletedBlobs = 0
822
+ let retainedBlobs = 0
823
+ for (const workspaceDir of targetDirs) {
824
+ const result = await this.purgeWorkspaceDir(workspaceDir, options.workspace, restorePointIds, options.signal)
825
+ deletedRestorePoints += result.deletedRestorePoints
826
+ retainedRestorePoints += result.retainedRestorePoints
827
+ deletedBlobs += result.deletedBlobs
828
+ retainedBlobs += result.retainedBlobs
829
+ }
830
+ return { workspace: options.workspace, deletedRestorePoints, retainedRestorePoints, deletedBlobs, retainedBlobs }
831
+ }
832
+
833
+ private async purgeWorkspaceDir(
834
+ workspaceDir: string,
835
+ workspace: string,
836
+ restorePointIds: ReadonlySet<string> | undefined,
837
+ signal?: AbortSignal,
838
+ ): Promise<{ deletedRestorePoints: number; retainedRestorePoints: number; deletedBlobs: number; retainedBlobs: number }> {
839
+ // A live repository needs both its shared Git lock and durable directory lock.
840
+ // A missing path, or one now nested under a different repository, cannot expose
841
+ // its old Git namespace but still takes the durable lock before storage cleanup.
842
+ let liveRepository = false
843
+ let release: () => Promise<void>
844
+ try {
845
+ const root = await discoverRepositoryRoot(workspace, signal)
846
+ if (root === workspace) {
847
+ await this.assertStorageSeparated(root)
848
+ release = await this.acquireWorkspace(root, signal)
849
+ liveRepository = true
850
+ } else {
851
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
852
+ }
853
+ } catch (error) {
854
+ if (!(error instanceof ChangeLedgerError) || error.code !== 'WORKSPACE_NOT_FOUND') throw error
855
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
856
+ }
857
+ let deletedRestorePoints = 0
858
+ let retainedRestorePoints = 0
859
+ try {
860
+ const manifests = (await this.store.listManifestsInDir(workspaceDir, signal))
861
+ .filter((manifest) => manifest.workspace === workspace
862
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id)))
863
+ const protectedIds = new Set<string>()
864
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, signal)) {
865
+ if (operation.state === 'running' || operation.state === 'rollback-running'
866
+ || operation.state === 'interrupted' || operation.state === 'recovery-required') {
867
+ protectedIds.add(operation.restorePointId)
868
+ protectedIds.add(operation.rescuePointId)
869
+ }
870
+ }
871
+ for (const journal of await this.store.listGitCheckpointJournalsInDir(workspaceDir, signal)) {
872
+ protectedIds.add(journal.restorePointId)
873
+ }
874
+ for (const manifest of manifests) {
875
+ if (protectedIds.has(manifest.id)) {
876
+ retainedRestorePoints += 1
877
+ continue
878
+ }
879
+ if (liveRepository) {
880
+ await this.deleteManifestWithGit(manifest, signal)
881
+ } else {
882
+ await this.store.deleteManifestInDir(workspaceDir, manifest.id)
883
+ }
884
+ deletedRestorePoints += 1
885
+ }
886
+ if (restorePointIds === undefined) await this.store.clearTurnOutcomesInDir(workspaceDir)
887
+ const gc = await this.store.collectGarbageInDir(workspaceDir, signal)
888
+ return { deletedRestorePoints, retainedRestorePoints, ...gc }
889
+ } finally {
890
+ await release()
891
+ }
892
+ }
893
+
746
894
  private async createLocked(options: {
747
895
  readonly cwd: string
748
896
  readonly kind: RestorePointKind
@@ -943,6 +1091,9 @@ export class ChangeLedgerEngine {
943
1091
  }
944
1092
  }
945
1093
 
1094
+ /** Mutable view used only by `updateConfig` to swap fields on the shared resolved object. */
1095
+ type WritableResolvedConfig = { -readonly [K in keyof ResolvedChangeLedgerConfig]: ResolvedChangeLedgerConfig[K] }
1096
+
946
1097
  /** Resolve and validate every deployment-varying configuration value. */
947
1098
  export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerConfig {
948
1099
  const home = homedir()
package/src/index.ts CHANGED
@@ -4,12 +4,14 @@
4
4
  */
5
5
  import type { Context } from '@deepseek-ai/cordis'
6
6
  import { ChangeLedgerEngine } from './engine.js'
7
- import { installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js'
7
+ import { installManageHttp, installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js'
8
+ import { installTurnRewindSettings } from './settings.js'
8
9
  import type { ChangeLedgerConfig } from './types.js'
9
10
 
10
11
  export * from './engine.js'
11
12
  export * from './errors.js'
12
13
  export * from './rewind-host.js'
14
+ export * from './settings.js'
13
15
  export * from './types.js'
14
16
 
15
17
  declare module '@deepseek-ai/cordis' {
@@ -30,7 +32,9 @@ export class ChangeLedgerService {
30
32
  ctx.inject(['agents'], (scope: Context) => { checkpoints.install(scope) })
31
33
  ctx.inject(['webServer', 'sessions', 'sessionQuery', 'apiProxy', 'agents'], (scope: Context) => {
32
34
  installRewindHttp(scope, this.engine, checkpoints)
35
+ installManageHttp(scope, this.engine)
33
36
  })
37
+ installTurnRewindSettings(ctx, config, this.engine)
34
38
  void this.engine.initialize().then((reconciled) => {
35
39
  if (reconciled > 0) {
36
40
  ctx.logger.warn(`[change-ledger] reconciled ${reconciled} interrupted durable operation(s)`)
@@ -95,6 +99,21 @@ export class ChangeLedgerService {
95
99
  listRecovery(options: Parameters<ChangeLedgerEngine['listRecovery']>[0]): ReturnType<ChangeLedgerEngine['listRecovery']> {
96
100
  return this.engine.listRecovery(options)
97
101
  }
102
+
103
+ /** Inventory every workspace this storage root has persisted state for. */
104
+ listWorkspaces(options?: Parameters<ChangeLedgerEngine['listWorkspaces']>[0]): ReturnType<ChangeLedgerEngine['listWorkspaces']> {
105
+ return this.engine.listWorkspaces(options)
106
+ }
107
+
108
+ /** Delete unprotected restore points recorded for one workspace. */
109
+ purgeWorkspace(options: Parameters<ChangeLedgerEngine['purgeWorkspace']>[0]): ReturnType<ChangeLedgerEngine['purgeWorkspace']> {
110
+ return this.engine.purgeWorkspace(options)
111
+ }
112
+
113
+ /** Swap runtime-tunable configuration; the storage root must stay fixed. */
114
+ updateConfig(config: ChangeLedgerConfig): void {
115
+ this.engine.updateConfig(config)
116
+ }
98
117
  }
99
118
 
100
119
  export default ChangeLedgerService
@@ -4,6 +4,7 @@ import { createDeadline } from './deadline.js'
4
4
  import { ChangeLedgerError, errorMessage } from './errors.js'
5
5
  import type { ChangeLedgerEngine } from './engine.js'
6
6
  import { discoverRepositoryRoot } from './git.js'
7
+ import type { WorkspacePurgeReport } from './types.js'
7
8
 
8
9
  interface SessionEventLike {
9
10
  readonly type: string
@@ -312,6 +313,101 @@ export function installRewindHttp(
312
313
  }), 'change-ledger.rewindHttp')
313
314
  }
314
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
+
315
411
  /** Build the exact-route handler as a testable unit. */
316
412
  export function createRewindHttpHandler(
317
413
  ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & { readonly agents?: AgentsLike },
@@ -327,7 +423,16 @@ export function createRewindHttpHandler(
327
423
  const detailsOnly = url.searchParams.get('details') === '1'
328
424
  const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset')
329
425
  const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
330
- 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
+ }
331
436
  if (checkpoint === undefined) {
332
437
  const durableSkip = await engine.findTurnCheckpointSkip({
333
438
  cwd: target.cwd,
@@ -383,11 +488,20 @@ export function createRewindHttpHandler(
383
488
  if (request.method === 'POST') {
384
489
  const body = objectBody(await readBody(request))
385
490
  const mode = body.mode
386
- if (mode !== 'code' && mode !== 'both') {
387
- 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"')
388
493
  }
389
494
  const sessionId = requiredText(body.sessionId, 'sessionId')
390
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
+ }
391
505
  const checkpointId = requiredText(body.checkpointId, 'checkpointId')
392
506
  const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
393
507
  const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
@@ -465,20 +579,29 @@ interface MessageCheckpoint extends MessageTarget {
465
579
  readonly id: string
466
580
  }
467
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
+
468
591
  async function resolveMessageCheckpoint(
469
592
  ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
470
593
  engine: ChangeLedgerEngine,
471
594
  sessionId: string,
472
- messageSeq: number,
473
- ): Promise<{ readonly target: MessageTarget; readonly checkpoint?: MessageCheckpoint }> {
474
- let current = await readSession(ctx, sessionId)
475
- const target = messageTarget(current, messageSeq)
595
+ source: SessionLike,
596
+ target: MessageTarget,
597
+ ): Promise<MessageCheckpoint | undefined> {
598
+ let current = source
476
599
  const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn })
477
600
  if (direct !== undefined) {
478
601
  if (direct.turnStartSeq !== target.turnStartSeq) {
479
602
  throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start')
480
603
  }
481
- return { target, checkpoint: { ...target, id: direct.id } }
604
+ return { ...target, id: direct.id }
482
605
  }
483
606
 
484
607
  const seen = new Set<string>([sessionId])
@@ -490,7 +613,7 @@ async function resolveMessageCheckpoint(
490
613
  }
491
614
  if (parentId === undefined || seedLength === undefined
492
615
  || target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
493
- return { target }
616
+ return undefined
494
617
  }
495
618
  if (seen.has(parentId)) {
496
619
  throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle')
@@ -501,7 +624,7 @@ async function resolveMessageCheckpoint(
501
624
  } catch (error) {
502
625
  throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error })
503
626
  }
504
- const parentTarget = messageTarget(current, messageSeq)
627
+ const parentTarget = messageTarget(current, target.messageSeq)
505
628
  if (parentTarget.turn !== target.turn
506
629
  || parentTarget.turnStartSeq !== target.turnStartSeq
507
630
  || parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
@@ -512,7 +635,7 @@ async function resolveMessageCheckpoint(
512
635
  if (inherited.turnStartSeq !== target.turnStartSeq) {
513
636
  throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary')
514
637
  }
515
- return { target, checkpoint: { ...target, id: inherited.id } }
638
+ return { ...target, id: inherited.id }
516
639
  }
517
640
  }
518
641
 
@@ -523,7 +646,8 @@ async function checkpointForRequest(
523
646
  messageSeq: number,
524
647
  requestedId: string,
525
648
  ): Promise<MessageCheckpoint> {
526
- 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)
527
651
  if (checkpoint === undefined) {
528
652
  throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`)
529
653
  }
@@ -536,23 +660,23 @@ async function checkpointForRequest(
536
660
  async function createConversationRestart(
537
661
  ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'>,
538
662
  sourceId: string,
539
- checkpoint: MessageCheckpoint,
663
+ target: MessageTarget,
540
664
  ): Promise<{ readonly sessionId: string }> {
541
665
  const source = await readSession(ctx, sourceId)
542
- const current = messageTarget(source, checkpoint.messageSeq)
543
- if (current.turn !== checkpoint.turn
544
- || current.turnStartSeq !== checkpoint.turnStartSeq
545
- || 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) {
546
670
  throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
547
671
  }
548
- const response = checkpoint.previousTurnEndSeq === undefined
672
+ const response = target.previousTurnEndSeq === undefined
549
673
  ? await ctx.apiProxy.sessions.create({
550
674
  rpcId: randomUUID(),
551
- payload: { cwd: checkpoint.cwd },
675
+ payload: { cwd: target.cwd },
552
676
  })
553
677
  : await ctx.apiProxy.sessions.fork({
554
678
  rpcId: randomUUID(),
555
- payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
679
+ payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
556
680
  })
557
681
  if (!response.result.ok) {
558
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
+ }