@anionex/dsh-turn-rewind 0.1.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/LICENSE +28 -0
- package/README.md +182 -0
- package/README.zh.md +180 -0
- package/SECURITY.md +53 -0
- package/cordis.patch.yml +4 -0
- package/docs/FORMAT.md +101 -0
- package/docs/IMPLEMENTATION_PLAN.md +56 -0
- package/docs/assets/turn-rewind-action.png +0 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +473 -0
- package/lib/client.js.map +1 -0
- package/lib/engine.js +616 -0
- package/lib/errors.js +20 -0
- package/lib/git.js +155 -0
- package/lib/index.js +71 -0
- package/lib/path-utils.js +262 -0
- package/lib/rewind-host.js +421 -0
- package/lib/snapshot.js +183 -0
- package/lib/store.js +391 -0
- package/lib/types/client/index.d.ts +81 -0
- package/lib/types/engine.d.ts +90 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/git.d.ts +14 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/path-utils.d.ts +34 -0
- package/lib/types/rewind-host.d.ts +137 -0
- package/lib/types/snapshot.d.ts +35 -0
- package/lib/types/store.d.ts +39 -0
- package/lib/types/types.d.ts +177 -0
- package/lib/types/validate.d.ts +11 -0
- package/lib/types.js +2 -0
- package/lib/validate.js +242 -0
- package/package.json +91 -0
- package/src/client/index.tsx +648 -0
- package/src/client/platform.d.ts +27 -0
- package/src/engine.ts +770 -0
- package/src/errors.ts +23 -0
- package/src/git.ts +178 -0
- package/src/index.ts +100 -0
- package/src/path-utils.ts +255 -0
- package/src/rewind-host.ts +584 -0
- package/src/snapshot.ts +229 -0
- package/src/store.ts +383 -0
- package/src/types.ts +195 -0
- package/src/validate.ts +245 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
4
|
+
import type { ChangeLedgerEngine } from './engine.js'
|
|
5
|
+
import { discoverRepositoryRoot } from './git.js'
|
|
6
|
+
|
|
7
|
+
interface SessionEventLike {
|
|
8
|
+
readonly type: string
|
|
9
|
+
readonly seq: number
|
|
10
|
+
readonly data: Record<string, unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SessionHeaderLike {
|
|
14
|
+
readonly cwd?: string
|
|
15
|
+
readonly parentSession?: string
|
|
16
|
+
readonly seedLength?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SessionLike {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly header: SessionHeaderLike
|
|
22
|
+
readonly events: readonly SessionEventLike[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AgentLike {
|
|
26
|
+
readonly id: string
|
|
27
|
+
readonly status: 'idle' | 'running'
|
|
28
|
+
readonly session: SessionLike
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AgentsLike {
|
|
32
|
+
list(): AgentLike[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface SessionsLike {
|
|
36
|
+
get(id: string): SessionLike | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SessionQueryLike {
|
|
40
|
+
readSession(id: string): Promise<{ readonly session: SessionHeaderLike; readonly events: readonly SessionEventLike[] }>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface HttpRequestLike {
|
|
44
|
+
method?: string
|
|
45
|
+
url?: string
|
|
46
|
+
on(event: 'data', listener: (chunk: Uint8Array | string) => void): this
|
|
47
|
+
on(event: 'end', listener: () => void): this
|
|
48
|
+
on(event: 'error', listener: (error: unknown) => void): this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface HttpResponseLike {
|
|
52
|
+
writeHead(status: number, headers?: Record<string, string>): unknown
|
|
53
|
+
end(body?: string): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface HttpServerLike {
|
|
57
|
+
register(route: {
|
|
58
|
+
kind: 'exact'
|
|
59
|
+
path: string
|
|
60
|
+
handler: (request: HttpRequestLike, response: HttpResponseLike) => void | Promise<void>
|
|
61
|
+
}): () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ApiProxyLike {
|
|
65
|
+
readonly sessions: {
|
|
66
|
+
create(request: {
|
|
67
|
+
readonly rpcId: string
|
|
68
|
+
readonly payload: { readonly cwd: string }
|
|
69
|
+
}): Promise<{
|
|
70
|
+
readonly result:
|
|
71
|
+
| { readonly ok: true; readonly value: { readonly sessionId: string } }
|
|
72
|
+
| { readonly ok: false; readonly error: { readonly message: string } }
|
|
73
|
+
}>
|
|
74
|
+
fork(request: {
|
|
75
|
+
readonly rpcId: string
|
|
76
|
+
readonly payload: { readonly sessionId: string; readonly atSeq: number }
|
|
77
|
+
}): Promise<{
|
|
78
|
+
readonly result:
|
|
79
|
+
| { readonly ok: true; readonly value: { readonly sessionId: string } }
|
|
80
|
+
| { readonly ok: false; readonly error: { readonly message: string } }
|
|
81
|
+
}>
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
declare module '@deepseek-ai/cordis' {
|
|
86
|
+
interface Context {
|
|
87
|
+
agents: AgentsLike
|
|
88
|
+
sessions: SessionsLike
|
|
89
|
+
sessionQuery: SessionQueryLike
|
|
90
|
+
webServer: HttpServerLike
|
|
91
|
+
apiProxy: ApiProxyLike
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Events {
|
|
95
|
+
'agent/pre-step'(
|
|
96
|
+
payload: {
|
|
97
|
+
readonly agent: AgentLike
|
|
98
|
+
readonly turn: number
|
|
99
|
+
readonly step: number
|
|
100
|
+
readonly signal: AbortSignal
|
|
101
|
+
},
|
|
102
|
+
next: () => Promise<unknown>,
|
|
103
|
+
): Promise<unknown>
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const REWIND_HTTP_PATH = '/turn-rewind'
|
|
108
|
+
const BODY_LIMIT = 64 * 1024
|
|
109
|
+
const INITIAL_CHANGE_PREVIEW_LIMIT = 8
|
|
110
|
+
const MAX_CHANGE_PAGE_SIZE = 200
|
|
111
|
+
|
|
112
|
+
/** Capture each turn before its opening user message can trigger model or tool work. */
|
|
113
|
+
export class TurnCheckpointCoordinator {
|
|
114
|
+
private readonly captures = new Map<string, Promise<void>>()
|
|
115
|
+
private readonly pending = new Set<string>()
|
|
116
|
+
private readonly failures = new Map<string, string>()
|
|
117
|
+
private readonly workspaceTails = new Map<string, Promise<void>>()
|
|
118
|
+
|
|
119
|
+
constructor(private readonly engine: ChangeLedgerEngine) {}
|
|
120
|
+
|
|
121
|
+
/** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
|
|
122
|
+
install(ctx: Context): void {
|
|
123
|
+
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
|
|
124
|
+
if (step === 1) await this.capture(ctx, agent, turn, signal)
|
|
125
|
+
return next()
|
|
126
|
+
}, { prepend: true })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 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 } {
|
|
131
|
+
const key = checkpointKey(sessionId, turn)
|
|
132
|
+
if (this.pending.has(key)) return { status: 'pending' }
|
|
133
|
+
const error = this.failures.get(key)
|
|
134
|
+
return error === undefined ? { status: 'missing' } : { status: 'failed', error }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async capture(
|
|
138
|
+
ctx: Pick<Context, 'logger'>,
|
|
139
|
+
agent: AgentLike,
|
|
140
|
+
turn: number,
|
|
141
|
+
signal: AbortSignal,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const key = checkpointKey(agent.id, turn)
|
|
144
|
+
const existing = this.captures.get(key)
|
|
145
|
+
if (existing !== undefined) {
|
|
146
|
+
await existing
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const cwd = agent.session.header.cwd
|
|
150
|
+
if (cwd === undefined) return
|
|
151
|
+
const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn)
|
|
152
|
+
if (start === undefined) {
|
|
153
|
+
this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
this.pending.add(key)
|
|
157
|
+
this.failures.delete(key)
|
|
158
|
+
const capture = (async () => {
|
|
159
|
+
try {
|
|
160
|
+
const workspace = await discoverRepositoryRoot(cwd, signal)
|
|
161
|
+
await this.serializeWorkspace(workspace, signal, async () => {
|
|
162
|
+
await this.engine.createTurnCheckpoint({
|
|
163
|
+
cwd: workspace,
|
|
164
|
+
sessionId: agent.id,
|
|
165
|
+
turn,
|
|
166
|
+
turnStartSeq: start.seq,
|
|
167
|
+
signal,
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
} catch (error) {
|
|
171
|
+
this.recordFailure(ctx, agent.id, turn, error)
|
|
172
|
+
} finally {
|
|
173
|
+
this.pending.delete(key)
|
|
174
|
+
}
|
|
175
|
+
})()
|
|
176
|
+
this.captures.set(key, capture)
|
|
177
|
+
await capture
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
|
|
181
|
+
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
|
|
182
|
+
const current = previous.catch(() => undefined).then(async () => {
|
|
183
|
+
signal.throwIfAborted()
|
|
184
|
+
await task()
|
|
185
|
+
})
|
|
186
|
+
this.workspaceTails.set(workspace, current)
|
|
187
|
+
try {
|
|
188
|
+
await current
|
|
189
|
+
} finally {
|
|
190
|
+
if (this.workspaceTails.get(workspace) === current) this.workspaceTails.delete(workspace)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private recordFailure(
|
|
195
|
+
ctx: Pick<Context, 'logger'>,
|
|
196
|
+
sessionId: string,
|
|
197
|
+
turn: number,
|
|
198
|
+
error: unknown,
|
|
199
|
+
): void {
|
|
200
|
+
const message = errorMessage(error)
|
|
201
|
+
this.failures.set(checkpointKey(sessionId, turn), message)
|
|
202
|
+
ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Register the same-origin preview/apply endpoint consumed by the browser half. */
|
|
207
|
+
export function installRewindHttp(
|
|
208
|
+
ctx: Context,
|
|
209
|
+
engine: ChangeLedgerEngine,
|
|
210
|
+
coordinator: TurnCheckpointCoordinator,
|
|
211
|
+
): void {
|
|
212
|
+
ctx.effect(() => ctx.webServer.register({
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: REWIND_HTTP_PATH,
|
|
215
|
+
handler: createRewindHttpHandler(ctx, engine, coordinator),
|
|
216
|
+
}), 'change-ledger.rewindHttp')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Build the exact-route handler as a testable unit. */
|
|
220
|
+
export function createRewindHttpHandler(
|
|
221
|
+
ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & { readonly agents?: AgentsLike },
|
|
222
|
+
engine: ChangeLedgerEngine,
|
|
223
|
+
coordinator: TurnCheckpointCoordinator,
|
|
224
|
+
): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
|
|
225
|
+
return async (request, response) => {
|
|
226
|
+
try {
|
|
227
|
+
if (request.method === 'GET') {
|
|
228
|
+
const url = new URL(request.url ?? REWIND_HTTP_PATH, 'http://dsh.local')
|
|
229
|
+
const sessionId = requiredText(url.searchParams.get('sessionId'), 'sessionId')
|
|
230
|
+
const messageSeq = nonNegativeInteger(url.searchParams.get('messageSeq'), 'messageSeq')
|
|
231
|
+
const detailsOnly = url.searchParams.get('details') === '1'
|
|
232
|
+
const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset')
|
|
233
|
+
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
|
|
234
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
|
|
235
|
+
if (checkpoint === undefined) {
|
|
236
|
+
json(response, 200, coordinator.state(sessionId, target.turn))
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id })
|
|
240
|
+
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
|
|
241
|
+
const changes = inspection.changes.slice(offset, offset + limit)
|
|
242
|
+
const common = {
|
|
243
|
+
status: 'ready', sessionId, messageSeq, turn: checkpoint.turn, checkpointId: checkpoint.id,
|
|
244
|
+
turnStartSeq: checkpoint.turnStartSeq,
|
|
245
|
+
totalChanges: inspection.changes.length,
|
|
246
|
+
changes: changes.map(change => ({ path: change.path, kind: change.kind })),
|
|
247
|
+
offset,
|
|
248
|
+
truncated: offset + changes.length < inspection.changes.length,
|
|
249
|
+
headChanged: inspection.headChanged,
|
|
250
|
+
operationChanged: inspection.operationChanged,
|
|
251
|
+
checkpointHead: inspection.restorePoint.head,
|
|
252
|
+
checkpointBranch: inspection.restorePoint.branch,
|
|
253
|
+
checkpointOperation: inspection.restorePoint.operation,
|
|
254
|
+
currentHead: inspection.currentHead,
|
|
255
|
+
currentBranch: inspection.currentBranch,
|
|
256
|
+
currentOperation: inspection.currentOperation,
|
|
257
|
+
activeSessionIds,
|
|
258
|
+
restoreBlocked: activeSessionIds.length > 0 || inspection.operationChanged,
|
|
259
|
+
} as const
|
|
260
|
+
if (inspection.changes.length === 0) {
|
|
261
|
+
json(response, 200, common)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (detailsOnly || activeSessionIds.length > 0 || inspection.operationChanged) {
|
|
265
|
+
json(response, 200, common)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
const plan = await engine.planRestore({
|
|
269
|
+
cwd: checkpoint.cwd,
|
|
270
|
+
restorePointId: checkpoint.id,
|
|
271
|
+
sessionId,
|
|
272
|
+
allowHeadChange: inspection.headChanged,
|
|
273
|
+
expectedCurrentTreeHash: inspection.currentTreeHash,
|
|
274
|
+
expectedRepository: inspection.currentRepository,
|
|
275
|
+
})
|
|
276
|
+
json(response, 200, { ...common, planId: plan.id, confirmation: plan.confirmation })
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (request.method === 'POST') {
|
|
280
|
+
const body = objectBody(await readBody(request))
|
|
281
|
+
const mode = body.mode
|
|
282
|
+
if (mode !== 'code' && mode !== 'both') {
|
|
283
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"')
|
|
284
|
+
}
|
|
285
|
+
const sessionId = requiredText(body.sessionId, 'sessionId')
|
|
286
|
+
const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq')
|
|
287
|
+
const checkpointId = requiredText(body.checkpointId, 'checkpointId')
|
|
288
|
+
const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
|
|
289
|
+
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
|
|
290
|
+
if (activeSessionIds.length > 0) {
|
|
291
|
+
throw new ChangeLedgerError(
|
|
292
|
+
'WORKSPACE_IN_USE',
|
|
293
|
+
`project files are also used by active sessions: ${activeSessionIds.slice(0, 5).join(', ')}`,
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
const planId = optionalText(body.planId, 'planId')
|
|
297
|
+
const confirmation = optionalText(body.confirmation, 'confirmation')
|
|
298
|
+
if (planId === undefined || confirmation === undefined) {
|
|
299
|
+
throw new ChangeLedgerError('NO_CHANGES', 'the selected turn has no project files to restore')
|
|
300
|
+
}
|
|
301
|
+
const restoreResult = await engine.applyRestore({ planId, confirmation, sessionId })
|
|
302
|
+
if (mode === 'code') {
|
|
303
|
+
json(response, 200, { status: 'completed', mode, ...restoreResult })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const fork = await createConversationRestart(ctx, sessionId, checkpoint)
|
|
309
|
+
json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId, ...restoreResult })
|
|
310
|
+
} catch (forkError) {
|
|
311
|
+
if (restoreResult === undefined) throw forkError
|
|
312
|
+
try {
|
|
313
|
+
const rollbackPlan = await engine.planRestore({
|
|
314
|
+
cwd: checkpoint.cwd,
|
|
315
|
+
restorePointId: restoreResult.rescuePointId,
|
|
316
|
+
sessionId,
|
|
317
|
+
})
|
|
318
|
+
await engine.applyRestore({
|
|
319
|
+
planId: rollbackPlan.id,
|
|
320
|
+
confirmation: rollbackPlan.confirmation,
|
|
321
|
+
sessionId,
|
|
322
|
+
})
|
|
323
|
+
} catch (rollbackError) {
|
|
324
|
+
throw new AggregateError([forkError, rollbackError], 'conversation fork failed and code compensation also failed')
|
|
325
|
+
}
|
|
326
|
+
throw new ChangeLedgerError(
|
|
327
|
+
'RESTORE_FAILED_ROLLED_BACK',
|
|
328
|
+
`conversation fork failed; code was recovered from ${restoreResult.rescuePointId}: ${errorMessage(forkError)}`,
|
|
329
|
+
{ cause: forkError },
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
json(response, 405, { error: 'method not allowed' })
|
|
335
|
+
} catch (error) {
|
|
336
|
+
const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
|
|
337
|
+
json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'REWIND_FAILED' })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function readSession(
|
|
343
|
+
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
344
|
+
sessionId: string,
|
|
345
|
+
): Promise<SessionLike> {
|
|
346
|
+
const live = ctx.sessions.get(sessionId)
|
|
347
|
+
if (live !== undefined) return live
|
|
348
|
+
const stored = await ctx.sessionQuery.readSession(sessionId)
|
|
349
|
+
return { id: sessionId, header: stored.session, events: stored.events }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
interface MessageTarget {
|
|
353
|
+
readonly messageSeq: number
|
|
354
|
+
readonly turn: number
|
|
355
|
+
readonly turnStartSeq: number
|
|
356
|
+
readonly previousTurnEndSeq?: number
|
|
357
|
+
readonly cwd: string
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
interface MessageCheckpoint extends MessageTarget {
|
|
361
|
+
readonly id: string
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function resolveMessageCheckpoint(
|
|
365
|
+
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
366
|
+
engine: ChangeLedgerEngine,
|
|
367
|
+
sessionId: string,
|
|
368
|
+
messageSeq: number,
|
|
369
|
+
): Promise<{ readonly target: MessageTarget; readonly checkpoint?: MessageCheckpoint }> {
|
|
370
|
+
let current = await readSession(ctx, sessionId)
|
|
371
|
+
const target = messageTarget(current, messageSeq)
|
|
372
|
+
const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn })
|
|
373
|
+
if (direct !== undefined) {
|
|
374
|
+
if (direct.turnStartSeq !== target.turnStartSeq) {
|
|
375
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start')
|
|
376
|
+
}
|
|
377
|
+
return { target, checkpoint: { ...target, id: direct.id } }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const seen = new Set<string>([sessionId])
|
|
381
|
+
while (true) {
|
|
382
|
+
const parentId = current.header.parentSession
|
|
383
|
+
const seedLength = current.header.seedLength
|
|
384
|
+
if ((parentId === undefined) !== (seedLength === undefined)) {
|
|
385
|
+
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage has incomplete parent metadata')
|
|
386
|
+
}
|
|
387
|
+
if (parentId === undefined || seedLength === undefined
|
|
388
|
+
|| target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
|
|
389
|
+
return { target }
|
|
390
|
+
}
|
|
391
|
+
if (seen.has(parentId)) {
|
|
392
|
+
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle')
|
|
393
|
+
}
|
|
394
|
+
seen.add(parentId)
|
|
395
|
+
try {
|
|
396
|
+
current = await readSession(ctx, parentId)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error })
|
|
399
|
+
}
|
|
400
|
+
const parentTarget = messageTarget(current, messageSeq)
|
|
401
|
+
if (parentTarget.turn !== target.turn
|
|
402
|
+
|| parentTarget.turnStartSeq !== target.turnStartSeq
|
|
403
|
+
|| parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
404
|
+
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage no longer matches the inherited message boundary')
|
|
405
|
+
}
|
|
406
|
+
const inherited = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId: parentId, turn: target.turn })
|
|
407
|
+
if (inherited === undefined) continue
|
|
408
|
+
if (inherited.turnStartSeq !== target.turnStartSeq) {
|
|
409
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary')
|
|
410
|
+
}
|
|
411
|
+
return { target, checkpoint: { ...target, id: inherited.id } }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function checkpointForRequest(
|
|
416
|
+
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
417
|
+
engine: ChangeLedgerEngine,
|
|
418
|
+
sessionId: string,
|
|
419
|
+
messageSeq: number,
|
|
420
|
+
requestedId: string,
|
|
421
|
+
): Promise<MessageCheckpoint> {
|
|
422
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq)
|
|
423
|
+
if (checkpoint === undefined) {
|
|
424
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`)
|
|
425
|
+
}
|
|
426
|
+
if (requestedId !== checkpoint.id) {
|
|
427
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the selected message checkpoint changed; reopen the rewind dialog')
|
|
428
|
+
}
|
|
429
|
+
return checkpoint
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function createConversationRestart(
|
|
433
|
+
ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'>,
|
|
434
|
+
sourceId: string,
|
|
435
|
+
checkpoint: MessageCheckpoint,
|
|
436
|
+
): Promise<{ readonly sessionId: string }> {
|
|
437
|
+
const source = await readSession(ctx, sourceId)
|
|
438
|
+
const current = messageTarget(source, checkpoint.messageSeq)
|
|
439
|
+
if (current.turn !== checkpoint.turn
|
|
440
|
+
|| current.turnStartSeq !== checkpoint.turnStartSeq
|
|
441
|
+
|| current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
|
|
442
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
|
|
443
|
+
}
|
|
444
|
+
const response = checkpoint.previousTurnEndSeq === undefined
|
|
445
|
+
? await ctx.apiProxy.sessions.create({
|
|
446
|
+
rpcId: randomUUID(),
|
|
447
|
+
payload: { cwd: checkpoint.cwd },
|
|
448
|
+
})
|
|
449
|
+
: await ctx.apiProxy.sessions.fork({
|
|
450
|
+
rpcId: randomUUID(),
|
|
451
|
+
payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
|
|
452
|
+
})
|
|
453
|
+
if (!response.result.ok) {
|
|
454
|
+
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message)
|
|
455
|
+
}
|
|
456
|
+
return { sessionId: requiredText(response.result.value.sessionId, 'fork sessionId') }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function messageTarget(session: SessionLike, messageSeq: number): MessageTarget {
|
|
460
|
+
const cwd = session.header.cwd
|
|
461
|
+
if (cwd === undefined) throw new ChangeLedgerError('WORKSPACE_REQUIRED', `session ${session.id} has no workspace`)
|
|
462
|
+
const message = session.events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event))
|
|
463
|
+
if (message === undefined) {
|
|
464
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `session ${session.id} has no user message at ${String(messageSeq)}`)
|
|
465
|
+
}
|
|
466
|
+
const start = session.events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq)
|
|
467
|
+
const turn = start?.data.turn
|
|
468
|
+
if (start === undefined || !Number.isSafeInteger(turn) || (turn as number) < 0) {
|
|
469
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message has no valid turn start')
|
|
470
|
+
}
|
|
471
|
+
const openingMessage = session.events.find(event => (
|
|
472
|
+
event.type === 'user/message'
|
|
473
|
+
&& event.seq > start.seq
|
|
474
|
+
&& event.seq <= messageSeq
|
|
475
|
+
&& isDirectUserMessage(event)
|
|
476
|
+
))
|
|
477
|
+
if (openingMessage?.seq !== messageSeq) {
|
|
478
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', 'rewind is available only for the opening user message of a turn')
|
|
479
|
+
}
|
|
480
|
+
const interveningEnd = session.events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq)
|
|
481
|
+
if (interveningEnd !== undefined) {
|
|
482
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message is outside its recorded turn')
|
|
483
|
+
}
|
|
484
|
+
const previousEnd = session.events.findLast(event => event.type === 'turn/end' && event.seq < start.seq)
|
|
485
|
+
return {
|
|
486
|
+
messageSeq,
|
|
487
|
+
turn: turn as number,
|
|
488
|
+
turnStartSeq: start.seq,
|
|
489
|
+
...(previousEnd === undefined ? {} : { previousTurnEndSeq: previousEnd.seq }),
|
|
490
|
+
cwd,
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function isDirectUserMessage(event: SessionEventLike): boolean {
|
|
495
|
+
const source = event.data.source
|
|
496
|
+
return source !== null && typeof source === 'object' && !Array.isArray(source)
|
|
497
|
+
&& (source as Record<string, unknown>).kind === 'user'
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function checkpointKey(sessionId: string, turn: number): string {
|
|
501
|
+
return `${sessionId}\0${String(turn)}`
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function requiredText(value: unknown, name: string): string {
|
|
505
|
+
if (typeof value !== 'string' || value === '') throw new ChangeLedgerError('INVALID_ARGUMENTS', `${name} must be a non-empty string`)
|
|
506
|
+
return value
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function optionalText(value: unknown, name: string): string | undefined {
|
|
510
|
+
return value === undefined ? undefined : requiredText(value, name)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function nonNegativeInteger(value: unknown, name: string): number {
|
|
514
|
+
const parsed = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
|
|
515
|
+
if (!Number.isSafeInteger(parsed) || (parsed as number) < 0) {
|
|
516
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `${name} must be a non-negative safe integer`)
|
|
517
|
+
}
|
|
518
|
+
return parsed as number
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function pageSize(value: unknown, fallback: number): number {
|
|
522
|
+
if (value === null || value === undefined) return fallback
|
|
523
|
+
const parsed = nonNegativeInteger(value, 'limit')
|
|
524
|
+
if (parsed < 1 || parsed > MAX_CHANGE_PAGE_SIZE) {
|
|
525
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `limit must be between 1 and ${String(MAX_CHANGE_PAGE_SIZE)}`)
|
|
526
|
+
}
|
|
527
|
+
return parsed
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function sharedWorkspaceSessions(
|
|
531
|
+
agents: AgentsLike | undefined,
|
|
532
|
+
cwd: string,
|
|
533
|
+
): Promise<readonly string[]> {
|
|
534
|
+
const listed = agents?.list() ?? []
|
|
535
|
+
if (listed.length === 0) return []
|
|
536
|
+
const root = await discoverRepositoryRoot(cwd)
|
|
537
|
+
const shared: string[] = []
|
|
538
|
+
for (const agent of listed) {
|
|
539
|
+
if (agent.status !== 'running') continue
|
|
540
|
+
const session = agent.session
|
|
541
|
+
if (session.header.cwd === undefined) continue
|
|
542
|
+
try {
|
|
543
|
+
if (await discoverRepositoryRoot(session.header.cwd) === root) shared.push(session.id)
|
|
544
|
+
} catch {
|
|
545
|
+
// A live Session whose cwd is not a valid Git worktree cannot share this worktree.
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return shared.sort()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function objectBody(value: unknown): Record<string, unknown> {
|
|
552
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
553
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'request body must be an object')
|
|
554
|
+
}
|
|
555
|
+
return value as Record<string, unknown>
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function readBody(request: HttpRequestLike): Promise<unknown> {
|
|
559
|
+
const chunks: Buffer[] = []
|
|
560
|
+
let size = 0
|
|
561
|
+
await new Promise<void>((resolve, reject) => {
|
|
562
|
+
request.on('data', (chunk) => {
|
|
563
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
564
|
+
size += bytes.length
|
|
565
|
+
if (size > BODY_LIMIT) {
|
|
566
|
+
reject(new ChangeLedgerError('INVALID_ARGUMENTS', 'request body is too large'))
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
chunks.push(bytes)
|
|
570
|
+
})
|
|
571
|
+
request.on('end', resolve)
|
|
572
|
+
request.on('error', reject)
|
|
573
|
+
})
|
|
574
|
+
try {
|
|
575
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
|
|
576
|
+
} catch (error) {
|
|
577
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'request body must be valid JSON', { cause: error })
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function json(response: HttpResponseLike, status: number, value: unknown): void {
|
|
582
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
583
|
+
response.end(`${JSON.stringify(value)}\n`)
|
|
584
|
+
}
|