@anionex/dsh-turn-rewind 0.1.2 → 0.2.1

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
@@ -29,6 +29,12 @@ interface AgentLike {
29
29
  readonly session: SessionLike
30
30
  }
31
31
 
32
+ interface ToolExecutionLike {
33
+ readonly agent?: AgentLike
34
+ readonly parent?: symbol
35
+ readonly signal: AbortSignal
36
+ }
37
+
32
38
  interface AgentsLike {
33
39
  list(): AgentLike[]
34
40
  }
@@ -102,6 +108,10 @@ declare module '@deepseek-ai/cordis' {
102
108
  },
103
109
  next: () => Promise<unknown>,
104
110
  ): Promise<unknown>
111
+ 'tools/execute'(
112
+ exec: ToolExecutionLike,
113
+ next: () => Promise<unknown>,
114
+ ): Promise<unknown>
105
115
  }
106
116
  }
107
117
 
@@ -110,7 +120,7 @@ const BODY_LIMIT = 64 * 1024
110
120
  const INITIAL_CHANGE_PREVIEW_LIMIT = 8
111
121
  const MAX_CHANGE_PAGE_SIZE = 200
112
122
 
113
- /** Capture each turn before its opening user message can trigger model or tool work. */
123
+ /** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
114
124
  export class TurnCheckpointCoordinator {
115
125
  private readonly captures = new Map<string, Promise<void>>()
116
126
  private readonly pending = new Set<string>()
@@ -120,10 +130,17 @@ export class TurnCheckpointCoordinator {
120
130
 
121
131
  constructor(private readonly engine: ChangeLedgerEngine) {}
122
132
 
123
- /** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
133
+ /** Keep sidecar checkpoint work out of the Agent response waterfall. */
124
134
  install(ctx: Context): void {
125
135
  ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
126
- if (step === 1) await this.capture(ctx, agent, turn, signal)
136
+ if (step === 1) this.startCapture(ctx, agent, turn, signal)
137
+ return next()
138
+ }, { prepend: true })
139
+
140
+ ctx.on('tools/execute', async (exec, next) => {
141
+ if (exec.agent !== undefined && exec.parent === undefined) {
142
+ await this.waitForOpenTurn(exec.agent, exec.signal)
143
+ }
127
144
  return next()
128
145
  }, { prepend: true })
129
146
  }
@@ -138,6 +155,20 @@ export class TurnCheckpointCoordinator {
138
155
  return error === undefined ? { status: 'missing' } : { status: 'failed', error }
139
156
  }
140
157
 
158
+ private startCapture(
159
+ ctx: Pick<Context, 'logger'>,
160
+ agent: AgentLike,
161
+ turn: number,
162
+ signal: AbortSignal,
163
+ ): void {
164
+ const key = checkpointKey(agent.id, turn)
165
+ if (this.captures.has(key)) return
166
+ const capture = this.capture(ctx, agent, turn, signal).catch(async (error: unknown) => {
167
+ await this.recordFailure(ctx, agent.id, turn, error)
168
+ })
169
+ this.captures.set(key, capture)
170
+ }
171
+
141
172
  private async capture(
142
173
  ctx: Pick<Context, 'logger'>,
143
174
  agent: AgentLike,
@@ -159,11 +190,6 @@ export class TurnCheckpointCoordinator {
159
190
  const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs))
160
191
  const captureSignal = AbortSignal.any([signal, captureDeadline.signal])
161
192
  try {
162
- const existing = this.captures.get(key)
163
- if (existing !== undefined) {
164
- await waitWithSignal(existing, captureSignal).catch(() => undefined)
165
- return
166
- }
167
193
  this.pending.add(key)
168
194
  this.failures.delete(key)
169
195
  this.skips.delete(key)
@@ -201,7 +227,6 @@ export class TurnCheckpointCoordinator {
201
227
  this.pending.delete(key)
202
228
  }
203
229
  })()
204
- this.captures.set(key, capture)
205
230
  try {
206
231
  await waitWithSignal(capture, outcomeSignal)
207
232
  } catch (error) {
@@ -220,6 +245,24 @@ export class TurnCheckpointCoordinator {
220
245
  }
221
246
  }
222
247
 
248
+ /** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
249
+ private async waitForOpenTurn(agent: AgentLike, signal: AbortSignal): Promise<void> {
250
+ if (signal.aborted) return
251
+ const boundary = agent.session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
252
+ if (boundary?.type !== 'turn/start') return
253
+ const turn = boundary.data.turn
254
+ if (!Number.isSafeInteger(turn) || (turn as number) < 0) return
255
+ const capture = this.captures.get(checkpointKey(agent.id, turn as number))
256
+ if (capture === undefined) return
257
+ try {
258
+ await waitWithSignal(capture, signal)
259
+ } catch (error) {
260
+ // Delegate caller cancellation so the DSH tool runtime preserves its
261
+ // canonical ABORTED_BEFORE_DISPATCH result instead of a wrapper error.
262
+ if (!signal.aborted) throw error
263
+ }
264
+ }
265
+
223
266
  private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
224
267
  const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
225
268
  const current = (async () => {
@@ -686,3 +729,73 @@ function json(response: HttpResponseLike, status: number, value: unknown): void
686
729
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
687
730
  response.end(`${JSON.stringify(value)}\n`)
688
731
  }
732
+
733
+ export const MANAGE_HTTP_PATH = '/turn-rewind/manage'
734
+
735
+ /** Register the same-origin storage-management endpoint consumed by the settings card. */
736
+ export function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void {
737
+ ctx.effect(() => ctx.webServer.register({
738
+ kind: 'exact',
739
+ path: MANAGE_HTTP_PATH,
740
+ handler: createManageHttpHandler(engine),
741
+ }), 'change-ledger.manageHttp')
742
+ }
743
+
744
+ /** Build the storage-management route as a testable unit. */
745
+ export function createManageHttpHandler(
746
+ engine: ChangeLedgerEngine,
747
+ ): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
748
+ return async (request, response) => {
749
+ try {
750
+ if (request.method === 'GET') {
751
+ const workspaces = await engine.listWorkspaces()
752
+ json(response, 200, {
753
+ storageDir: engine.config.storageDir,
754
+ totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
755
+ workspaces: workspaces.map((workspace) => ({
756
+ workspace: workspace.workspace,
757
+ totalBytes: workspace.totalBytes,
758
+ recoveryCount: workspace.recoveryCount,
759
+ restorePoints: workspace.restorePoints.map((point) => ({
760
+ id: point.id,
761
+ kind: point.kind,
762
+ format: point.format,
763
+ createdAt: point.createdAt,
764
+ totalBytes: point.totalBytes,
765
+ fileCount: point.fileCount,
766
+ ...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
767
+ ...(point.turn === undefined ? {} : { turn: point.turn }),
768
+ ...(point.label === undefined ? {} : { label: point.label }),
769
+ })),
770
+ })),
771
+ })
772
+ return
773
+ }
774
+ if (request.method === 'POST') {
775
+ const body = objectBody(await readBody(request))
776
+ const action = requiredText(body.action, 'action')
777
+ if (action === 'delete') {
778
+ const workspace = requiredText(body.workspace, 'workspace')
779
+ const restorePointId = requiredText(body.restorePointId, 'restorePointId')
780
+ const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] })
781
+ if (report.deletedRestorePoints === 0) {
782
+ throw new ChangeLedgerError(
783
+ report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND',
784
+ report.retainedRestorePoints > 0
785
+ ? 'restore point is required by an incomplete recovery journal'
786
+ : `restore point ${restorePointId} does not exist`,
787
+ )
788
+ }
789
+ json(response, 200, { status: 'completed', action, ...report })
790
+ return
791
+ }
792
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', `unsupported manage action ${action}`)
793
+ }
794
+ response.writeHead(405, { allow: 'GET, POST' })
795
+ response.end()
796
+ } catch (error) {
797
+ const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
798
+ json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED' })
799
+ }
800
+ }
801
+ }
@@ -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
+ }