@anionex/dsh-turn-rewind 0.2.0 → 0.2.2
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/client.js +103 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/rewind-host.js +153 -148
- package/lib/types/client/index.d.ts +57 -9
- package/lib/types/rewind-host.d.ts +43 -7
- package/package.json +6 -2
- package/src/client/index.tsx +137 -20
- package/src/index.ts +3 -1
- package/src/rewind-host.ts +197 -164
package/lib/rewind-host.js
CHANGED
|
@@ -2,11 +2,19 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { createDeadline } from './deadline.js';
|
|
3
3
|
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
4
4
|
import { discoverRepositoryRoot } from './git.js';
|
|
5
|
+
function getSessionEvents(session) {
|
|
6
|
+
if (!session)
|
|
7
|
+
return [];
|
|
8
|
+
if (typeof session.snapshotEvents === 'function') {
|
|
9
|
+
return session.snapshotEvents();
|
|
10
|
+
}
|
|
11
|
+
return session.events ?? [];
|
|
12
|
+
}
|
|
5
13
|
export const REWIND_HTTP_PATH = '/turn-rewind';
|
|
6
14
|
const BODY_LIMIT = 64 * 1024;
|
|
7
15
|
const INITIAL_CHANGE_PREVIEW_LIMIT = 8;
|
|
8
16
|
const MAX_CHANGE_PAGE_SIZE = 200;
|
|
9
|
-
/** Capture each turn
|
|
17
|
+
/** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
|
|
10
18
|
export class TurnCheckpointCoordinator {
|
|
11
19
|
engine;
|
|
12
20
|
captures = new Map();
|
|
@@ -17,11 +25,17 @@ export class TurnCheckpointCoordinator {
|
|
|
17
25
|
constructor(engine) {
|
|
18
26
|
this.engine = engine;
|
|
19
27
|
}
|
|
20
|
-
/**
|
|
28
|
+
/** Keep sidecar checkpoint work out of the Agent response waterfall. */
|
|
21
29
|
install(ctx) {
|
|
22
30
|
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
|
|
23
31
|
if (step === 1)
|
|
24
|
-
|
|
32
|
+
this.startCapture(ctx, agent, turn, signal);
|
|
33
|
+
return next();
|
|
34
|
+
}, { prepend: true });
|
|
35
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
36
|
+
if (exec.agent !== undefined && exec.parent === undefined) {
|
|
37
|
+
await this.waitForOpenTurn(exec.agent, exec.signal);
|
|
38
|
+
}
|
|
25
39
|
return next();
|
|
26
40
|
}, { prepend: true });
|
|
27
41
|
}
|
|
@@ -36,12 +50,22 @@ export class TurnCheckpointCoordinator {
|
|
|
36
50
|
const error = this.failures.get(key);
|
|
37
51
|
return error === undefined ? { status: 'missing' } : { status: 'failed', error };
|
|
38
52
|
}
|
|
53
|
+
startCapture(ctx, agent, turn, signal) {
|
|
54
|
+
const key = checkpointKey(agent.id, turn);
|
|
55
|
+
if (this.captures.has(key))
|
|
56
|
+
return;
|
|
57
|
+
const capture = this.capture(ctx, agent, turn, signal).catch(async (error) => {
|
|
58
|
+
await this.recordFailure(ctx, agent.id, turn, error);
|
|
59
|
+
});
|
|
60
|
+
this.captures.set(key, capture);
|
|
61
|
+
}
|
|
39
62
|
async capture(ctx, agent, turn, signal) {
|
|
40
63
|
const key = checkpointKey(agent.id, turn);
|
|
41
64
|
const cwd = agent.session.header.cwd;
|
|
42
65
|
if (cwd === undefined)
|
|
43
66
|
return;
|
|
44
|
-
const
|
|
67
|
+
const events = getSessionEvents(agent.session);
|
|
68
|
+
const start = events.findLast(event => event.type === 'turn/start' && event.data.turn === turn);
|
|
45
69
|
if (start === undefined) {
|
|
46
70
|
await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
|
|
47
71
|
return;
|
|
@@ -53,11 +77,6 @@ export class TurnCheckpointCoordinator {
|
|
|
53
77
|
const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs));
|
|
54
78
|
const captureSignal = AbortSignal.any([signal, captureDeadline.signal]);
|
|
55
79
|
try {
|
|
56
|
-
const existing = this.captures.get(key);
|
|
57
|
-
if (existing !== undefined) {
|
|
58
|
-
await waitWithSignal(existing, captureSignal).catch(() => undefined);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
80
|
this.pending.add(key);
|
|
62
81
|
this.failures.delete(key);
|
|
63
82
|
this.skips.delete(key);
|
|
@@ -86,7 +105,6 @@ export class TurnCheckpointCoordinator {
|
|
|
86
105
|
this.pending.delete(key);
|
|
87
106
|
}
|
|
88
107
|
})();
|
|
89
|
-
this.captures.set(key, capture);
|
|
90
108
|
try {
|
|
91
109
|
await waitWithSignal(capture, outcomeSignal);
|
|
92
110
|
}
|
|
@@ -107,6 +125,30 @@ export class TurnCheckpointCoordinator {
|
|
|
107
125
|
outcomeDeadline.cancel();
|
|
108
126
|
}
|
|
109
127
|
}
|
|
128
|
+
/** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
|
|
129
|
+
async waitForOpenTurn(agent, signal) {
|
|
130
|
+
if (signal.aborted)
|
|
131
|
+
return;
|
|
132
|
+
const events = getSessionEvents(agent.session);
|
|
133
|
+
const boundary = events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end');
|
|
134
|
+
if (boundary?.type !== 'turn/start')
|
|
135
|
+
return;
|
|
136
|
+
const turn = boundary.data.turn;
|
|
137
|
+
if (!Number.isSafeInteger(turn) || turn < 0)
|
|
138
|
+
return;
|
|
139
|
+
const capture = this.captures.get(checkpointKey(agent.id, turn));
|
|
140
|
+
if (capture === undefined)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
await waitWithSignal(capture, signal);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
// Delegate caller cancellation so the DSH tool runtime preserves its
|
|
147
|
+
// canonical ABORTED_BEFORE_DISPATCH result instead of a wrapper error.
|
|
148
|
+
if (!signal.aborted)
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
110
152
|
async serializeWorkspace(workspace, signal, task) {
|
|
111
153
|
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve();
|
|
112
154
|
const current = (async () => {
|
|
@@ -182,95 +224,6 @@ export function installRewindHttp(ctx, engine, coordinator) {
|
|
|
182
224
|
handler: createRewindHttpHandler(ctx, engine, coordinator),
|
|
183
225
|
}), 'change-ledger.rewindHttp');
|
|
184
226
|
}
|
|
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
|
-
}
|
|
274
227
|
/** Build the exact-route handler as a testable unit. */
|
|
275
228
|
export function createRewindHttpHandler(ctx, engine, coordinator) {
|
|
276
229
|
return async (request, response) => {
|
|
@@ -282,17 +235,7 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
|
|
|
282
235
|
const detailsOnly = url.searchParams.get('details') === '1';
|
|
283
236
|
const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset');
|
|
284
237
|
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT);
|
|
285
|
-
const {
|
|
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
|
-
}
|
|
238
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
|
|
296
239
|
if (checkpoint === undefined) {
|
|
297
240
|
const durableSkip = await engine.findTurnCheckpointSkip({
|
|
298
241
|
cwd: target.cwd,
|
|
@@ -348,20 +291,11 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
|
|
|
348
291
|
if (request.method === 'POST') {
|
|
349
292
|
const body = objectBody(await readBody(request));
|
|
350
293
|
const mode = body.mode;
|
|
351
|
-
if (mode !== 'code' && mode !== 'both'
|
|
352
|
-
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code"
|
|
294
|
+
if (mode !== 'code' && mode !== 'both') {
|
|
295
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"');
|
|
353
296
|
}
|
|
354
297
|
const sessionId = requiredText(body.sessionId, 'sessionId');
|
|
355
298
|
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
|
-
}
|
|
365
299
|
const checkpointId = requiredText(body.checkpointId, 'checkpointId');
|
|
366
300
|
const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId);
|
|
367
301
|
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
|
|
@@ -419,18 +353,15 @@ async function readSession(ctx, sessionId) {
|
|
|
419
353
|
const stored = await ctx.sessionQuery.readSession(sessionId);
|
|
420
354
|
return { id: sessionId, header: stored.session, events: stored.events };
|
|
421
355
|
}
|
|
422
|
-
async function
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target) {
|
|
427
|
-
let current = source;
|
|
356
|
+
async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
|
|
357
|
+
let current = await readSession(ctx, sessionId);
|
|
358
|
+
const target = messageTarget(current, messageSeq);
|
|
428
359
|
const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn });
|
|
429
360
|
if (direct !== undefined) {
|
|
430
361
|
if (direct.turnStartSeq !== target.turnStartSeq) {
|
|
431
362
|
throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start');
|
|
432
363
|
}
|
|
433
|
-
return { ...target, id: direct.id };
|
|
364
|
+
return { target, checkpoint: { ...target, id: direct.id } };
|
|
434
365
|
}
|
|
435
366
|
const seen = new Set([sessionId]);
|
|
436
367
|
while (true) {
|
|
@@ -441,7 +372,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target)
|
|
|
441
372
|
}
|
|
442
373
|
if (parentId === undefined || seedLength === undefined
|
|
443
374
|
|| target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
|
|
444
|
-
return
|
|
375
|
+
return { target };
|
|
445
376
|
}
|
|
446
377
|
if (seen.has(parentId)) {
|
|
447
378
|
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle');
|
|
@@ -453,7 +384,7 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target)
|
|
|
453
384
|
catch (error) {
|
|
454
385
|
throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error });
|
|
455
386
|
}
|
|
456
|
-
const parentTarget = messageTarget(current,
|
|
387
|
+
const parentTarget = messageTarget(current, messageSeq);
|
|
457
388
|
if (parentTarget.turn !== target.turn
|
|
458
389
|
|| parentTarget.turnStartSeq !== target.turnStartSeq
|
|
459
390
|
|| parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
@@ -465,12 +396,11 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, source, target)
|
|
|
465
396
|
if (inherited.turnStartSeq !== target.turnStartSeq) {
|
|
466
397
|
throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary');
|
|
467
398
|
}
|
|
468
|
-
return { ...target, id: inherited.id };
|
|
399
|
+
return { target, checkpoint: { ...target, id: inherited.id } };
|
|
469
400
|
}
|
|
470
401
|
}
|
|
471
402
|
async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requestedId) {
|
|
472
|
-
const {
|
|
473
|
-
const checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target);
|
|
403
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
|
|
474
404
|
if (checkpoint === undefined) {
|
|
475
405
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`);
|
|
476
406
|
}
|
|
@@ -479,22 +409,33 @@ async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requeste
|
|
|
479
409
|
}
|
|
480
410
|
return checkpoint;
|
|
481
411
|
}
|
|
482
|
-
async function createConversationRestart(ctx, sourceId,
|
|
412
|
+
async function createConversationRestart(ctx, sourceId, checkpoint) {
|
|
483
413
|
const source = await readSession(ctx, sourceId);
|
|
484
|
-
const current = messageTarget(source,
|
|
485
|
-
if (current.turn !==
|
|
486
|
-
|| current.turnStartSeq !==
|
|
487
|
-
|| current.previousTurnEndSeq !==
|
|
414
|
+
const current = messageTarget(source, checkpoint.messageSeq);
|
|
415
|
+
if (current.turn !== checkpoint.turn
|
|
416
|
+
|| current.turnStartSeq !== checkpoint.turnStartSeq
|
|
417
|
+
|| current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
|
|
488
418
|
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary');
|
|
489
419
|
}
|
|
490
|
-
const
|
|
491
|
-
|
|
420
|
+
const controller = ctx.sessionController;
|
|
421
|
+
if (controller !== undefined) {
|
|
422
|
+
const created = checkpoint.previousTurnEndSeq === undefined
|
|
423
|
+
? await controller.create({ cwd: checkpoint.cwd })
|
|
424
|
+
: await controller.fork({ sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq });
|
|
425
|
+
return { sessionId: requiredText(created.sessionId, 'sessionController sessionId') };
|
|
426
|
+
}
|
|
427
|
+
const apiProxy = ctx.apiProxy;
|
|
428
|
+
if (apiProxy === undefined) {
|
|
429
|
+
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', 'this DSH host exposes neither sessionController nor apiProxy, so a new conversation cannot be created');
|
|
430
|
+
}
|
|
431
|
+
const response = checkpoint.previousTurnEndSeq === undefined
|
|
432
|
+
? await apiProxy.sessions.create({
|
|
492
433
|
rpcId: randomUUID(),
|
|
493
|
-
payload: { cwd:
|
|
434
|
+
payload: { cwd: checkpoint.cwd },
|
|
494
435
|
})
|
|
495
|
-
: await
|
|
436
|
+
: await apiProxy.sessions.fork({
|
|
496
437
|
rpcId: randomUUID(),
|
|
497
|
-
payload: { sessionId: sourceId, atSeq:
|
|
438
|
+
payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
|
|
498
439
|
});
|
|
499
440
|
if (!response.result.ok) {
|
|
500
441
|
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message);
|
|
@@ -505,27 +446,28 @@ function messageTarget(session, messageSeq) {
|
|
|
505
446
|
const cwd = session.header.cwd;
|
|
506
447
|
if (cwd === undefined)
|
|
507
448
|
throw new ChangeLedgerError('WORKSPACE_REQUIRED', `session ${session.id} has no workspace`);
|
|
508
|
-
const
|
|
449
|
+
const events = getSessionEvents(session);
|
|
450
|
+
const message = events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event));
|
|
509
451
|
if (message === undefined) {
|
|
510
452
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `session ${session.id} has no user message at ${String(messageSeq)}`);
|
|
511
453
|
}
|
|
512
|
-
const start =
|
|
454
|
+
const start = events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq);
|
|
513
455
|
const turn = start?.data.turn;
|
|
514
456
|
if (start === undefined || !Number.isSafeInteger(turn) || turn < 0) {
|
|
515
457
|
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message has no valid turn start');
|
|
516
458
|
}
|
|
517
|
-
const openingMessage =
|
|
459
|
+
const openingMessage = events.find(event => (event.type === 'user/message'
|
|
518
460
|
&& event.seq > start.seq
|
|
519
461
|
&& event.seq <= messageSeq
|
|
520
462
|
&& isDirectUserMessage(event)));
|
|
521
463
|
if (openingMessage?.seq !== messageSeq) {
|
|
522
464
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', 'rewind is available only for the opening user message of a turn');
|
|
523
465
|
}
|
|
524
|
-
const interveningEnd =
|
|
466
|
+
const interveningEnd = events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq);
|
|
525
467
|
if (interveningEnd !== undefined) {
|
|
526
468
|
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message is outside its recorded turn');
|
|
527
469
|
}
|
|
528
|
-
const previousEnd =
|
|
470
|
+
const previousEnd = events.findLast(event => event.type === 'turn/end' && event.seq < start.seq);
|
|
529
471
|
return {
|
|
530
472
|
messageSeq,
|
|
531
473
|
turn: turn,
|
|
@@ -621,3 +563,66 @@ function json(response, status, value) {
|
|
|
621
563
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
622
564
|
response.end(`${JSON.stringify(value)}\n`);
|
|
623
565
|
}
|
|
566
|
+
export const MANAGE_HTTP_PATH = '/turn-rewind/manage';
|
|
567
|
+
/** Register the same-origin storage-management endpoint consumed by the settings card. */
|
|
568
|
+
export function installManageHttp(ctx, engine) {
|
|
569
|
+
ctx.effect(() => ctx.webServer.register({
|
|
570
|
+
kind: 'exact',
|
|
571
|
+
path: MANAGE_HTTP_PATH,
|
|
572
|
+
handler: createManageHttpHandler(engine),
|
|
573
|
+
}), 'change-ledger.manageHttp');
|
|
574
|
+
}
|
|
575
|
+
/** Build the storage-management route as a testable unit. */
|
|
576
|
+
export function createManageHttpHandler(engine) {
|
|
577
|
+
return async (request, response) => {
|
|
578
|
+
try {
|
|
579
|
+
if (request.method === 'GET') {
|
|
580
|
+
const workspaces = await engine.listWorkspaces();
|
|
581
|
+
json(response, 200, {
|
|
582
|
+
storageDir: engine.config.storageDir,
|
|
583
|
+
totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
|
|
584
|
+
workspaces: workspaces.map((workspace) => ({
|
|
585
|
+
workspace: workspace.workspace,
|
|
586
|
+
totalBytes: workspace.totalBytes,
|
|
587
|
+
recoveryCount: workspace.recoveryCount,
|
|
588
|
+
restorePoints: workspace.restorePoints.map((point) => ({
|
|
589
|
+
id: point.id,
|
|
590
|
+
kind: point.kind,
|
|
591
|
+
format: point.format,
|
|
592
|
+
createdAt: point.createdAt,
|
|
593
|
+
totalBytes: point.totalBytes,
|
|
594
|
+
fileCount: point.fileCount,
|
|
595
|
+
...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
|
|
596
|
+
...(point.turn === undefined ? {} : { turn: point.turn }),
|
|
597
|
+
...(point.label === undefined ? {} : { label: point.label }),
|
|
598
|
+
})),
|
|
599
|
+
})),
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (request.method === 'POST') {
|
|
604
|
+
const body = objectBody(await readBody(request));
|
|
605
|
+
const action = requiredText(body.action, 'action');
|
|
606
|
+
if (action === 'delete') {
|
|
607
|
+
const workspace = requiredText(body.workspace, 'workspace');
|
|
608
|
+
const restorePointId = requiredText(body.restorePointId, 'restorePointId');
|
|
609
|
+
const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] });
|
|
610
|
+
if (report.deletedRestorePoints === 0) {
|
|
611
|
+
throw new ChangeLedgerError(report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND', report.retainedRestorePoints > 0
|
|
612
|
+
? 'restore point is required by an incomplete recovery journal'
|
|
613
|
+
: `restore point ${restorePointId} does not exist`);
|
|
614
|
+
}
|
|
615
|
+
json(response, 200, { status: 'completed', action, ...report });
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `unsupported manage action ${action}`);
|
|
619
|
+
}
|
|
620
|
+
response.writeHead(405, { allow: 'GET, POST' });
|
|
621
|
+
response.end();
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409;
|
|
625
|
+
json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED' });
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
}
|
|
@@ -12,14 +12,29 @@ interface ConversationChatNodeLike {
|
|
|
12
12
|
readonly kind: string;
|
|
13
13
|
readonly data: ConversationNodeLike;
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Chat node store exposed by the Chat target. DSH 0.1.2+ publishes a mutable
|
|
17
|
+
* keyed store; older clients published a Map with the same `get` contract.
|
|
18
|
+
*/
|
|
19
|
+
interface ChatNodeStoreLike {
|
|
20
|
+
get(key: string): RewindNodeLike | undefined;
|
|
21
|
+
values?(): Iterable<RewindNodeLike>;
|
|
22
|
+
}
|
|
23
|
+
/** Chat projection shared by `session.chat` (0.1.1) and the `useChat` hook (0.1.2+). */
|
|
24
|
+
interface ChatSnapshotLike {
|
|
25
|
+
readonly order?: readonly string[];
|
|
26
|
+
readonly nodes?: ChatNodeStoreLike | readonly RewindNodeLike[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Snapshot seen by a session-scoped slot entry. 0.1.1 exposes the chat
|
|
30
|
+
* projection as `snapshot.chat`; 0.1.2+ moved it to the separate `useChat`
|
|
31
|
+
* hook, so the same snapshot is the chat projection itself.
|
|
32
|
+
*/
|
|
33
|
+
interface ConversationSnapshotLike extends ChatSnapshotLike {
|
|
34
|
+
readonly chat?: ChatSnapshotLike;
|
|
22
35
|
}
|
|
36
|
+
/** Selector hook shape shared by `useSession` and `useChat`. */
|
|
37
|
+
type SnapshotSelectorHook = <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T;
|
|
23
38
|
type RewindNodeLike = ConversationNodeLike | ConversationChatNodeLike;
|
|
24
39
|
interface RewindMatch {
|
|
25
40
|
readonly messageSeq: number;
|
|
@@ -33,7 +48,8 @@ interface RewindMessageActionProps {
|
|
|
33
48
|
interface RewindPortalBridgeProps {
|
|
34
49
|
readonly sessionId: string;
|
|
35
50
|
readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>;
|
|
36
|
-
readonly useSession:
|
|
51
|
+
readonly useSession: SnapshotSelectorHook;
|
|
52
|
+
readonly useChat?: SnapshotSelectorHook;
|
|
37
53
|
}
|
|
38
54
|
interface SlotsLike {
|
|
39
55
|
inject(name: string, install: () => unknown): void;
|
|
@@ -127,8 +143,20 @@ export declare function selectRewindMessage(node: ConversationNodeLike): RewindM
|
|
|
127
143
|
/** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
|
|
128
144
|
export declare const inject: string[];
|
|
129
145
|
export declare function apply(ctx: ClientContextLike): void;
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the ordered chat node list from one chat projection.
|
|
148
|
+
*
|
|
149
|
+
* Both inputs keep a stable identity across renders: `order` is republished only
|
|
150
|
+
* when the node set changes and the store is a mutable handle, so the caller can
|
|
151
|
+
* memoize the list instead of allocating a new array on every render (a fresh
|
|
152
|
+
* array per render makes `useSyncExternalStore` loop forever).
|
|
153
|
+
* @param order - ordered node keys, or null when the projection is absent.
|
|
154
|
+
* @param store - keyed node store, a legacy node array, or null.
|
|
155
|
+
* @returns the chat nodes in render order.
|
|
156
|
+
*/
|
|
157
|
+
export declare function collectChatNodes(order: readonly string[] | null, store: ChatNodeStoreLike | readonly RewindNodeLike[] | null): readonly RewindNodeLike[];
|
|
130
158
|
/** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
|
|
131
|
-
export declare function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode;
|
|
159
|
+
export declare function RewindMessagePortals({ sessionId, openRestoredSession, useSession, useChat }: RewindPortalBridgeProps): ReactNode;
|
|
132
160
|
/** User-message action and its review-first file/conversation restore dialog. */
|
|
133
161
|
export declare function RewindMessageAction({ matched, sessionId, openRestoredSession }: RewindMessageActionProps): ReactNode;
|
|
134
162
|
interface TurnRewindSettingsCardProps {
|
|
@@ -143,6 +171,26 @@ export declare function selectRewindMessageTarget(value: RewindNodeLike): {
|
|
|
143
171
|
readonly matched: RewindMatch;
|
|
144
172
|
readonly rowKey: string;
|
|
145
173
|
} | null;
|
|
174
|
+
/**
|
|
175
|
+
* Read one rewind response body without ever leaking a raw parse error.
|
|
176
|
+
*
|
|
177
|
+
* A missing route, a restarted Host, or a proxy answering before the plugin
|
|
178
|
+
* loads all produce a body that is not the plugin's JSON envelope; those must
|
|
179
|
+
* surface as an explained failure instead of `Failed to execute 'json' …`.
|
|
180
|
+
* @param response - fetch response from the rewind endpoint.
|
|
181
|
+
* @returns the decoded JSON body.
|
|
182
|
+
*/
|
|
183
|
+
export declare function responseJson(response: Response): Promise<unknown>;
|
|
146
184
|
/** Describe the user-visible result of restoring one changed file. */
|
|
147
185
|
export declare function fileRecoveryLabel(kind: ChangeKind): string;
|
|
186
|
+
/**
|
|
187
|
+
* Explain one recorded checkpoint failure in user terms.
|
|
188
|
+
*
|
|
189
|
+
* The Host records the raw `[CODE] diagnostic` line; a non-Git project
|
|
190
|
+
* directory is the common case and deserves a plain sentence instead of a
|
|
191
|
+
* `git rev-parse` transcript.
|
|
192
|
+
* @param message - recorded checkpoint failure message.
|
|
193
|
+
* @returns one user-facing sentence.
|
|
194
|
+
*/
|
|
195
|
+
export declare function explainCheckpointFailure(message: string): string;
|
|
148
196
|
export {};
|
|
@@ -13,13 +13,19 @@ interface SessionHeaderLike {
|
|
|
13
13
|
interface SessionLike {
|
|
14
14
|
readonly id: string;
|
|
15
15
|
readonly header: SessionHeaderLike;
|
|
16
|
-
readonly events
|
|
16
|
+
readonly events?: readonly SessionEventLike[];
|
|
17
|
+
snapshotEvents?(fromSeq?: number, toSeqExclusive?: number): readonly SessionEventLike[];
|
|
17
18
|
}
|
|
18
19
|
interface AgentLike {
|
|
19
20
|
readonly id: string;
|
|
20
21
|
readonly status: 'idle' | 'running';
|
|
21
22
|
readonly session: SessionLike;
|
|
22
23
|
}
|
|
24
|
+
interface ToolExecutionLike {
|
|
25
|
+
readonly agent?: AgentLike;
|
|
26
|
+
readonly parent?: symbol;
|
|
27
|
+
readonly signal: AbortSignal;
|
|
28
|
+
}
|
|
23
29
|
interface AgentsLike {
|
|
24
30
|
list(): AgentLike[];
|
|
25
31
|
}
|
|
@@ -91,6 +97,31 @@ interface ApiProxyLike {
|
|
|
91
97
|
}>;
|
|
92
98
|
};
|
|
93
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Conversation create/fork capability of the Session controller service.
|
|
102
|
+
*
|
|
103
|
+
* DSH 0.1.2-alpha replaced the `apiProxy` RPC envelope with this plain service,
|
|
104
|
+
* so carriers without `apiProxy` (DSH Desktop 2.x) reach the same capability here.
|
|
105
|
+
*/
|
|
106
|
+
interface SessionControllerLike {
|
|
107
|
+
create(request: {
|
|
108
|
+
readonly cwd?: string;
|
|
109
|
+
readonly workspaceId?: string;
|
|
110
|
+
}): Promise<{
|
|
111
|
+
readonly sessionId: string;
|
|
112
|
+
}>;
|
|
113
|
+
fork(request: {
|
|
114
|
+
readonly sessionId: string;
|
|
115
|
+
readonly atSeq?: number;
|
|
116
|
+
}): Promise<{
|
|
117
|
+
readonly sessionId: string;
|
|
118
|
+
}>;
|
|
119
|
+
}
|
|
120
|
+
/** Services a conversation restart can be built on, in either carrier shape. */
|
|
121
|
+
type ConversationRestartContext = Pick<Context, 'sessions' | 'sessionQuery'> & {
|
|
122
|
+
readonly apiProxy?: ApiProxyLike;
|
|
123
|
+
readonly sessionController?: SessionControllerLike;
|
|
124
|
+
};
|
|
94
125
|
declare module '@deepseek-ai/cordis' {
|
|
95
126
|
interface Context {
|
|
96
127
|
agents: AgentsLike;
|
|
@@ -98,6 +129,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
98
129
|
sessionQuery: SessionQueryLike;
|
|
99
130
|
webServer: HttpServerLike;
|
|
100
131
|
apiProxy: ApiProxyLike;
|
|
132
|
+
sessionController: SessionControllerLike;
|
|
101
133
|
}
|
|
102
134
|
interface Events {
|
|
103
135
|
'agent/pre-step'(payload: {
|
|
@@ -106,10 +138,11 @@ declare module '@deepseek-ai/cordis' {
|
|
|
106
138
|
readonly step: number;
|
|
107
139
|
readonly signal: AbortSignal;
|
|
108
140
|
}, next: () => Promise<unknown>): Promise<unknown>;
|
|
141
|
+
'tools/execute'(exec: ToolExecutionLike, next: () => Promise<unknown>): Promise<unknown>;
|
|
109
142
|
}
|
|
110
143
|
}
|
|
111
144
|
export declare const REWIND_HTTP_PATH = "/turn-rewind";
|
|
112
|
-
/** Capture each turn
|
|
145
|
+
/** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
|
|
113
146
|
export declare class TurnCheckpointCoordinator {
|
|
114
147
|
private readonly engine;
|
|
115
148
|
private readonly captures;
|
|
@@ -118,7 +151,7 @@ export declare class TurnCheckpointCoordinator {
|
|
|
118
151
|
private readonly skips;
|
|
119
152
|
private readonly workspaceTails;
|
|
120
153
|
constructor(engine: ChangeLedgerEngine);
|
|
121
|
-
/**
|
|
154
|
+
/** Keep sidecar checkpoint work out of the Agent response waterfall. */
|
|
122
155
|
install(ctx: Context): void;
|
|
123
156
|
/** Current capture state for a session turn when no durable checkpoint exists yet. */
|
|
124
157
|
state(sessionId: string, turn: number): {
|
|
@@ -126,19 +159,22 @@ export declare class TurnCheckpointCoordinator {
|
|
|
126
159
|
readonly error?: string;
|
|
127
160
|
readonly reason?: string;
|
|
128
161
|
};
|
|
162
|
+
private startCapture;
|
|
129
163
|
private capture;
|
|
164
|
+
/** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
|
|
165
|
+
private waitForOpenTurn;
|
|
130
166
|
private serializeWorkspace;
|
|
131
167
|
private recordFailure;
|
|
132
168
|
}
|
|
133
169
|
/** Register the same-origin preview/apply endpoint consumed by the browser half. */
|
|
134
170
|
export declare function installRewindHttp(ctx: Context, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): void;
|
|
171
|
+
/** Build the exact-route handler as a testable unit. */
|
|
172
|
+
export declare function createRewindHttpHandler(ctx: ConversationRestartContext & {
|
|
173
|
+
readonly agents?: AgentsLike;
|
|
174
|
+
}, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
|
|
135
175
|
export declare const MANAGE_HTTP_PATH = "/turn-rewind/manage";
|
|
136
176
|
/** Register the same-origin storage-management endpoint consumed by the settings card. */
|
|
137
177
|
export declare function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void;
|
|
138
178
|
/** Build the storage-management route as a testable unit. */
|
|
139
179
|
export declare function createManageHttpHandler(engine: ChangeLedgerEngine): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
|
|
140
|
-
/** Build the exact-route handler as a testable unit. */
|
|
141
|
-
export declare function createRewindHttpHandler(ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & {
|
|
142
|
-
readonly agents?: AgentsLike;
|
|
143
|
-
}, engine: ChangeLedgerEngine, coordinator: TurnCheckpointCoordinator): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void>;
|
|
144
180
|
export {};
|