@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.
- package/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- package/src/validate.ts +67 -7
package/lib/rewind-host.js
CHANGED
|
@@ -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.
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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 "
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
468
|
+
return { ...target, id: inherited.id };
|
|
268
469
|
}
|
|
269
470
|
}
|
|
270
471
|
async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requestedId) {
|
|
271
|
-
const {
|
|
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,
|
|
482
|
+
async function createConversationRestart(ctx, sourceId, target) {
|
|
281
483
|
const source = await readSession(ctx, sourceId);
|
|
282
|
-
const current = messageTarget(source,
|
|
283
|
-
if (current.turn !==
|
|
284
|
-
|| current.turnStartSeq !==
|
|
285
|
-
|| current.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 =
|
|
490
|
+
const response = target.previousTurnEndSeq === undefined
|
|
289
491
|
? await ctx.apiProxy.sessions.create({
|
|
290
492
|
rpcId: randomUUID(),
|
|
291
|
-
payload: { cwd:
|
|
493
|
+
payload: { cwd: target.cwd },
|
|
292
494
|
})
|
|
293
495
|
: await ctx.apiProxy.sessions.fork({
|
|
294
496
|
rpcId: randomUUID(),
|
|
295
|
-
payload: { sessionId: sourceId, atSeq:
|
|
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);
|
package/lib/settings.js
ADDED
|
@@ -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
|
+
}
|