@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,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { createDeadline } from './deadline.js';
2
3
  import { ChangeLedgerError, errorMessage } from './errors.js';
3
4
  import { discoverRepositoryRoot } from './git.js';
4
5
  export const REWIND_HTTP_PATH = '/turn-rewind';
@@ -11,6 +12,7 @@ export class TurnCheckpointCoordinator {
11
12
  captures = new Map();
12
13
  pending = new Set();
13
14
  failures = new Map();
15
+ skips = new Map();
14
16
  workspaceTails = new Map();
15
17
  constructor(engine) {
16
18
  this.engine = engine;
@@ -28,55 +30,90 @@ export class TurnCheckpointCoordinator {
28
30
  const key = checkpointKey(sessionId, turn);
29
31
  if (this.pending.has(key))
30
32
  return { status: 'pending' };
33
+ const reason = this.skips.get(key);
34
+ if (reason !== undefined)
35
+ return { status: 'skipped', reason };
31
36
  const error = this.failures.get(key);
32
37
  return error === undefined ? { status: 'missing' } : { status: 'failed', error };
33
38
  }
34
39
  async capture(ctx, agent, turn, signal) {
35
40
  const key = checkpointKey(agent.id, turn);
36
- const existing = this.captures.get(key);
37
- if (existing !== undefined) {
38
- await existing;
39
- return;
40
- }
41
41
  const cwd = agent.session.header.cwd;
42
42
  if (cwd === undefined)
43
43
  return;
44
44
  const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn);
45
45
  if (start === undefined) {
46
- this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
46
+ await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
47
47
  return;
48
48
  }
49
- this.pending.add(key);
50
- this.failures.delete(key);
51
- const capture = (async () => {
52
- try {
53
- const workspace = await discoverRepositoryRoot(cwd, signal);
54
- await this.serializeWorkspace(workspace, signal, async () => {
55
- await this.engine.createTurnCheckpoint({
56
- cwd: workspace,
57
- sessionId: agent.id,
58
- turn,
59
- turnStartSeq: start.seq,
60
- signal,
49
+ const timeoutMs = this.engine.config.turnCheckpointTimeoutMs;
50
+ const outcomeDeadline = createDeadline(timeoutMs);
51
+ const outcomeSignal = AbortSignal.any([signal, outcomeDeadline.signal]);
52
+ const reserveMs = Math.min(250, Math.max(10, Math.ceil(timeoutMs / 5)));
53
+ const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs));
54
+ const captureSignal = AbortSignal.any([signal, captureDeadline.signal]);
55
+ try {
56
+ const existing = this.captures.get(key);
57
+ if (existing !== undefined) {
58
+ await waitWithSignal(existing, captureSignal).catch(() => undefined);
59
+ return;
60
+ }
61
+ this.pending.add(key);
62
+ this.failures.delete(key);
63
+ this.skips.delete(key);
64
+ const capture = (async () => {
65
+ try {
66
+ const workspace = await discoverRepositoryRoot(cwd, captureSignal);
67
+ await this.serializeWorkspace(workspace, captureSignal, async () => {
68
+ try {
69
+ await this.engine.createTurnCheckpoint({
70
+ cwd: workspace,
71
+ sessionId: agent.id,
72
+ turn,
73
+ turnStartSeq: start.seq,
74
+ signal: captureSignal,
75
+ });
76
+ }
77
+ catch (error) {
78
+ await this.recordFailure(ctx, agent.id, turn, checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal), { cwd: workspace, turnStartSeq: start.seq });
79
+ }
61
80
  });
62
- });
81
+ }
82
+ catch (error) {
83
+ await this.recordFailure(ctx, agent.id, turn, checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal), { cwd, turnStartSeq: start.seq });
84
+ }
85
+ finally {
86
+ this.pending.delete(key);
87
+ }
88
+ })();
89
+ this.captures.set(key, capture);
90
+ try {
91
+ await waitWithSignal(capture, outcomeSignal);
63
92
  }
64
93
  catch (error) {
65
- this.recordFailure(ctx, agent.id, turn, error);
66
- }
67
- finally {
94
+ const bounded = checkpointTimeoutError(error, timeoutMs, outcomeDeadline.signal, signal);
95
+ const message = errorMessage(bounded);
68
96
  this.pending.delete(key);
97
+ if (bounded instanceof ChangeLedgerError && isCheckpointSkip(bounded.code)) {
98
+ this.skips.set(key, message);
99
+ }
100
+ else {
101
+ this.failures.set(key, message);
102
+ }
69
103
  }
70
- })();
71
- this.captures.set(key, capture);
72
- await capture;
104
+ }
105
+ finally {
106
+ captureDeadline.cancel();
107
+ outcomeDeadline.cancel();
108
+ }
73
109
  }
74
110
  async serializeWorkspace(workspace, signal, task) {
75
111
  const previous = this.workspaceTails.get(workspace) ?? Promise.resolve();
76
- const current = previous.catch(() => undefined).then(async () => {
112
+ const current = (async () => {
113
+ await waitWithSignal(previous.catch(() => undefined), signal);
77
114
  signal.throwIfAborted();
78
115
  await task();
79
- });
116
+ })();
80
117
  this.workspaceTails.set(workspace, current);
81
118
  try {
82
119
  await current;
@@ -86,12 +123,57 @@ export class TurnCheckpointCoordinator {
86
123
  this.workspaceTails.delete(workspace);
87
124
  }
88
125
  }
89
- recordFailure(ctx, sessionId, turn, error) {
126
+ async recordFailure(ctx, sessionId, turn, error, skipContext) {
90
127
  const message = errorMessage(error);
128
+ if (error instanceof ChangeLedgerError && isCheckpointSkip(error.code)) {
129
+ this.skips.set(checkpointKey(sessionId, turn), message);
130
+ if (skipContext !== undefined) {
131
+ try {
132
+ await this.engine.recordTurnCheckpointSkip({
133
+ cwd: skipContext.cwd,
134
+ sessionId,
135
+ turn,
136
+ turnStartSeq: skipContext.turnStartSeq,
137
+ reason: message,
138
+ });
139
+ }
140
+ catch (persistError) {
141
+ ctx.logger.warn(`[turn-rewind] could not persist checkpoint skip for ${sessionId} turn ${String(turn)}: ${errorMessage(persistError)}`);
142
+ }
143
+ }
144
+ ctx.logger.warn(`[turn-rewind] checkpoint skipped for ${sessionId} turn ${String(turn)}: ${message}`);
145
+ return;
146
+ }
91
147
  this.failures.set(checkpointKey(sessionId, turn), message);
92
148
  ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`);
93
149
  }
94
150
  }
151
+ function isCheckpointSkip(code) {
152
+ return code === 'TURN_CHECKPOINT_DISABLED'
153
+ || code === 'TURN_CHECKPOINT_TIMEOUT'
154
+ || code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT'
155
+ || code === 'GIT_OBJECT_STORE_UNSUPPORTED'
156
+ || code === 'GIT_INDEX_UNSUPPORTED';
157
+ }
158
+ async function waitWithSignal(promise, signal) {
159
+ signal.throwIfAborted();
160
+ let rejectAbort;
161
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
162
+ const onAbort = () => rejectAbort?.(signal.reason);
163
+ signal.addEventListener('abort', onAbort, { once: true });
164
+ try {
165
+ return await Promise.race([promise, aborted]);
166
+ }
167
+ finally {
168
+ signal.removeEventListener('abort', onAbort);
169
+ }
170
+ }
171
+ function checkpointTimeoutError(error, timeoutMs, deadline, callerSignal) {
172
+ if (!callerSignal.aborted && deadline.aborted) {
173
+ return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error });
174
+ }
175
+ return error;
176
+ }
95
177
  /** Register the same-origin preview/apply endpoint consumed by the browser half. */
96
178
  export function installRewindHttp(ctx, engine, coordinator) {
97
179
  ctx.effect(() => ctx.webServer.register({
@@ -100,6 +182,95 @@ export function installRewindHttp(ctx, engine, coordinator) {
100
182
  handler: createRewindHttpHandler(ctx, engine, coordinator),
101
183
  }), 'change-ledger.rewindHttp');
102
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
+ }
103
274
  /** Build the exact-route handler as a testable unit. */
104
275
  export function createRewindHttpHandler(ctx, engine, coordinator) {
105
276
  return async (request, response) => {
@@ -111,9 +282,27 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
111
282
  const detailsOnly = url.searchParams.get('details') === '1';
112
283
  const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset');
113
284
  const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT);
114
- 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
+ }
115
296
  if (checkpoint === undefined) {
116
- json(response, 200, coordinator.state(sessionId, target.turn));
297
+ const durableSkip = await engine.findTurnCheckpointSkip({
298
+ cwd: target.cwd,
299
+ sessionId,
300
+ turn: target.turn,
301
+ turnStartSeq: target.turnStartSeq,
302
+ });
303
+ json(response, 200, durableSkip === undefined
304
+ ? coordinator.state(sessionId, target.turn)
305
+ : { status: 'skipped', reason: durableSkip.reason });
117
306
  return;
118
307
  }
119
308
  const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id });
@@ -159,11 +348,20 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
159
348
  if (request.method === 'POST') {
160
349
  const body = objectBody(await readBody(request));
161
350
  const mode = body.mode;
162
- if (mode !== 'code' && mode !== 'both') {
163
- 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"');
164
353
  }
165
354
  const sessionId = requiredText(body.sessionId, 'sessionId');
166
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
+ }
167
365
  const checkpointId = requiredText(body.checkpointId, 'checkpointId');
168
366
  const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId);
169
367
  const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
@@ -221,15 +419,18 @@ async function readSession(ctx, sessionId) {
221
419
  const stored = await ctx.sessionQuery.readSession(sessionId);
222
420
  return { id: sessionId, header: stored.session, events: stored.events };
223
421
  }
224
- async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
225
- let current = await readSession(ctx, sessionId);
226
- 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;
227
428
  const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn });
228
429
  if (direct !== undefined) {
229
430
  if (direct.turnStartSeq !== target.turnStartSeq) {
230
431
  throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start');
231
432
  }
232
- return { target, checkpoint: { ...target, id: direct.id } };
433
+ return { ...target, id: direct.id };
233
434
  }
234
435
  const seen = new Set([sessionId]);
235
436
  while (true) {
@@ -240,7 +441,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
240
441
  }
241
442
  if (parentId === undefined || seedLength === undefined
242
443
  || target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
243
- return { target };
444
+ return undefined;
244
445
  }
245
446
  if (seen.has(parentId)) {
246
447
  throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle');
@@ -252,7 +453,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
252
453
  catch (error) {
253
454
  throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error });
254
455
  }
255
- const parentTarget = messageTarget(current, messageSeq);
456
+ const parentTarget = messageTarget(current, target.messageSeq);
256
457
  if (parentTarget.turn !== target.turn
257
458
  || parentTarget.turnStartSeq !== target.turnStartSeq
258
459
  || parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
@@ -264,11 +465,12 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
264
465
  if (inherited.turnStartSeq !== target.turnStartSeq) {
265
466
  throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary');
266
467
  }
267
- return { target, checkpoint: { ...target, id: inherited.id } };
468
+ return { ...target, id: inherited.id };
268
469
  }
269
470
  }
270
471
  async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requestedId) {
271
- 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);
272
474
  if (checkpoint === undefined) {
273
475
  throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`);
274
476
  }
@@ -277,22 +479,22 @@ async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requeste
277
479
  }
278
480
  return checkpoint;
279
481
  }
280
- async function createConversationRestart(ctx, sourceId, checkpoint) {
482
+ async function createConversationRestart(ctx, sourceId, target) {
281
483
  const source = await readSession(ctx, sourceId);
282
- const current = messageTarget(source, checkpoint.messageSeq);
283
- if (current.turn !== checkpoint.turn
284
- || current.turnStartSeq !== checkpoint.turnStartSeq
285
- || 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) {
286
488
  throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary');
287
489
  }
288
- const response = checkpoint.previousTurnEndSeq === undefined
490
+ const response = target.previousTurnEndSeq === undefined
289
491
  ? await ctx.apiProxy.sessions.create({
290
492
  rpcId: randomUUID(),
291
- payload: { cwd: checkpoint.cwd },
493
+ payload: { cwd: target.cwd },
292
494
  })
293
495
  : await ctx.apiProxy.sessions.fork({
294
496
  rpcId: randomUUID(),
295
- payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
497
+ payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
296
498
  });
297
499
  if (!response.result.ok) {
298
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
+ }