@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/lib/engine.js CHANGED
@@ -25,17 +25,32 @@ const DEFAULTS = {
25
25
  };
26
26
  /** Persistent workspace change-set engine, independent of the DSH tool adapter. */
27
27
  export class ChangeLedgerEngine {
28
- config;
28
+ currentConfig;
29
29
  store;
30
30
  plans = new Map();
31
31
  activePlans = new Set();
32
32
  ready;
33
33
  /** Build an engine and start crash-journal reconciliation. */
34
34
  constructor(config = {}) {
35
- this.config = resolveConfig(config);
36
- this.store = new LedgerStore(this.config);
35
+ this.currentConfig = resolveConfig(config);
36
+ this.store = new LedgerStore(this.currentConfig);
37
37
  this.ready = this.initializeStore();
38
38
  }
39
+ /** Currently resolved configuration; runtime-tunable fields update in place. */
40
+ get config() {
41
+ return this.currentConfig;
42
+ }
43
+ /**
44
+ * Swap runtime-tunable configuration (limits, modes, timeouts, trust) in place.
45
+ * The storage root must never move once the engine owns locks and journals.
46
+ */
47
+ updateConfig(config) {
48
+ const resolved = resolveConfig(config);
49
+ if (resolved.storageDir !== this.currentConfig.storageDir) {
50
+ throw new ChangeLedgerError('INVALID_CONFIG', 'storageDir cannot change while the engine is running');
51
+ }
52
+ Object.assign(this.currentConfig, resolved);
53
+ }
39
54
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
40
55
  async initialize() {
41
56
  return this.ready;
@@ -615,6 +630,128 @@ export class ChangeLedgerEngine {
615
630
  await release();
616
631
  }
617
632
  }
633
+ /** Inventory every workspace this storage root has ever persisted state for. */
634
+ async listWorkspaces(options = {}) {
635
+ await this.ready;
636
+ const groups = new Map();
637
+ const groupOf = (workspace) => {
638
+ const existing = groups.get(workspace);
639
+ if (existing !== undefined)
640
+ return existing;
641
+ const created = { restorePoints: [], recoveryCount: 0 };
642
+ groups.set(workspace, created);
643
+ return created;
644
+ };
645
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
646
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
647
+ groupOf(manifest.workspace).restorePoints.push(summarize(manifest));
648
+ }
649
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
650
+ if (operation.state === 'interrupted' || operation.state === 'recovery-required') {
651
+ groupOf(operation.workspace).recoveryCount += 1;
652
+ }
653
+ }
654
+ }
655
+ return [...groups.entries()]
656
+ .map(([workspace, group]) => ({
657
+ workspace,
658
+ restorePoints: [...group.restorePoints].sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id)),
659
+ totalBytes: group.restorePoints.reduce((total, point) => total + point.totalBytes, 0),
660
+ recoveryCount: group.recoveryCount,
661
+ }))
662
+ .sort((left, right) => left.workspace.localeCompare(right.workspace));
663
+ }
664
+ /** Delete unprotected restore points recorded for one workspace and collect unused blobs. */
665
+ async purgeWorkspace(options) {
666
+ await this.ready;
667
+ const restorePointIds = options.restorePointIds === undefined ? undefined : new Set(options.restorePointIds);
668
+ const targetDirs = new Set();
669
+ for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
670
+ for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
671
+ if (manifest.workspace === options.workspace
672
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id))) {
673
+ targetDirs.add(workspaceDir);
674
+ }
675
+ }
676
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
677
+ if (operation.workspace === options.workspace)
678
+ targetDirs.add(workspaceDir);
679
+ }
680
+ }
681
+ let deletedRestorePoints = 0;
682
+ let retainedRestorePoints = 0;
683
+ let deletedBlobs = 0;
684
+ let retainedBlobs = 0;
685
+ for (const workspaceDir of targetDirs) {
686
+ const result = await this.purgeWorkspaceDir(workspaceDir, options.workspace, restorePointIds, options.signal);
687
+ deletedRestorePoints += result.deletedRestorePoints;
688
+ retainedRestorePoints += result.retainedRestorePoints;
689
+ deletedBlobs += result.deletedBlobs;
690
+ retainedBlobs += result.retainedBlobs;
691
+ }
692
+ return { workspace: options.workspace, deletedRestorePoints, retainedRestorePoints, deletedBlobs, retainedBlobs };
693
+ }
694
+ async purgeWorkspaceDir(workspaceDir, workspace, restorePointIds, signal) {
695
+ // A live repository needs both its shared Git lock and durable directory lock.
696
+ // A missing path, or one now nested under a different repository, cannot expose
697
+ // its old Git namespace but still takes the durable lock before storage cleanup.
698
+ let liveRepository = false;
699
+ let release;
700
+ try {
701
+ const root = await discoverRepositoryRoot(workspace, signal);
702
+ if (root === workspace) {
703
+ await this.assertStorageSeparated(root);
704
+ release = await this.acquireWorkspace(root, signal);
705
+ liveRepository = true;
706
+ }
707
+ else {
708
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal);
709
+ }
710
+ }
711
+ catch (error) {
712
+ if (!(error instanceof ChangeLedgerError) || error.code !== 'WORKSPACE_NOT_FOUND')
713
+ throw error;
714
+ release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal);
715
+ }
716
+ let deletedRestorePoints = 0;
717
+ let retainedRestorePoints = 0;
718
+ try {
719
+ const manifests = (await this.store.listManifestsInDir(workspaceDir, signal))
720
+ .filter((manifest) => manifest.workspace === workspace
721
+ && (restorePointIds === undefined || restorePointIds.has(manifest.id)));
722
+ const protectedIds = new Set();
723
+ for (const operation of await this.store.listOperationsInDir(workspaceDir, signal)) {
724
+ if (operation.state === 'running' || operation.state === 'rollback-running'
725
+ || operation.state === 'interrupted' || operation.state === 'recovery-required') {
726
+ protectedIds.add(operation.restorePointId);
727
+ protectedIds.add(operation.rescuePointId);
728
+ }
729
+ }
730
+ for (const journal of await this.store.listGitCheckpointJournalsInDir(workspaceDir, signal)) {
731
+ protectedIds.add(journal.restorePointId);
732
+ }
733
+ for (const manifest of manifests) {
734
+ if (protectedIds.has(manifest.id)) {
735
+ retainedRestorePoints += 1;
736
+ continue;
737
+ }
738
+ if (liveRepository) {
739
+ await this.deleteManifestWithGit(manifest, signal);
740
+ }
741
+ else {
742
+ await this.store.deleteManifestInDir(workspaceDir, manifest.id);
743
+ }
744
+ deletedRestorePoints += 1;
745
+ }
746
+ if (restorePointIds === undefined)
747
+ await this.store.clearTurnOutcomesInDir(workspaceDir);
748
+ const gc = await this.store.collectGarbageInDir(workspaceDir, signal);
749
+ return { deletedRestorePoints, retainedRestorePoints, ...gc };
750
+ }
751
+ finally {
752
+ await release();
753
+ }
754
+ }
618
755
  async createLocked(options) {
619
756
  const existing = await this.store.listManifests(options.cwd);
620
757
  const durableUserPoints = existing.filter(point => point.kind !== 'turn');
package/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { ChangeLedgerEngine } from './engine.js';
2
- import { installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js';
2
+ import { installManageHttp, installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js';
3
+ import { installTurnRewindSettings } from './settings.js';
3
4
  export * from './engine.js';
4
5
  export * from './errors.js';
5
6
  export * from './rewind-host.js';
7
+ export * from './settings.js';
6
8
  export * from './types.js';
7
9
  /** Cordis service exposed as `ctx.changeLedger` for other DSH plugins. */
8
10
  export class ChangeLedgerService {
@@ -15,7 +17,9 @@ export class ChangeLedgerService {
15
17
  ctx.inject(['agents'], (scope) => { checkpoints.install(scope); });
16
18
  ctx.inject(['webServer', 'sessions', 'sessionQuery', 'apiProxy', 'agents'], (scope) => {
17
19
  installRewindHttp(scope, this.engine, checkpoints);
20
+ installManageHttp(scope, this.engine);
18
21
  });
22
+ installTurnRewindSettings(ctx, config, this.engine);
19
23
  void this.engine.initialize().then((reconciled) => {
20
24
  if (reconciled > 0) {
21
25
  ctx.logger.warn(`[change-ledger] reconciled ${reconciled} interrupted durable operation(s)`);
@@ -67,5 +71,17 @@ export class ChangeLedgerService {
67
71
  listRecovery(options) {
68
72
  return this.engine.listRecovery(options);
69
73
  }
74
+ /** Inventory every workspace this storage root has persisted state for. */
75
+ listWorkspaces(options) {
76
+ return this.engine.listWorkspaces(options);
77
+ }
78
+ /** Delete unprotected restore points recorded for one workspace. */
79
+ purgeWorkspace(options) {
80
+ return this.engine.purgeWorkspace(options);
81
+ }
82
+ /** Swap runtime-tunable configuration; the storage root must stay fixed. */
83
+ updateConfig(config) {
84
+ this.engine.updateConfig(config);
85
+ }
70
86
  }
71
87
  export default ChangeLedgerService;
@@ -182,6 +182,95 @@ export function installRewindHttp(ctx, engine, coordinator) {
182
182
  handler: createRewindHttpHandler(ctx, engine, coordinator),
183
183
  }), 'change-ledger.rewindHttp');
184
184
  }
185
+ export const MANAGE_HTTP_PATH = '/turn-rewind/manage';
186
+ /** Register the same-origin storage-management endpoint consumed by the settings card. */
187
+ export function installManageHttp(ctx, engine) {
188
+ ctx.effect(() => ctx.webServer.register({
189
+ kind: 'exact',
190
+ path: MANAGE_HTTP_PATH,
191
+ handler: createManageHttpHandler(engine),
192
+ }), 'change-ledger.manageHttp');
193
+ }
194
+ /** Build the storage-management route as a testable unit. */
195
+ export function createManageHttpHandler(engine) {
196
+ return async (request, response) => {
197
+ try {
198
+ if (request.method === 'GET') {
199
+ const workspaces = await engine.listWorkspaces();
200
+ json(response, 200, {
201
+ storageDir: engine.config.storageDir,
202
+ totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
203
+ workspaces: workspaces.map((workspace) => ({
204
+ workspace: workspace.workspace,
205
+ totalBytes: workspace.totalBytes,
206
+ recoveryCount: workspace.recoveryCount,
207
+ restorePoints: workspace.restorePoints.map((point) => ({
208
+ id: point.id,
209
+ kind: point.kind,
210
+ format: point.format,
211
+ createdAt: point.createdAt,
212
+ totalBytes: point.totalBytes,
213
+ fileCount: point.fileCount,
214
+ ...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
215
+ ...(point.turn === undefined ? {} : { turn: point.turn }),
216
+ ...(point.label === undefined ? {} : { label: point.label }),
217
+ })),
218
+ })),
219
+ });
220
+ return;
221
+ }
222
+ if (request.method === 'POST') {
223
+ const body = objectBody(await readBody(request));
224
+ const action = requiredText(body.action, 'action');
225
+ if (action === 'delete') {
226
+ const workspace = requiredText(body.workspace, 'workspace');
227
+ const restorePointId = requiredText(body.restorePointId, 'restorePointId');
228
+ const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] });
229
+ if (report.deletedRestorePoints === 0) {
230
+ throw new ChangeLedgerError(report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND', report.retainedRestorePoints > 0
231
+ ? 'restore point is required by an incomplete recovery journal'
232
+ : `restore point ${restorePointId} does not exist`);
233
+ }
234
+ json(response, 200, { status: 'completed', action, ...report });
235
+ return;
236
+ }
237
+ if (action === 'clear-workspace') {
238
+ const workspace = requiredText(body.workspace, 'workspace');
239
+ json(response, 200, { status: 'completed', action, ...await engine.purgeWorkspace({ workspace }) });
240
+ return;
241
+ }
242
+ if (action === 'clear-all') {
243
+ const reports = [];
244
+ const failures = [];
245
+ for (const overview of await engine.listWorkspaces()) {
246
+ try {
247
+ reports.push(await engine.purgeWorkspace({ workspace: overview.workspace }));
248
+ }
249
+ catch (error) {
250
+ failures.push({ workspace: overview.workspace, error: errorMessage(error) });
251
+ }
252
+ }
253
+ json(response, 200, {
254
+ status: failures.length > 0 ? 'partial' : 'completed',
255
+ action,
256
+ reports,
257
+ ...(failures.length > 0 ? { failures } : {}),
258
+ });
259
+ return;
260
+ }
261
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'action must be "delete", "clear-workspace", or "clear-all"');
262
+ }
263
+ json(response, 405, { error: 'method not allowed' });
264
+ }
265
+ catch (error) {
266
+ const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409;
267
+ json(response, status, {
268
+ error: errorMessage(error),
269
+ code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED',
270
+ });
271
+ }
272
+ };
273
+ }
185
274
  /** Build the exact-route handler as a testable unit. */
186
275
  export function createRewindHttpHandler(ctx, engine, coordinator) {
187
276
  return async (request, response) => {
@@ -193,7 +282,17 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
193
282
  const detailsOnly = url.searchParams.get('details') === '1';
194
283
  const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset');
195
284
  const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT);
196
- const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
285
+ const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq);
286
+ let checkpoint;
287
+ try {
288
+ checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target);
289
+ }
290
+ catch (error) {
291
+ // File-checkpoint discovery is allowed to fail independently: the dialog
292
+ // must still expose message-only rewind for a valid Session boundary.
293
+ json(response, 200, { status: 'failed', error: errorMessage(error) });
294
+ return;
295
+ }
197
296
  if (checkpoint === undefined) {
198
297
  const durableSkip = await engine.findTurnCheckpointSkip({
199
298
  cwd: target.cwd,
@@ -249,11 +348,20 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
249
348
  if (request.method === 'POST') {
250
349
  const body = objectBody(await readBody(request));
251
350
  const mode = body.mode;
252
- if (mode !== 'code' && mode !== 'both') {
253
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"');
351
+ if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
352
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"');
254
353
  }
255
354
  const sessionId = requiredText(body.sessionId, 'sessionId');
256
355
  const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq');
356
+ if (mode === 'messages') {
357
+ // Message-only rewind never mutates project files, so no checkpoint, no
358
+ // restore plan, and no shared-workspace gate apply; the conversation
359
+ // restart revalidates the message boundary itself.
360
+ const { target } = await resolveMessageTarget(ctx, sessionId, messageSeq);
361
+ const fork = await createConversationRestart(ctx, sessionId, target);
362
+ json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId });
363
+ return;
364
+ }
257
365
  const checkpointId = requiredText(body.checkpointId, 'checkpointId');
258
366
  const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId);
259
367
  const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
@@ -311,15 +419,18 @@ async function readSession(ctx, sessionId) {
311
419
  const stored = await ctx.sessionQuery.readSession(sessionId);
312
420
  return { id: sessionId, header: stored.session, events: stored.events };
313
421
  }
314
- async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
315
- let current = await readSession(ctx, sessionId);
316
- const target = messageTarget(current, messageSeq);
422
+ async function resolveMessageTarget(ctx, sessionId, messageSeq) {
423
+ const session = await readSession(ctx, sessionId);
424
+ return { session, target: messageTarget(session, messageSeq) };
425
+ }
426
+ async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target) {
427
+ let current = source;
317
428
  const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn });
318
429
  if (direct !== undefined) {
319
430
  if (direct.turnStartSeq !== target.turnStartSeq) {
320
431
  throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start');
321
432
  }
322
- return { target, checkpoint: { ...target, id: direct.id } };
433
+ return { ...target, id: direct.id };
323
434
  }
324
435
  const seen = new Set([sessionId]);
325
436
  while (true) {
@@ -330,7 +441,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
330
441
  }
331
442
  if (parentId === undefined || seedLength === undefined
332
443
  || target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
333
- return { target };
444
+ return undefined;
334
445
  }
335
446
  if (seen.has(parentId)) {
336
447
  throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle');
@@ -342,7 +453,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
342
453
  catch (error) {
343
454
  throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error });
344
455
  }
345
- const parentTarget = messageTarget(current, messageSeq);
456
+ const parentTarget = messageTarget(current, target.messageSeq);
346
457
  if (parentTarget.turn !== target.turn
347
458
  || parentTarget.turnStartSeq !== target.turnStartSeq
348
459
  || parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
@@ -354,11 +465,12 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
354
465
  if (inherited.turnStartSeq !== target.turnStartSeq) {
355
466
  throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary');
356
467
  }
357
- return { target, checkpoint: { ...target, id: inherited.id } };
468
+ return { ...target, id: inherited.id };
358
469
  }
359
470
  }
360
471
  async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requestedId) {
361
- const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
472
+ const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq);
473
+ const checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target);
362
474
  if (checkpoint === undefined) {
363
475
  throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`);
364
476
  }
@@ -367,22 +479,22 @@ async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requeste
367
479
  }
368
480
  return checkpoint;
369
481
  }
370
- async function createConversationRestart(ctx, sourceId, checkpoint) {
482
+ async function createConversationRestart(ctx, sourceId, target) {
371
483
  const source = await readSession(ctx, sourceId);
372
- const current = messageTarget(source, checkpoint.messageSeq);
373
- if (current.turn !== checkpoint.turn
374
- || current.turnStartSeq !== checkpoint.turnStartSeq
375
- || current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
484
+ const current = messageTarget(source, target.messageSeq);
485
+ if (current.turn !== target.turn
486
+ || current.turnStartSeq !== target.turnStartSeq
487
+ || current.previousTurnEndSeq !== target.previousTurnEndSeq) {
376
488
  throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary');
377
489
  }
378
- const response = checkpoint.previousTurnEndSeq === undefined
490
+ const response = target.previousTurnEndSeq === undefined
379
491
  ? await ctx.apiProxy.sessions.create({
380
492
  rpcId: randomUUID(),
381
- payload: { cwd: checkpoint.cwd },
493
+ payload: { cwd: target.cwd },
382
494
  })
383
495
  : await ctx.apiProxy.sessions.fork({
384
496
  rpcId: randomUUID(),
385
- payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
497
+ payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
386
498
  });
387
499
  if (!response.result.ok) {
388
500
  throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message);
@@ -0,0 +1,58 @@
1
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
2
+ import z from '@deepseek-ai/schemastery';
3
+ import { resolveConfig } from './engine.js';
4
+ /** Namespace join key shared by the host section and the browser settings card. */
5
+ export const TURN_REWIND_SETTINGS_NAMESPACE = settingsNamespace('turn-rewind');
6
+ /** Schemastery schema for the `turn-rewind` settings namespace. */
7
+ export const TurnRewindSettingsSchema = (() => {
8
+ const defaults = tunableSettings(resolveConfig({}));
9
+ return z.object({
10
+ maxRestorePoints: z.number().step(1).min(1).default(defaults.maxRestorePoints),
11
+ maxTurnCheckpointsPerSession: z.number().step(1).min(1).default(defaults.maxTurnCheckpointsPerSession),
12
+ maxFiles: z.number().step(1).min(1).default(defaults.maxFiles),
13
+ maxFileBytes: z.number().step(1).min(1).default(defaults.maxFileBytes),
14
+ maxSnapshotBytes: z.number().step(1).min(1).default(defaults.maxSnapshotBytes),
15
+ planTtlMs: z.number().step(1).min(1).default(defaults.planTtlMs),
16
+ staleLockMs: z.number().step(1).min(1).default(defaults.staleLockMs),
17
+ turnCheckpointMode: z.union(['off', 'git-native', 'legacy']).default(defaults.turnCheckpointMode),
18
+ turnCheckpointTimeoutMs: z.number().step(1).min(1).default(defaults.turnCheckpointTimeoutMs),
19
+ turnCheckpointMaxNewBytes: z.number().step(1).min(1).default(defaults.turnCheckpointMaxNewBytes),
20
+ turnCheckpointTrust: z.union(['fast', 'strict']).default(defaults.turnCheckpointTrust),
21
+ });
22
+ })();
23
+ /** Project one resolved configuration onto the settings namespace subset. */
24
+ function tunableSettings(resolved) {
25
+ return {
26
+ maxRestorePoints: resolved.maxRestorePoints,
27
+ maxTurnCheckpointsPerSession: resolved.maxTurnCheckpointsPerSession,
28
+ maxFiles: resolved.maxFiles,
29
+ maxFileBytes: resolved.maxFileBytes,
30
+ maxSnapshotBytes: resolved.maxSnapshotBytes,
31
+ planTtlMs: resolved.planTtlMs,
32
+ staleLockMs: resolved.staleLockMs,
33
+ turnCheckpointMode: resolved.turnCheckpointMode,
34
+ turnCheckpointTimeoutMs: resolved.turnCheckpointTimeoutMs,
35
+ turnCheckpointMaxNewBytes: resolved.turnCheckpointMaxNewBytes,
36
+ turnCheckpointTrust: resolved.turnCheckpointTrust,
37
+ };
38
+ }
39
+ /**
40
+ * Register the `turn-rewind` settings namespace and apply its resolved value to the
41
+ * running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
42
+ * the user layer persists through the DSH settings provider. `storageDir` is never
43
+ * carried by the namespace: the storage root must not move while the engine runs.
44
+ */
45
+ export function installTurnRewindSettings(ctx, config, engine) {
46
+ let source = () => tunableSettings(resolveConfig(config));
47
+ installSettingsSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, source(), {
48
+ setSource: (current) => { source = current; },
49
+ onChange: () => {
50
+ try {
51
+ engine.updateConfig({ ...config, ...source() });
52
+ }
53
+ catch (error) {
54
+ ctx.logger.warn(`[turn-rewind] could not apply settings update: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ },
57
+ });
58
+ }