@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.
@@ -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: readonly SessionEventLike[]
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 before its opening user message can trigger model or tool work. */
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
- /** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
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) await this.capture(ctx, agent, turn, signal)
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 start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn)
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: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & { readonly agents?: AgentsLike },
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 { 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
- }
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' && mode !== 'messages') {
492
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"')
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
- source: SessionLike,
596
- target: MessageTarget,
597
- ): Promise<MessageCheckpoint | undefined> {
598
- let current = source
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 undefined
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, target.messageSeq)
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 { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
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: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'>,
609
+ ctx: ConversationRestartContext,
662
610
  sourceId: string,
663
- target: MessageTarget,
611
+ checkpoint: MessageCheckpoint,
664
612
  ): Promise<{ readonly sessionId: string }> {
665
613
  const source = await readSession(ctx, sourceId)
666
- const current = messageTarget(source, target.messageSeq)
667
- if (current.turn !== target.turn
668
- || current.turnStartSeq !== target.turnStartSeq
669
- || current.previousTurnEndSeq !== target.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 response = target.previousTurnEndSeq === undefined
673
- ? await ctx.apiProxy.sessions.create({
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: target.cwd },
637
+ payload: { cwd: checkpoint.cwd },
676
638
  })
677
- : await ctx.apiProxy.sessions.fork({
639
+ : await apiProxy.sessions.fork({
678
640
  rpcId: randomUUID(),
679
- payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
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 message = session.events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event))
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 = session.events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq)
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 = session.events.find(event => (
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 = session.events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq)
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 = session.events.findLast(event => event.type === 'turn/end' && event.seq < start.seq)
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
+ }