@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/src/rewind-host.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { createDeadline } from './deadline.js'
|
|
|
4
4
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
5
5
|
import type { ChangeLedgerEngine } from './engine.js'
|
|
6
6
|
import { discoverRepositoryRoot } from './git.js'
|
|
7
|
-
import type { WorkspacePurgeReport } from './types.js'
|
|
8
7
|
|
|
9
8
|
interface SessionEventLike {
|
|
10
9
|
readonly type: string
|
|
@@ -21,7 +20,8 @@ interface SessionHeaderLike {
|
|
|
21
20
|
interface SessionLike {
|
|
22
21
|
readonly id: string
|
|
23
22
|
readonly header: SessionHeaderLike
|
|
24
|
-
readonly events
|
|
23
|
+
readonly events?: readonly SessionEventLike[]
|
|
24
|
+
snapshotEvents?(fromSeq?: number, toSeqExclusive?: number): readonly SessionEventLike[]
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
interface AgentLike {
|
|
@@ -30,6 +30,12 @@ interface AgentLike {
|
|
|
30
30
|
readonly session: SessionLike
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
interface ToolExecutionLike {
|
|
34
|
+
readonly agent?: AgentLike
|
|
35
|
+
readonly parent?: symbol
|
|
36
|
+
readonly signal: AbortSignal
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
interface AgentsLike {
|
|
34
40
|
list(): AgentLike[]
|
|
35
41
|
}
|
|
@@ -84,6 +90,31 @@ interface ApiProxyLike {
|
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Conversation create/fork capability of the Session controller service.
|
|
95
|
+
*
|
|
96
|
+
* DSH 0.1.2-alpha replaced the `apiProxy` RPC envelope with this plain service,
|
|
97
|
+
* so carriers without `apiProxy` (DSH Desktop 2.x) reach the same capability here.
|
|
98
|
+
*/
|
|
99
|
+
interface SessionControllerLike {
|
|
100
|
+
create(request: { readonly cwd?: string; readonly workspaceId?: string }): Promise<{ readonly sessionId: string }>
|
|
101
|
+
fork(request: { readonly sessionId: string; readonly atSeq?: number }): Promise<{ readonly sessionId: string }>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Services a conversation restart can be built on, in either carrier shape. */
|
|
105
|
+
type ConversationRestartContext = Pick<Context, 'sessions' | 'sessionQuery'> & {
|
|
106
|
+
readonly apiProxy?: ApiProxyLike
|
|
107
|
+
readonly sessionController?: SessionControllerLike
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getSessionEvents(session: SessionLike | undefined): readonly SessionEventLike[] {
|
|
111
|
+
if (!session) return []
|
|
112
|
+
if (typeof session.snapshotEvents === 'function') {
|
|
113
|
+
return session.snapshotEvents()
|
|
114
|
+
}
|
|
115
|
+
return session.events ?? []
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
declare module '@deepseek-ai/cordis' {
|
|
88
119
|
interface Context {
|
|
89
120
|
agents: AgentsLike
|
|
@@ -91,6 +122,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
91
122
|
sessionQuery: SessionQueryLike
|
|
92
123
|
webServer: HttpServerLike
|
|
93
124
|
apiProxy: ApiProxyLike
|
|
125
|
+
sessionController: SessionControllerLike
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
interface Events {
|
|
@@ -103,6 +135,10 @@ declare module '@deepseek-ai/cordis' {
|
|
|
103
135
|
},
|
|
104
136
|
next: () => Promise<unknown>,
|
|
105
137
|
): Promise<unknown>
|
|
138
|
+
'tools/execute'(
|
|
139
|
+
exec: ToolExecutionLike,
|
|
140
|
+
next: () => Promise<unknown>,
|
|
141
|
+
): Promise<unknown>
|
|
106
142
|
}
|
|
107
143
|
}
|
|
108
144
|
|
|
@@ -111,7 +147,7 @@ const BODY_LIMIT = 64 * 1024
|
|
|
111
147
|
const INITIAL_CHANGE_PREVIEW_LIMIT = 8
|
|
112
148
|
const MAX_CHANGE_PAGE_SIZE = 200
|
|
113
149
|
|
|
114
|
-
/** Capture each turn
|
|
150
|
+
/** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
|
|
115
151
|
export class TurnCheckpointCoordinator {
|
|
116
152
|
private readonly captures = new Map<string, Promise<void>>()
|
|
117
153
|
private readonly pending = new Set<string>()
|
|
@@ -121,10 +157,17 @@ export class TurnCheckpointCoordinator {
|
|
|
121
157
|
|
|
122
158
|
constructor(private readonly engine: ChangeLedgerEngine) {}
|
|
123
159
|
|
|
124
|
-
/**
|
|
160
|
+
/** Keep sidecar checkpoint work out of the Agent response waterfall. */
|
|
125
161
|
install(ctx: Context): void {
|
|
126
162
|
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
|
|
127
|
-
if (step === 1)
|
|
163
|
+
if (step === 1) this.startCapture(ctx, agent, turn, signal)
|
|
164
|
+
return next()
|
|
165
|
+
}, { prepend: true })
|
|
166
|
+
|
|
167
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
168
|
+
if (exec.agent !== undefined && exec.parent === undefined) {
|
|
169
|
+
await this.waitForOpenTurn(exec.agent, exec.signal)
|
|
170
|
+
}
|
|
128
171
|
return next()
|
|
129
172
|
}, { prepend: true })
|
|
130
173
|
}
|
|
@@ -139,6 +182,20 @@ export class TurnCheckpointCoordinator {
|
|
|
139
182
|
return error === undefined ? { status: 'missing' } : { status: 'failed', error }
|
|
140
183
|
}
|
|
141
184
|
|
|
185
|
+
private startCapture(
|
|
186
|
+
ctx: Pick<Context, 'logger'>,
|
|
187
|
+
agent: AgentLike,
|
|
188
|
+
turn: number,
|
|
189
|
+
signal: AbortSignal,
|
|
190
|
+
): void {
|
|
191
|
+
const key = checkpointKey(agent.id, turn)
|
|
192
|
+
if (this.captures.has(key)) return
|
|
193
|
+
const capture = this.capture(ctx, agent, turn, signal).catch(async (error: unknown) => {
|
|
194
|
+
await this.recordFailure(ctx, agent.id, turn, error)
|
|
195
|
+
})
|
|
196
|
+
this.captures.set(key, capture)
|
|
197
|
+
}
|
|
198
|
+
|
|
142
199
|
private async capture(
|
|
143
200
|
ctx: Pick<Context, 'logger'>,
|
|
144
201
|
agent: AgentLike,
|
|
@@ -148,7 +205,8 @@ export class TurnCheckpointCoordinator {
|
|
|
148
205
|
const key = checkpointKey(agent.id, turn)
|
|
149
206
|
const cwd = agent.session.header.cwd
|
|
150
207
|
if (cwd === undefined) return
|
|
151
|
-
const
|
|
208
|
+
const events = getSessionEvents(agent.session)
|
|
209
|
+
const start = events.findLast(event => event.type === 'turn/start' && event.data.turn === turn)
|
|
152
210
|
if (start === undefined) {
|
|
153
211
|
await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
|
|
154
212
|
return
|
|
@@ -160,11 +218,6 @@ export class TurnCheckpointCoordinator {
|
|
|
160
218
|
const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs))
|
|
161
219
|
const captureSignal = AbortSignal.any([signal, captureDeadline.signal])
|
|
162
220
|
try {
|
|
163
|
-
const existing = this.captures.get(key)
|
|
164
|
-
if (existing !== undefined) {
|
|
165
|
-
await waitWithSignal(existing, captureSignal).catch(() => undefined)
|
|
166
|
-
return
|
|
167
|
-
}
|
|
168
221
|
this.pending.add(key)
|
|
169
222
|
this.failures.delete(key)
|
|
170
223
|
this.skips.delete(key)
|
|
@@ -202,7 +255,6 @@ export class TurnCheckpointCoordinator {
|
|
|
202
255
|
this.pending.delete(key)
|
|
203
256
|
}
|
|
204
257
|
})()
|
|
205
|
-
this.captures.set(key, capture)
|
|
206
258
|
try {
|
|
207
259
|
await waitWithSignal(capture, outcomeSignal)
|
|
208
260
|
} catch (error) {
|
|
@@ -221,6 +273,25 @@ export class TurnCheckpointCoordinator {
|
|
|
221
273
|
}
|
|
222
274
|
}
|
|
223
275
|
|
|
276
|
+
/** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
|
|
277
|
+
private async waitForOpenTurn(agent: AgentLike, signal: AbortSignal): Promise<void> {
|
|
278
|
+
if (signal.aborted) return
|
|
279
|
+
const events = getSessionEvents(agent.session)
|
|
280
|
+
const boundary = events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
|
|
281
|
+
if (boundary?.type !== 'turn/start') return
|
|
282
|
+
const turn = boundary.data.turn
|
|
283
|
+
if (!Number.isSafeInteger(turn) || (turn as number) < 0) return
|
|
284
|
+
const capture = this.captures.get(checkpointKey(agent.id, turn as number))
|
|
285
|
+
if (capture === undefined) return
|
|
286
|
+
try {
|
|
287
|
+
await waitWithSignal(capture, signal)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// Delegate caller cancellation so the DSH tool runtime preserves its
|
|
290
|
+
// canonical ABORTED_BEFORE_DISPATCH result instead of a wrapper error.
|
|
291
|
+
if (!signal.aborted) throw error
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
224
295
|
private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
|
|
225
296
|
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
|
|
226
297
|
const current = (async () => {
|
|
@@ -313,104 +384,9 @@ export function installRewindHttp(
|
|
|
313
384
|
}), 'change-ledger.rewindHttp')
|
|
314
385
|
}
|
|
315
386
|
|
|
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
|
-
|
|
411
387
|
/** Build the exact-route handler as a testable unit. */
|
|
412
388
|
export function createRewindHttpHandler(
|
|
413
|
-
ctx:
|
|
389
|
+
ctx: ConversationRestartContext & { readonly agents?: AgentsLike },
|
|
414
390
|
engine: ChangeLedgerEngine,
|
|
415
391
|
coordinator: TurnCheckpointCoordinator,
|
|
416
392
|
): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
|
|
@@ -423,16 +399,7 @@ export function createRewindHttpHandler(
|
|
|
423
399
|
const detailsOnly = url.searchParams.get('details') === '1'
|
|
424
400
|
const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset')
|
|
425
401
|
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
|
|
426
|
-
const {
|
|
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
|
-
}
|
|
402
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
|
|
436
403
|
if (checkpoint === undefined) {
|
|
437
404
|
const durableSkip = await engine.findTurnCheckpointSkip({
|
|
438
405
|
cwd: target.cwd,
|
|
@@ -488,20 +455,11 @@ export function createRewindHttpHandler(
|
|
|
488
455
|
if (request.method === 'POST') {
|
|
489
456
|
const body = objectBody(await readBody(request))
|
|
490
457
|
const mode = body.mode
|
|
491
|
-
if (mode !== 'code' && mode !== 'both'
|
|
492
|
-
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code"
|
|
458
|
+
if (mode !== 'code' && mode !== 'both') {
|
|
459
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"')
|
|
493
460
|
}
|
|
494
461
|
const sessionId = requiredText(body.sessionId, 'sessionId')
|
|
495
462
|
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
|
-
}
|
|
505
463
|
const checkpointId = requiredText(body.checkpointId, 'checkpointId')
|
|
506
464
|
const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
|
|
507
465
|
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
|
|
@@ -579,29 +537,20 @@ interface MessageCheckpoint extends MessageTarget {
|
|
|
579
537
|
readonly id: string
|
|
580
538
|
}
|
|
581
539
|
|
|
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
|
-
|
|
591
540
|
async function resolveMessageCheckpoint(
|
|
592
541
|
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
593
542
|
engine: ChangeLedgerEngine,
|
|
594
543
|
sessionId: string,
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
544
|
+
messageSeq: number,
|
|
545
|
+
): Promise<{ readonly target: MessageTarget; readonly checkpoint?: MessageCheckpoint }> {
|
|
546
|
+
let current = await readSession(ctx, sessionId)
|
|
547
|
+
const target = messageTarget(current, messageSeq)
|
|
599
548
|
const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn })
|
|
600
549
|
if (direct !== undefined) {
|
|
601
550
|
if (direct.turnStartSeq !== target.turnStartSeq) {
|
|
602
551
|
throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start')
|
|
603
552
|
}
|
|
604
|
-
return { ...target, id: direct.id }
|
|
553
|
+
return { target, checkpoint: { ...target, id: direct.id } }
|
|
605
554
|
}
|
|
606
555
|
|
|
607
556
|
const seen = new Set<string>([sessionId])
|
|
@@ -613,7 +562,7 @@ async function resolveMessageCheckpoint(
|
|
|
613
562
|
}
|
|
614
563
|
if (parentId === undefined || seedLength === undefined
|
|
615
564
|
|| target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
|
|
616
|
-
return
|
|
565
|
+
return { target }
|
|
617
566
|
}
|
|
618
567
|
if (seen.has(parentId)) {
|
|
619
568
|
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle')
|
|
@@ -624,7 +573,7 @@ async function resolveMessageCheckpoint(
|
|
|
624
573
|
} catch (error) {
|
|
625
574
|
throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error })
|
|
626
575
|
}
|
|
627
|
-
const parentTarget = messageTarget(current,
|
|
576
|
+
const parentTarget = messageTarget(current, messageSeq)
|
|
628
577
|
if (parentTarget.turn !== target.turn
|
|
629
578
|
|| parentTarget.turnStartSeq !== target.turnStartSeq
|
|
630
579
|
|| parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
@@ -635,7 +584,7 @@ async function resolveMessageCheckpoint(
|
|
|
635
584
|
if (inherited.turnStartSeq !== target.turnStartSeq) {
|
|
636
585
|
throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary')
|
|
637
586
|
}
|
|
638
|
-
return { ...target, id: inherited.id }
|
|
587
|
+
return { target, checkpoint: { ...target, id: inherited.id } }
|
|
639
588
|
}
|
|
640
589
|
}
|
|
641
590
|
|
|
@@ -646,8 +595,7 @@ async function checkpointForRequest(
|
|
|
646
595
|
messageSeq: number,
|
|
647
596
|
requestedId: string,
|
|
648
597
|
): Promise<MessageCheckpoint> {
|
|
649
|
-
const {
|
|
650
|
-
const checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target)
|
|
598
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
|
|
651
599
|
if (checkpoint === undefined) {
|
|
652
600
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`)
|
|
653
601
|
}
|
|
@@ -658,25 +606,39 @@ async function checkpointForRequest(
|
|
|
658
606
|
}
|
|
659
607
|
|
|
660
608
|
async function createConversationRestart(
|
|
661
|
-
ctx:
|
|
609
|
+
ctx: ConversationRestartContext,
|
|
662
610
|
sourceId: string,
|
|
663
|
-
|
|
611
|
+
checkpoint: MessageCheckpoint,
|
|
664
612
|
): Promise<{ readonly sessionId: string }> {
|
|
665
613
|
const source = await readSession(ctx, sourceId)
|
|
666
|
-
const current = messageTarget(source,
|
|
667
|
-
if (current.turn !==
|
|
668
|
-
|| current.turnStartSeq !==
|
|
669
|
-
|| current.previousTurnEndSeq !==
|
|
614
|
+
const current = messageTarget(source, checkpoint.messageSeq)
|
|
615
|
+
if (current.turn !== checkpoint.turn
|
|
616
|
+
|| current.turnStartSeq !== checkpoint.turnStartSeq
|
|
617
|
+
|| current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
|
|
670
618
|
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
|
|
671
619
|
}
|
|
672
|
-
const
|
|
673
|
-
|
|
620
|
+
const controller = ctx.sessionController
|
|
621
|
+
if (controller !== undefined) {
|
|
622
|
+
const created = checkpoint.previousTurnEndSeq === undefined
|
|
623
|
+
? await controller.create({ cwd: checkpoint.cwd })
|
|
624
|
+
: await controller.fork({ sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq })
|
|
625
|
+
return { sessionId: requiredText(created.sessionId, 'sessionController sessionId') }
|
|
626
|
+
}
|
|
627
|
+
const apiProxy = ctx.apiProxy
|
|
628
|
+
if (apiProxy === undefined) {
|
|
629
|
+
throw new ChangeLedgerError(
|
|
630
|
+
'CONVERSATION_REWIND_FAILED',
|
|
631
|
+
'this DSH host exposes neither sessionController nor apiProxy, so a new conversation cannot be created',
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
const response = checkpoint.previousTurnEndSeq === undefined
|
|
635
|
+
? await apiProxy.sessions.create({
|
|
674
636
|
rpcId: randomUUID(),
|
|
675
|
-
payload: { cwd:
|
|
637
|
+
payload: { cwd: checkpoint.cwd },
|
|
676
638
|
})
|
|
677
|
-
: await
|
|
639
|
+
: await apiProxy.sessions.fork({
|
|
678
640
|
rpcId: randomUUID(),
|
|
679
|
-
payload: { sessionId: sourceId, atSeq:
|
|
641
|
+
payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
|
|
680
642
|
})
|
|
681
643
|
if (!response.result.ok) {
|
|
682
644
|
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message)
|
|
@@ -687,16 +649,17 @@ async function createConversationRestart(
|
|
|
687
649
|
function messageTarget(session: SessionLike, messageSeq: number): MessageTarget {
|
|
688
650
|
const cwd = session.header.cwd
|
|
689
651
|
if (cwd === undefined) throw new ChangeLedgerError('WORKSPACE_REQUIRED', `session ${session.id} has no workspace`)
|
|
690
|
-
const
|
|
652
|
+
const events = getSessionEvents(session)
|
|
653
|
+
const message = events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event))
|
|
691
654
|
if (message === undefined) {
|
|
692
655
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `session ${session.id} has no user message at ${String(messageSeq)}`)
|
|
693
656
|
}
|
|
694
|
-
const start =
|
|
657
|
+
const start = events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq)
|
|
695
658
|
const turn = start?.data.turn
|
|
696
659
|
if (start === undefined || !Number.isSafeInteger(turn) || (turn as number) < 0) {
|
|
697
660
|
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message has no valid turn start')
|
|
698
661
|
}
|
|
699
|
-
const openingMessage =
|
|
662
|
+
const openingMessage = events.find(event => (
|
|
700
663
|
event.type === 'user/message'
|
|
701
664
|
&& event.seq > start.seq
|
|
702
665
|
&& event.seq <= messageSeq
|
|
@@ -705,11 +668,11 @@ function messageTarget(session: SessionLike, messageSeq: number): MessageTarget
|
|
|
705
668
|
if (openingMessage?.seq !== messageSeq) {
|
|
706
669
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', 'rewind is available only for the opening user message of a turn')
|
|
707
670
|
}
|
|
708
|
-
const interveningEnd =
|
|
671
|
+
const interveningEnd = events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq)
|
|
709
672
|
if (interveningEnd !== undefined) {
|
|
710
673
|
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message is outside its recorded turn')
|
|
711
674
|
}
|
|
712
|
-
const previousEnd =
|
|
675
|
+
const previousEnd = events.findLast(event => event.type === 'turn/end' && event.seq < start.seq)
|
|
713
676
|
return {
|
|
714
677
|
messageSeq,
|
|
715
678
|
turn: turn as number,
|
|
@@ -810,3 +773,73 @@ function json(response: HttpResponseLike, status: number, value: unknown): void
|
|
|
810
773
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
811
774
|
response.end(`${JSON.stringify(value)}\n`)
|
|
812
775
|
}
|
|
776
|
+
|
|
777
|
+
export const MANAGE_HTTP_PATH = '/turn-rewind/manage'
|
|
778
|
+
|
|
779
|
+
/** Register the same-origin storage-management endpoint consumed by the settings card. */
|
|
780
|
+
export function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void {
|
|
781
|
+
ctx.effect(() => ctx.webServer.register({
|
|
782
|
+
kind: 'exact',
|
|
783
|
+
path: MANAGE_HTTP_PATH,
|
|
784
|
+
handler: createManageHttpHandler(engine),
|
|
785
|
+
}), 'change-ledger.manageHttp')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Build the storage-management route as a testable unit. */
|
|
789
|
+
export function createManageHttpHandler(
|
|
790
|
+
engine: ChangeLedgerEngine,
|
|
791
|
+
): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
|
|
792
|
+
return async (request, response) => {
|
|
793
|
+
try {
|
|
794
|
+
if (request.method === 'GET') {
|
|
795
|
+
const workspaces = await engine.listWorkspaces()
|
|
796
|
+
json(response, 200, {
|
|
797
|
+
storageDir: engine.config.storageDir,
|
|
798
|
+
totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
|
|
799
|
+
workspaces: workspaces.map((workspace) => ({
|
|
800
|
+
workspace: workspace.workspace,
|
|
801
|
+
totalBytes: workspace.totalBytes,
|
|
802
|
+
recoveryCount: workspace.recoveryCount,
|
|
803
|
+
restorePoints: workspace.restorePoints.map((point) => ({
|
|
804
|
+
id: point.id,
|
|
805
|
+
kind: point.kind,
|
|
806
|
+
format: point.format,
|
|
807
|
+
createdAt: point.createdAt,
|
|
808
|
+
totalBytes: point.totalBytes,
|
|
809
|
+
fileCount: point.fileCount,
|
|
810
|
+
...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
|
|
811
|
+
...(point.turn === undefined ? {} : { turn: point.turn }),
|
|
812
|
+
...(point.label === undefined ? {} : { label: point.label }),
|
|
813
|
+
})),
|
|
814
|
+
})),
|
|
815
|
+
})
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
if (request.method === 'POST') {
|
|
819
|
+
const body = objectBody(await readBody(request))
|
|
820
|
+
const action = requiredText(body.action, 'action')
|
|
821
|
+
if (action === 'delete') {
|
|
822
|
+
const workspace = requiredText(body.workspace, 'workspace')
|
|
823
|
+
const restorePointId = requiredText(body.restorePointId, 'restorePointId')
|
|
824
|
+
const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] })
|
|
825
|
+
if (report.deletedRestorePoints === 0) {
|
|
826
|
+
throw new ChangeLedgerError(
|
|
827
|
+
report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND',
|
|
828
|
+
report.retainedRestorePoints > 0
|
|
829
|
+
? 'restore point is required by an incomplete recovery journal'
|
|
830
|
+
: `restore point ${restorePointId} does not exist`,
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
json(response, 200, { status: 'completed', action, ...report })
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `unsupported manage action ${action}`)
|
|
837
|
+
}
|
|
838
|
+
response.writeHead(405, { allow: 'GET, POST' })
|
|
839
|
+
response.end()
|
|
840
|
+
} catch (error) {
|
|
841
|
+
const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
|
|
842
|
+
json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED' })
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|