@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/src/rewind-host.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import { createDeadline } from './deadline.js'
|
|
3
4
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
4
5
|
import type { ChangeLedgerEngine } from './engine.js'
|
|
5
6
|
import { discoverRepositoryRoot } from './git.js'
|
|
7
|
+
import type { WorkspacePurgeReport } from './types.js'
|
|
6
8
|
|
|
7
9
|
interface SessionEventLike {
|
|
8
10
|
readonly type: string
|
|
@@ -114,6 +116,7 @@ export class TurnCheckpointCoordinator {
|
|
|
114
116
|
private readonly captures = new Map<string, Promise<void>>()
|
|
115
117
|
private readonly pending = new Set<string>()
|
|
116
118
|
private readonly failures = new Map<string, string>()
|
|
119
|
+
private readonly skips = new Map<string, string>()
|
|
117
120
|
private readonly workspaceTails = new Map<string, Promise<void>>()
|
|
118
121
|
|
|
119
122
|
constructor(private readonly engine: ChangeLedgerEngine) {}
|
|
@@ -127,9 +130,11 @@ export class TurnCheckpointCoordinator {
|
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
/** Current capture state for a session turn when no durable checkpoint exists yet. */
|
|
130
|
-
state(sessionId: string, turn: number): { readonly status: 'pending' | 'failed' | 'missing'; readonly error?: string } {
|
|
133
|
+
state(sessionId: string, turn: number): { readonly status: 'pending' | 'failed' | 'skipped' | 'missing'; readonly error?: string; readonly reason?: string } {
|
|
131
134
|
const key = checkpointKey(sessionId, turn)
|
|
132
135
|
if (this.pending.has(key)) return { status: 'pending' }
|
|
136
|
+
const reason = this.skips.get(key)
|
|
137
|
+
if (reason !== undefined) return { status: 'skipped', reason }
|
|
133
138
|
const error = this.failures.get(key)
|
|
134
139
|
return error === undefined ? { status: 'missing' } : { status: 'failed', error }
|
|
135
140
|
}
|
|
@@ -141,48 +146,88 @@ export class TurnCheckpointCoordinator {
|
|
|
141
146
|
signal: AbortSignal,
|
|
142
147
|
): Promise<void> {
|
|
143
148
|
const key = checkpointKey(agent.id, turn)
|
|
144
|
-
const existing = this.captures.get(key)
|
|
145
|
-
if (existing !== undefined) {
|
|
146
|
-
await existing
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
149
|
const cwd = agent.session.header.cwd
|
|
150
150
|
if (cwd === undefined) return
|
|
151
151
|
const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn)
|
|
152
152
|
if (start === undefined) {
|
|
153
|
-
this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
|
|
153
|
+
await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
|
|
154
154
|
return
|
|
155
155
|
}
|
|
156
|
-
this.
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
156
|
+
const timeoutMs = this.engine.config.turnCheckpointTimeoutMs
|
|
157
|
+
const outcomeDeadline = createDeadline(timeoutMs)
|
|
158
|
+
const outcomeSignal = AbortSignal.any([signal, outcomeDeadline.signal])
|
|
159
|
+
const reserveMs = Math.min(250, Math.max(10, Math.ceil(timeoutMs / 5)))
|
|
160
|
+
const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs))
|
|
161
|
+
const captureSignal = AbortSignal.any([signal, captureDeadline.signal])
|
|
162
|
+
try {
|
|
163
|
+
const existing = this.captures.get(key)
|
|
164
|
+
if (existing !== undefined) {
|
|
165
|
+
await waitWithSignal(existing, captureSignal).catch(() => undefined)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
this.pending.add(key)
|
|
169
|
+
this.failures.delete(key)
|
|
170
|
+
this.skips.delete(key)
|
|
171
|
+
const capture = (async () => {
|
|
172
|
+
try {
|
|
173
|
+
const workspace = await discoverRepositoryRoot(cwd, captureSignal)
|
|
174
|
+
await this.serializeWorkspace(workspace, captureSignal, async () => {
|
|
175
|
+
try {
|
|
176
|
+
await this.engine.createTurnCheckpoint({
|
|
177
|
+
cwd: workspace,
|
|
178
|
+
sessionId: agent.id,
|
|
179
|
+
turn,
|
|
180
|
+
turnStartSeq: start.seq,
|
|
181
|
+
signal: captureSignal,
|
|
182
|
+
})
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await this.recordFailure(
|
|
185
|
+
ctx,
|
|
186
|
+
agent.id,
|
|
187
|
+
turn,
|
|
188
|
+
checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal),
|
|
189
|
+
{ cwd: workspace, turnStartSeq: start.seq },
|
|
190
|
+
)
|
|
191
|
+
}
|
|
168
192
|
})
|
|
169
|
-
})
|
|
193
|
+
} catch (error) {
|
|
194
|
+
await this.recordFailure(
|
|
195
|
+
ctx,
|
|
196
|
+
agent.id,
|
|
197
|
+
turn,
|
|
198
|
+
checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal),
|
|
199
|
+
{ cwd, turnStartSeq: start.seq },
|
|
200
|
+
)
|
|
201
|
+
} finally {
|
|
202
|
+
this.pending.delete(key)
|
|
203
|
+
}
|
|
204
|
+
})()
|
|
205
|
+
this.captures.set(key, capture)
|
|
206
|
+
try {
|
|
207
|
+
await waitWithSignal(capture, outcomeSignal)
|
|
170
208
|
} catch (error) {
|
|
171
|
-
|
|
172
|
-
|
|
209
|
+
const bounded = checkpointTimeoutError(error, timeoutMs, outcomeDeadline.signal, signal)
|
|
210
|
+
const message = errorMessage(bounded)
|
|
173
211
|
this.pending.delete(key)
|
|
212
|
+
if (bounded instanceof ChangeLedgerError && isCheckpointSkip(bounded.code)) {
|
|
213
|
+
this.skips.set(key, message)
|
|
214
|
+
} else {
|
|
215
|
+
this.failures.set(key, message)
|
|
216
|
+
}
|
|
174
217
|
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
218
|
+
} finally {
|
|
219
|
+
captureDeadline.cancel()
|
|
220
|
+
outcomeDeadline.cancel()
|
|
221
|
+
}
|
|
178
222
|
}
|
|
179
223
|
|
|
180
224
|
private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
|
|
181
225
|
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
|
|
182
|
-
const current =
|
|
226
|
+
const current = (async () => {
|
|
227
|
+
await waitWithSignal(previous.catch(() => undefined), signal)
|
|
183
228
|
signal.throwIfAborted()
|
|
184
229
|
await task()
|
|
185
|
-
})
|
|
230
|
+
})()
|
|
186
231
|
this.workspaceTails.set(workspace, current)
|
|
187
232
|
try {
|
|
188
233
|
await current
|
|
@@ -191,18 +236,70 @@ export class TurnCheckpointCoordinator {
|
|
|
191
236
|
}
|
|
192
237
|
}
|
|
193
238
|
|
|
194
|
-
private recordFailure(
|
|
239
|
+
private async recordFailure(
|
|
195
240
|
ctx: Pick<Context, 'logger'>,
|
|
196
241
|
sessionId: string,
|
|
197
242
|
turn: number,
|
|
198
243
|
error: unknown,
|
|
199
|
-
|
|
244
|
+
skipContext?: { readonly cwd: string; readonly turnStartSeq: number },
|
|
245
|
+
): Promise<void> {
|
|
200
246
|
const message = errorMessage(error)
|
|
247
|
+
if (error instanceof ChangeLedgerError && isCheckpointSkip(error.code)) {
|
|
248
|
+
this.skips.set(checkpointKey(sessionId, turn), message)
|
|
249
|
+
if (skipContext !== undefined) {
|
|
250
|
+
try {
|
|
251
|
+
await this.engine.recordTurnCheckpointSkip({
|
|
252
|
+
cwd: skipContext.cwd,
|
|
253
|
+
sessionId,
|
|
254
|
+
turn,
|
|
255
|
+
turnStartSeq: skipContext.turnStartSeq,
|
|
256
|
+
reason: message,
|
|
257
|
+
})
|
|
258
|
+
} catch (persistError) {
|
|
259
|
+
ctx.logger.warn(`[turn-rewind] could not persist checkpoint skip for ${sessionId} turn ${String(turn)}: ${errorMessage(persistError)}`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
ctx.logger.warn(`[turn-rewind] checkpoint skipped for ${sessionId} turn ${String(turn)}: ${message}`)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
201
265
|
this.failures.set(checkpointKey(sessionId, turn), message)
|
|
202
266
|
ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`)
|
|
203
267
|
}
|
|
204
268
|
}
|
|
205
269
|
|
|
270
|
+
function isCheckpointSkip(code: string): boolean {
|
|
271
|
+
return code === 'TURN_CHECKPOINT_DISABLED'
|
|
272
|
+
|| code === 'TURN_CHECKPOINT_TIMEOUT'
|
|
273
|
+
|| code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT'
|
|
274
|
+
|| code === 'GIT_OBJECT_STORE_UNSUPPORTED'
|
|
275
|
+
|| code === 'GIT_INDEX_UNSUPPORTED'
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
279
|
+
signal.throwIfAborted()
|
|
280
|
+
let rejectAbort: ((reason?: unknown) => void) | undefined
|
|
281
|
+
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
|
282
|
+
const onAbort = () => rejectAbort?.(signal.reason)
|
|
283
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
284
|
+
try {
|
|
285
|
+
return await Promise.race([promise, aborted])
|
|
286
|
+
} finally {
|
|
287
|
+
signal.removeEventListener('abort', onAbort)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function checkpointTimeoutError(
|
|
292
|
+
error: unknown,
|
|
293
|
+
timeoutMs: number,
|
|
294
|
+
deadline: AbortSignal,
|
|
295
|
+
callerSignal: AbortSignal,
|
|
296
|
+
): unknown {
|
|
297
|
+
if (!callerSignal.aborted && deadline.aborted) {
|
|
298
|
+
return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error })
|
|
299
|
+
}
|
|
300
|
+
return error
|
|
301
|
+
}
|
|
302
|
+
|
|
206
303
|
/** Register the same-origin preview/apply endpoint consumed by the browser half. */
|
|
207
304
|
export function installRewindHttp(
|
|
208
305
|
ctx: Context,
|
|
@@ -216,6 +313,101 @@ export function installRewindHttp(
|
|
|
216
313
|
}), 'change-ledger.rewindHttp')
|
|
217
314
|
}
|
|
218
315
|
|
|
316
|
+
export const MANAGE_HTTP_PATH = '/turn-rewind/manage'
|
|
317
|
+
|
|
318
|
+
/** Register the same-origin storage-management endpoint consumed by the settings card. */
|
|
319
|
+
export function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void {
|
|
320
|
+
ctx.effect(() => ctx.webServer.register({
|
|
321
|
+
kind: 'exact',
|
|
322
|
+
path: MANAGE_HTTP_PATH,
|
|
323
|
+
handler: createManageHttpHandler(engine),
|
|
324
|
+
}), 'change-ledger.manageHttp')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Build the storage-management route as a testable unit. */
|
|
328
|
+
export function createManageHttpHandler(
|
|
329
|
+
engine: ChangeLedgerEngine,
|
|
330
|
+
): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
|
|
331
|
+
return async (request, response) => {
|
|
332
|
+
try {
|
|
333
|
+
if (request.method === 'GET') {
|
|
334
|
+
const workspaces = await engine.listWorkspaces()
|
|
335
|
+
json(response, 200, {
|
|
336
|
+
storageDir: engine.config.storageDir,
|
|
337
|
+
totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
|
|
338
|
+
workspaces: workspaces.map((workspace) => ({
|
|
339
|
+
workspace: workspace.workspace,
|
|
340
|
+
totalBytes: workspace.totalBytes,
|
|
341
|
+
recoveryCount: workspace.recoveryCount,
|
|
342
|
+
restorePoints: workspace.restorePoints.map((point) => ({
|
|
343
|
+
id: point.id,
|
|
344
|
+
kind: point.kind,
|
|
345
|
+
format: point.format,
|
|
346
|
+
createdAt: point.createdAt,
|
|
347
|
+
totalBytes: point.totalBytes,
|
|
348
|
+
fileCount: point.fileCount,
|
|
349
|
+
...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
|
|
350
|
+
...(point.turn === undefined ? {} : { turn: point.turn }),
|
|
351
|
+
...(point.label === undefined ? {} : { label: point.label }),
|
|
352
|
+
})),
|
|
353
|
+
})),
|
|
354
|
+
})
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (request.method === 'POST') {
|
|
358
|
+
const body = objectBody(await readBody(request))
|
|
359
|
+
const action = requiredText(body.action, 'action')
|
|
360
|
+
if (action === 'delete') {
|
|
361
|
+
const workspace = requiredText(body.workspace, 'workspace')
|
|
362
|
+
const restorePointId = requiredText(body.restorePointId, 'restorePointId')
|
|
363
|
+
const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] })
|
|
364
|
+
if (report.deletedRestorePoints === 0) {
|
|
365
|
+
throw new ChangeLedgerError(
|
|
366
|
+
report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND',
|
|
367
|
+
report.retainedRestorePoints > 0
|
|
368
|
+
? 'restore point is required by an incomplete recovery journal'
|
|
369
|
+
: `restore point ${restorePointId} does not exist`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
json(response, 200, { status: 'completed', action, ...report })
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
if (action === 'clear-workspace') {
|
|
376
|
+
const workspace = requiredText(body.workspace, 'workspace')
|
|
377
|
+
json(response, 200, { status: 'completed', action, ...await engine.purgeWorkspace({ workspace }) })
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
if (action === 'clear-all') {
|
|
381
|
+
const reports: WorkspacePurgeReport[] = []
|
|
382
|
+
const failures: { readonly workspace: string; readonly error: string }[] = []
|
|
383
|
+
for (const overview of await engine.listWorkspaces()) {
|
|
384
|
+
try {
|
|
385
|
+
reports.push(await engine.purgeWorkspace({ workspace: overview.workspace }))
|
|
386
|
+
} catch (error) {
|
|
387
|
+
failures.push({ workspace: overview.workspace, error: errorMessage(error) })
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
json(response, 200, {
|
|
391
|
+
status: failures.length > 0 ? 'partial' : 'completed',
|
|
392
|
+
action,
|
|
393
|
+
reports,
|
|
394
|
+
...(failures.length > 0 ? { failures } : {}),
|
|
395
|
+
})
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'action must be "delete", "clear-workspace", or "clear-all"')
|
|
399
|
+
}
|
|
400
|
+
json(response, 405, { error: 'method not allowed' })
|
|
401
|
+
} catch (error) {
|
|
402
|
+
const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
|
|
403
|
+
json(response, status, {
|
|
404
|
+
error: errorMessage(error),
|
|
405
|
+
code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED',
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
219
411
|
/** Build the exact-route handler as a testable unit. */
|
|
220
412
|
export function createRewindHttpHandler(
|
|
221
413
|
ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & { readonly agents?: AgentsLike },
|
|
@@ -231,9 +423,26 @@ export function createRewindHttpHandler(
|
|
|
231
423
|
const detailsOnly = url.searchParams.get('details') === '1'
|
|
232
424
|
const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset')
|
|
233
425
|
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
|
|
234
|
-
const {
|
|
426
|
+
const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
|
|
427
|
+
let checkpoint: MessageCheckpoint | undefined
|
|
428
|
+
try {
|
|
429
|
+
checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target)
|
|
430
|
+
} catch (error) {
|
|
431
|
+
// File-checkpoint discovery is allowed to fail independently: the dialog
|
|
432
|
+
// must still expose message-only rewind for a valid Session boundary.
|
|
433
|
+
json(response, 200, { status: 'failed', error: errorMessage(error) })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
235
436
|
if (checkpoint === undefined) {
|
|
236
|
-
|
|
437
|
+
const durableSkip = await engine.findTurnCheckpointSkip({
|
|
438
|
+
cwd: target.cwd,
|
|
439
|
+
sessionId,
|
|
440
|
+
turn: target.turn,
|
|
441
|
+
turnStartSeq: target.turnStartSeq,
|
|
442
|
+
})
|
|
443
|
+
json(response, 200, durableSkip === undefined
|
|
444
|
+
? coordinator.state(sessionId, target.turn)
|
|
445
|
+
: { status: 'skipped', reason: durableSkip.reason })
|
|
237
446
|
return
|
|
238
447
|
}
|
|
239
448
|
const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id })
|
|
@@ -279,11 +488,20 @@ export function createRewindHttpHandler(
|
|
|
279
488
|
if (request.method === 'POST') {
|
|
280
489
|
const body = objectBody(await readBody(request))
|
|
281
490
|
const mode = body.mode
|
|
282
|
-
if (mode !== 'code' && mode !== 'both') {
|
|
283
|
-
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "
|
|
491
|
+
if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
|
|
492
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"')
|
|
284
493
|
}
|
|
285
494
|
const sessionId = requiredText(body.sessionId, 'sessionId')
|
|
286
495
|
const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq')
|
|
496
|
+
if (mode === 'messages') {
|
|
497
|
+
// Message-only rewind never mutates project files, so no checkpoint, no
|
|
498
|
+
// restore plan, and no shared-workspace gate apply; the conversation
|
|
499
|
+
// restart revalidates the message boundary itself.
|
|
500
|
+
const { target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
|
|
501
|
+
const fork = await createConversationRestart(ctx, sessionId, target)
|
|
502
|
+
json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId })
|
|
503
|
+
return
|
|
504
|
+
}
|
|
287
505
|
const checkpointId = requiredText(body.checkpointId, 'checkpointId')
|
|
288
506
|
const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
|
|
289
507
|
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
|
|
@@ -361,20 +579,29 @@ interface MessageCheckpoint extends MessageTarget {
|
|
|
361
579
|
readonly id: string
|
|
362
580
|
}
|
|
363
581
|
|
|
582
|
+
async function resolveMessageTarget(
|
|
583
|
+
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
584
|
+
sessionId: string,
|
|
585
|
+
messageSeq: number,
|
|
586
|
+
): Promise<{ readonly session: SessionLike; readonly target: MessageTarget }> {
|
|
587
|
+
const session = await readSession(ctx, sessionId)
|
|
588
|
+
return { session, target: messageTarget(session, messageSeq) }
|
|
589
|
+
}
|
|
590
|
+
|
|
364
591
|
async function resolveMessageCheckpoint(
|
|
365
592
|
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
366
593
|
engine: ChangeLedgerEngine,
|
|
367
594
|
sessionId: string,
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
595
|
+
source: SessionLike,
|
|
596
|
+
target: MessageTarget,
|
|
597
|
+
): Promise<MessageCheckpoint | undefined> {
|
|
598
|
+
let current = source
|
|
372
599
|
const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn })
|
|
373
600
|
if (direct !== undefined) {
|
|
374
601
|
if (direct.turnStartSeq !== target.turnStartSeq) {
|
|
375
602
|
throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start')
|
|
376
603
|
}
|
|
377
|
-
return {
|
|
604
|
+
return { ...target, id: direct.id }
|
|
378
605
|
}
|
|
379
606
|
|
|
380
607
|
const seen = new Set<string>([sessionId])
|
|
@@ -386,7 +613,7 @@ async function resolveMessageCheckpoint(
|
|
|
386
613
|
}
|
|
387
614
|
if (parentId === undefined || seedLength === undefined
|
|
388
615
|
|| target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
|
|
389
|
-
return
|
|
616
|
+
return undefined
|
|
390
617
|
}
|
|
391
618
|
if (seen.has(parentId)) {
|
|
392
619
|
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle')
|
|
@@ -397,7 +624,7 @@ async function resolveMessageCheckpoint(
|
|
|
397
624
|
} catch (error) {
|
|
398
625
|
throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error })
|
|
399
626
|
}
|
|
400
|
-
const parentTarget = messageTarget(current, messageSeq)
|
|
627
|
+
const parentTarget = messageTarget(current, target.messageSeq)
|
|
401
628
|
if (parentTarget.turn !== target.turn
|
|
402
629
|
|| parentTarget.turnStartSeq !== target.turnStartSeq
|
|
403
630
|
|| parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
@@ -408,7 +635,7 @@ async function resolveMessageCheckpoint(
|
|
|
408
635
|
if (inherited.turnStartSeq !== target.turnStartSeq) {
|
|
409
636
|
throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary')
|
|
410
637
|
}
|
|
411
|
-
return {
|
|
638
|
+
return { ...target, id: inherited.id }
|
|
412
639
|
}
|
|
413
640
|
}
|
|
414
641
|
|
|
@@ -419,7 +646,8 @@ async function checkpointForRequest(
|
|
|
419
646
|
messageSeq: number,
|
|
420
647
|
requestedId: string,
|
|
421
648
|
): Promise<MessageCheckpoint> {
|
|
422
|
-
const {
|
|
649
|
+
const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
|
|
650
|
+
const checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target)
|
|
423
651
|
if (checkpoint === undefined) {
|
|
424
652
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`)
|
|
425
653
|
}
|
|
@@ -432,23 +660,23 @@ async function checkpointForRequest(
|
|
|
432
660
|
async function createConversationRestart(
|
|
433
661
|
ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'>,
|
|
434
662
|
sourceId: string,
|
|
435
|
-
|
|
663
|
+
target: MessageTarget,
|
|
436
664
|
): Promise<{ readonly sessionId: string }> {
|
|
437
665
|
const source = await readSession(ctx, sourceId)
|
|
438
|
-
const current = messageTarget(source,
|
|
439
|
-
if (current.turn !==
|
|
440
|
-
|| current.turnStartSeq !==
|
|
441
|
-
|| current.previousTurnEndSeq !==
|
|
666
|
+
const current = messageTarget(source, target.messageSeq)
|
|
667
|
+
if (current.turn !== target.turn
|
|
668
|
+
|| current.turnStartSeq !== target.turnStartSeq
|
|
669
|
+
|| current.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
442
670
|
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
|
|
443
671
|
}
|
|
444
|
-
const response =
|
|
672
|
+
const response = target.previousTurnEndSeq === undefined
|
|
445
673
|
? await ctx.apiProxy.sessions.create({
|
|
446
674
|
rpcId: randomUUID(),
|
|
447
|
-
payload: { cwd:
|
|
675
|
+
payload: { cwd: target.cwd },
|
|
448
676
|
})
|
|
449
677
|
: await ctx.apiProxy.sessions.fork({
|
|
450
678
|
rpcId: randomUUID(),
|
|
451
|
-
payload: { sessionId: sourceId, atSeq:
|
|
679
|
+
payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
|
|
452
680
|
})
|
|
453
681
|
if (!response.result.ok) {
|
|
454
682
|
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message)
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime settings surface for Turn Rewind on the DSH web settings page.
|
|
3
|
+
* @module @anionex/dsh-turn-rewind
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
7
|
+
import z from '@deepseek-ai/schemastery'
|
|
8
|
+
import { resolveConfig, type ChangeLedgerEngine } from './engine.js'
|
|
9
|
+
import type { ChangeLedgerConfig, ResolvedChangeLedgerConfig } from './types.js'
|
|
10
|
+
|
|
11
|
+
/** Namespace join key shared by the host section and the browser settings card. */
|
|
12
|
+
export const TURN_REWIND_SETTINGS_NAMESPACE = settingsNamespace('turn-rewind')
|
|
13
|
+
|
|
14
|
+
/** Every runtime-tunable field of {@link ChangeLedgerConfig}; `storageDir` stays config-layer only. */
|
|
15
|
+
export interface TurnRewindSettings {
|
|
16
|
+
/** Maximum user and rescue restore points retained per workspace. */
|
|
17
|
+
maxRestorePoints: number
|
|
18
|
+
/** Maximum automatic turn checkpoints retained per session. */
|
|
19
|
+
maxTurnCheckpointsPerSession: number
|
|
20
|
+
/** Maximum number of files in one restore point. */
|
|
21
|
+
maxFiles: number
|
|
22
|
+
/** Maximum bytes read from one regular file. */
|
|
23
|
+
maxFileBytes: number
|
|
24
|
+
/** Maximum aggregate regular-file bytes in one restore point. */
|
|
25
|
+
maxSnapshotBytes: number
|
|
26
|
+
/** Restore-plan lifetime in milliseconds. */
|
|
27
|
+
planTtlMs: number
|
|
28
|
+
/** Age after which a lock whose owner is gone may be reclaimed. */
|
|
29
|
+
staleLockMs: number
|
|
30
|
+
/** Automatic turn-checkpoint implementation; `off` records durable skips instead. */
|
|
31
|
+
turnCheckpointMode: 'off' | 'git-native' | 'legacy'
|
|
32
|
+
/** Maximum time one automatic checkpoint may block the first Agent step. */
|
|
33
|
+
turnCheckpointTimeoutMs: number
|
|
34
|
+
/** Maximum uncached worktree bytes read by one automatic Git-native checkpoint. */
|
|
35
|
+
turnCheckpointMaxNewBytes: number
|
|
36
|
+
/** Fast trusts fenced Git/stat metadata; strict rereads every eligible path. */
|
|
37
|
+
turnCheckpointTrust: 'fast' | 'strict'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Schemastery schema for the `turn-rewind` settings namespace. */
|
|
41
|
+
export const TurnRewindSettingsSchema: z<TurnRewindSettings> = (() => {
|
|
42
|
+
const defaults = tunableSettings(resolveConfig({}))
|
|
43
|
+
return z.object({
|
|
44
|
+
maxRestorePoints: z.number().step(1).min(1).default(defaults.maxRestorePoints),
|
|
45
|
+
maxTurnCheckpointsPerSession: z.number().step(1).min(1).default(defaults.maxTurnCheckpointsPerSession),
|
|
46
|
+
maxFiles: z.number().step(1).min(1).default(defaults.maxFiles),
|
|
47
|
+
maxFileBytes: z.number().step(1).min(1).default(defaults.maxFileBytes),
|
|
48
|
+
maxSnapshotBytes: z.number().step(1).min(1).default(defaults.maxSnapshotBytes),
|
|
49
|
+
planTtlMs: z.number().step(1).min(1).default(defaults.planTtlMs),
|
|
50
|
+
staleLockMs: z.number().step(1).min(1).default(defaults.staleLockMs),
|
|
51
|
+
turnCheckpointMode: z.union(['off', 'git-native', 'legacy']).default(defaults.turnCheckpointMode),
|
|
52
|
+
turnCheckpointTimeoutMs: z.number().step(1).min(1).default(defaults.turnCheckpointTimeoutMs),
|
|
53
|
+
turnCheckpointMaxNewBytes: z.number().step(1).min(1).default(defaults.turnCheckpointMaxNewBytes),
|
|
54
|
+
turnCheckpointTrust: z.union(['fast', 'strict']).default(defaults.turnCheckpointTrust),
|
|
55
|
+
})
|
|
56
|
+
})()
|
|
57
|
+
|
|
58
|
+
/** Project one resolved configuration onto the settings namespace subset. */
|
|
59
|
+
function tunableSettings(resolved: ResolvedChangeLedgerConfig): TurnRewindSettings {
|
|
60
|
+
return {
|
|
61
|
+
maxRestorePoints: resolved.maxRestorePoints,
|
|
62
|
+
maxTurnCheckpointsPerSession: resolved.maxTurnCheckpointsPerSession,
|
|
63
|
+
maxFiles: resolved.maxFiles,
|
|
64
|
+
maxFileBytes: resolved.maxFileBytes,
|
|
65
|
+
maxSnapshotBytes: resolved.maxSnapshotBytes,
|
|
66
|
+
planTtlMs: resolved.planTtlMs,
|
|
67
|
+
staleLockMs: resolved.staleLockMs,
|
|
68
|
+
turnCheckpointMode: resolved.turnCheckpointMode,
|
|
69
|
+
turnCheckpointTimeoutMs: resolved.turnCheckpointTimeoutMs,
|
|
70
|
+
turnCheckpointMaxNewBytes: resolved.turnCheckpointMaxNewBytes,
|
|
71
|
+
turnCheckpointTrust: resolved.turnCheckpointTrust,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register the `turn-rewind` settings namespace and apply its resolved value to the
|
|
77
|
+
* running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
|
|
78
|
+
* the user layer persists through the DSH settings provider. `storageDir` is never
|
|
79
|
+
* carried by the namespace: the storage root must not move while the engine runs.
|
|
80
|
+
*/
|
|
81
|
+
export function installTurnRewindSettings(ctx: Context, config: ChangeLedgerConfig, engine: ChangeLedgerEngine): void {
|
|
82
|
+
let source: () => TurnRewindSettings = () => tunableSettings(resolveConfig(config))
|
|
83
|
+
installSettingsSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, source(), {
|
|
84
|
+
setSource: (current) => { source = current },
|
|
85
|
+
onChange: () => {
|
|
86
|
+
try {
|
|
87
|
+
engine.updateConfig({ ...config, ...source() })
|
|
88
|
+
} catch (error) {
|
|
89
|
+
ctx.logger.warn(`[turn-rewind] could not apply settings update: ${error instanceof Error ? error.message : String(error)}`)
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
}
|