@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/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +289 -22
- package/lib/client.js.map +1 -1
- package/lib/engine.js +140 -3
- package/lib/index.js +17 -1
- package/lib/rewind-host.js +131 -19
- package/lib/settings.js +58 -0
- package/lib/store.js +90 -23
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/engine.d.ts +21 -2
- package/lib/types/index.d.ts +7 -0
- package/lib/types/rewind-host.d.ts +5 -0
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/store.d.ts +20 -0
- package/lib/types/types.d.ts +19 -0
- package/package.json +19 -3
- package/src/client/index.tsx +534 -42
- package/src/engine.ts +154 -3
- package/src/index.ts +20 -1
- package/src/rewind-host.ts +144 -20
- package/src/settings.ts +93 -0
- package/src/store.ts +109 -23
- package/src/types.ts +21 -0
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
|
-
|
|
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.
|
|
36
|
-
this.store = new LedgerStore(this.
|
|
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;
|
package/lib/rewind-host.js
CHANGED
|
@@ -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 {
|
|
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 "
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
468
|
+
return { ...target, id: inherited.id };
|
|
358
469
|
}
|
|
359
470
|
}
|
|
360
471
|
async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requestedId) {
|
|
361
|
-
const {
|
|
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,
|
|
482
|
+
async function createConversationRestart(ctx, sourceId, target) {
|
|
371
483
|
const source = await readSession(ctx, sourceId);
|
|
372
|
-
const current = messageTarget(source,
|
|
373
|
-
if (current.turn !==
|
|
374
|
-
|| current.turnStartSeq !==
|
|
375
|
-
|| 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) {
|
|
376
488
|
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary');
|
|
377
489
|
}
|
|
378
|
-
const response =
|
|
490
|
+
const response = target.previousTurnEndSeq === undefined
|
|
379
491
|
? await ctx.apiProxy.sessions.create({
|
|
380
492
|
rpcId: randomUUID(),
|
|
381
|
-
payload: { cwd:
|
|
493
|
+
payload: { cwd: target.cwd },
|
|
382
494
|
})
|
|
383
495
|
: await ctx.apiProxy.sessions.fork({
|
|
384
496
|
rpcId: randomUUID(),
|
|
385
|
-
payload: { sessionId: sourceId, atSeq:
|
|
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);
|
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
|
+
}
|