import type { UUID } from 'crypto' import { chmod, copyFile, mkdir, readFile, stat, unlink } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { createTwoFilesPatch, diffLines } from 'diff' import { ApiError } from '../middleware/errorHandler.js' import { type FileHistorySnapshot, } from '../../utils/fileHistory.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { conversationService } from './conversationService.js' import { sessionService, type MessageEntry } from './sessionService.js' type RewindTarget = { targetUserMessageId: string userMessageIndex: number userMessageCount: number messagesRemoved: number } type RewindCodePreview = { available: boolean reason?: string filesChanged: string[] insertions: number deletions: number } type TranscriptFileChange = { path: string absolutePath: string additions: number deletions: number diff?: string } export type RewindTargetSelector = { targetUserMessageId?: string userMessageIndex?: number expectedContent?: string } export type SessionRewindPreview = { target: { targetUserMessageId: string userMessageIndex: number userMessageCount: number } conversation: { messagesRemoved: number } code: RewindCodePreview } export type SessionRewindExecuteResult = SessionRewindPreview & { conversation: SessionRewindPreview['conversation'] & { removedMessageIds: string[] } } export type SessionTurnCheckpointPreview = SessionRewindPreview & { workDir: string } export type SessionTurnCheckpointDiffResult = { target: SessionRewindPreview['target'] workDir: string path: string state: 'ok' | 'missing' | 'error' diff?: string error?: string } function normalizeDiffStats(diffStats: { filesChanged?: string[] insertions?: number deletions?: number } | undefined): RewindCodePreview { return { available: true, filesChanged: diffStats?.filesChanged ?? [], insertions: diffStats?.insertions ?? 0, deletions: diffStats?.deletions ?? 0, } } function normalizePromptText(text: string): string { return text.replace(/\r\n/g, '\n').trim() } function extractUserPromptText(content: unknown): string { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' return content .flatMap((block) => { if (!block || typeof block !== 'object') return [] const record = block as Record return record.type === 'text' && typeof record.text === 'string' ? [record.text] : [] }) .join('\n') } function assertExpectedPromptMatches( targetMessage: { content: unknown }, expectedContent: string | undefined, ): void { if (expectedContent === undefined) return const actual = normalizePromptText(extractUserPromptText(targetMessage.content)) const expected = normalizePromptText(expectedContent) if (actual !== expected) { throw ApiError.badRequest( 'The resolved rewind target does not match the selected prompt. Refresh the session and try again.', ) } } async function resolveRewindTarget( sessionId: string, selector: RewindTargetSelector, ): Promise { const activeMessages = await sessionService.getSessionMessages(sessionId) const userMessages = activeMessages.filter((message) => message.type === 'user') if (userMessages.length === 0) { throw ApiError.badRequest('This session has no user messages to rewind.') } let targetUserMessage = null as (typeof userMessages)[number] | null let userMessageIndex = -1 if (selector.targetUserMessageId) { const activeMessage = activeMessages.find( (message) => message.id === selector.targetUserMessageId, ) if (activeMessage) { if (activeMessage.type !== 'user') { throw ApiError.badRequest('The selected rewind target is not a user message.') } targetUserMessage = activeMessage userMessageIndex = userMessages.findIndex( (message) => message.id === activeMessage.id, ) } } if (!targetUserMessage && Number.isInteger(selector.userMessageIndex)) { userMessageIndex = selector.userMessageIndex! if (userMessageIndex >= 0 && userMessageIndex < userMessages.length) { targetUserMessage = userMessages[userMessageIndex]! } } if ( !targetUserMessage || userMessageIndex < 0 || userMessageIndex >= userMessages.length ) { throw ApiError.badRequest( `Invalid rewind target. Expected targetUserMessageId or userMessageIndex 0-${userMessages.length - 1}.`, ) } assertExpectedPromptMatches(targetUserMessage, selector.expectedContent) const activeMessageIndex = activeMessages.findIndex( (message) => message.id === targetUserMessage.id, ) if (activeMessageIndex < 0) { throw ApiError.badRequest('The selected user message is not in the active chain.') } return { targetUserMessageId: targetUserMessage.id, userMessageIndex, userMessageCount: userMessages.length, messagesRemoved: activeMessages.length - activeMessageIndex, } } async function loadFileHistorySnapshots( sessionId: string, ): Promise { const snapshots = await sessionService.getSessionFileHistorySnapshots(sessionId) if (snapshots.length === 0) { return null } return snapshots } function expandTrackingPath(workDir: string, trackingPath: string): string { return isAbsolute(trackingPath) ? trackingPath : join(workDir, trackingPath) } function resolveBackupPath(sessionId: string, backupFileName: string): string { return join(getClaudeConfigHomeDir(), 'file-history', sessionId, backupFileName) } function collectTrackedPaths( snapshots: FileHistorySnapshot[], ): Set { const trackedPaths = new Set() for (const snapshot of snapshots) { for (const trackingPath of Object.keys(snapshot.trackedFileBackups)) { trackedPaths.add(trackingPath) } } return trackedPaths } function findTargetSnapshot( snapshots: FileHistorySnapshot[], targetUserMessageId: string, ): FileHistorySnapshot | null { return ( snapshots.findLast((snapshot) => snapshot.messageId === (targetUserMessageId as UUID)) ?? null ) } function getEarliestBackupFileName( trackingPath: string, snapshots: FileHistorySnapshot[], ): string | null | undefined { for (const snapshot of snapshots) { const backup = snapshot.trackedFileBackups[trackingPath] if (backup !== undefined) { return backup.backupFileName } } return undefined } function getBackupFileNameForTarget( trackingPath: string, snapshots: FileHistorySnapshot[], targetSnapshot: FileHistorySnapshot, ): string | null | undefined { const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] if (targetBackup && 'backupFileName' in targetBackup) { return targetBackup.backupFileName } return getEarliestBackupFileName(trackingPath, snapshots) } async function resolveSessionWorkDir(sessionId: string): Promise { return ( (conversationService.hasSession(sessionId) ? conversationService.getSessionWorkDir(sessionId) : null) || (await sessionService.getSessionWorkDir(sessionId)) || process.cwd() ) } async function resolveCheckpointBaseDir( sessionId: string, targetUserMessageId: string, fallbackWorkDir?: string, ): Promise { return ( (await sessionService.getSessionMessageCwd(sessionId, targetUserMessageId)) || fallbackWorkDir || (await resolveSessionWorkDir(sessionId)) ) } function normalizeComparablePath(filePath: string): string { return filePath.replace(/\\/g, '/') } function toCheckpointResponsePath( trackingPath: string, checkpointBaseDir: string, ): string { if (isAbsolute(trackingPath)) { return trackingPath } const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath) const relativePath = normalizeComparablePath(relative(checkpointBaseDir, absolutePath)) return relativePath && !relativePath.startsWith('../') ? relativePath : normalizeComparablePath(trackingPath) } function matchesCheckpointPath( requestedPath: string, trackingPath: string, checkpointBaseDir: string, ): boolean { const normalizedRequestedPath = normalizeComparablePath(requestedPath) const absolutePath = normalizeComparablePath( expandTrackingPath(checkpointBaseDir, trackingPath), ) const responsePath = normalizeComparablePath( toCheckpointResponsePath(trackingPath, checkpointBaseDir), ) return normalizedRequestedPath === absolutePath || normalizedRequestedPath === normalizeComparablePath(trackingPath) || normalizedRequestedPath === responsePath } function buildTurnPreview( target: RewindTarget, preview: RewindCodePreview, workDir: string, ): SessionTurnCheckpointPreview { return { target: { targetUserMessageId: target.targetUserMessageId, userMessageIndex: target.userMessageIndex, userMessageCount: target.userMessageCount, }, conversation: { messagesRemoved: target.messagesRemoved, }, code: preview, workDir, } } async function readFileOrNull(filePath: string): Promise { try { return await readFile(filePath, 'utf-8') } catch { return null } } function countInsertedLines(content: string): number { return diffLines('', content).reduce((total, change) => ( change.added ? total + (change.count || 0) : total ), 0) } function buildCheckpointDiff( displayPath: string, oldContent: string, newContent: string, oldExists: boolean, newExists: boolean, ): string { const oldFileName = oldExists ? `a/${displayPath}` : '/dev/null' const newFileName = newExists ? `b/${displayPath}` : '/dev/null' return createTwoFilesPatch( oldFileName, newFileName, oldContent, newContent, '', '', { context: 3 }, ) } async function readBackupContent( sessionId: string, backupFileName: string | null | undefined, ): Promise { if (backupFileName === undefined) return undefined if (backupFileName === null) return null return await readFileOrNull(resolveBackupPath(sessionId, backupFileName)) } function countTurnDiffStats( beforeContent: string | null, afterContent: string | null, ): { insertions: number; deletions: number } { let insertions = 0 let deletions = 0 for (const change of diffLines(beforeContent ?? '', afterContent ?? '')) { if (change.added) insertions += change.count || 0 if (change.removed) deletions += change.count || 0 } return { insertions, deletions } } function getTurnMessageRange( activeMessages: Awaited>, targetUserMessageId: string, ): { start: number; end: number } | null { const start = activeMessages.findIndex((message) => message.id === targetUserMessageId) if (start < 0) return null const nextUserIndex = activeMessages.findIndex( (message, index) => index > start && message.type === 'user', ) return { start, end: nextUserIndex >= 0 ? nextUserIndex : activeMessages.length } } function hasCompletedTurn( activeMessages: Awaited>, targetUserMessageId: string, ): boolean { const range = getTurnMessageRange(activeMessages, targetUserMessageId) if (!range) return false return activeMessages.slice(range.start + 1, range.end).some((message) => message.type === 'assistant' || message.type === 'tool_use' || message.type === 'tool_result' || message.type === 'error', ) } function getNextUserMessageId( userMessages: Awaited>, userMessageIndex: number, ): string | null { return userMessages[userMessageIndex + 1]?.id ?? null } function isWithinBaseDir(absolutePath: string, baseDir: string): boolean { const relativePath = relative(baseDir, absolutePath) return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) } function normalizeTranscriptRelativePath(filePath: string): string { return normalizeComparablePath(filePath).replace(/^\/+/, '') } function resolveTranscriptToolPath( filePath: unknown, baseDir: string, ): { path: string; absolutePath: string } | null { if (typeof filePath !== 'string' || !filePath.trim()) return null const normalizedBaseDir = resolve(baseDir) const absolutePath = isAbsolute(filePath) ? resolve(filePath) : resolve(normalizedBaseDir, filePath) if (!isWithinBaseDir(absolutePath, normalizedBaseDir)) return null return { path: normalizeTranscriptRelativePath(relative(normalizedBaseDir, absolutePath)), absolutePath, } } function countTranscriptLines(content: string): number { if (!content) return 0 const lines = content.split(/\r\n|\r|\n/) if (lines[lines.length - 1] === '') { lines.pop() } return lines.length } function buildTranscriptDiff( oldPath: string, newPath: string, oldContent: string, newContent: string, ): string { const oldLines = oldContent ? oldContent.split('\n') : [] const newLines = newContent ? newContent.split('\n') : [] if (oldLines.at(-1) === '') oldLines.pop() if (newLines.at(-1) === '') newLines.pop() return [ `diff --session a/${oldPath} b/${newPath}`, `--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`, `+++ b/${newPath}`, `@@ -1,${oldLines.length} +1,${newLines.length} @@`, ...oldLines.map((line) => `-${line}`), ...newLines.map((line) => `+${line}`), ].join('\n') } function buildTranscriptEditChange( filePath: { path: string; absolutePath: string }, input: Record, ): TranscriptFileChange { const oldString = typeof input.old_string === 'string' ? input.old_string : '' const newString = typeof input.new_string === 'string' ? input.new_string : '' return { path: filePath.path, absolutePath: filePath.absolutePath, additions: countTranscriptLines(newString), deletions: countTranscriptLines(oldString), diff: buildTranscriptDiff(filePath.path, filePath.path, oldString, newString), } } function extractApplyPatchTranscriptChanges( patch: unknown, baseDir: string, ): TranscriptFileChange[] { if (typeof patch !== 'string') return [] const changes: TranscriptFileChange[] = [] for (const line of patch.split('\n')) { const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) if (!match?.[1]) continue const filePath = resolveTranscriptToolPath(match[1], baseDir) if (!filePath) continue changes.push({ path: filePath.path, absolutePath: filePath.absolutePath, additions: 0, deletions: 0, }) } return changes } function extractTranscriptChangesFromTool( toolName: string, input: Record, baseDir: string, ): TranscriptFileChange[] { const normalizedToolName = toolName.toLowerCase() if (normalizedToolName === 'write') { const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir) if (!filePath) return [] const content = typeof input.content === 'string' ? input.content : '' return [{ path: filePath.path, absolutePath: filePath.absolutePath, additions: countTranscriptLines(content), deletions: 0, diff: buildTranscriptDiff('/dev/null', filePath.path, '', content), }] } if (normalizedToolName === 'edit') { const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir) if (!filePath) return [] return [buildTranscriptEditChange(filePath, input)] } if (normalizedToolName === 'multiedit') { const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir) if (!filePath || !Array.isArray(input.edits)) return [] return input.edits .filter((edit): edit is Record => !!edit && typeof edit === 'object') .map((edit) => buildTranscriptEditChange(filePath, edit)) } if (normalizedToolName === 'notebookedit') { const filePath = resolveTranscriptToolPath( input.notebook_path ?? input.file_path ?? input.path, baseDir, ) if (!filePath) return [] const oldString = typeof input.old_source === 'string' ? input.old_source : '' const newString = typeof input.new_source === 'string' ? input.new_source : '' return [{ path: filePath.path, absolutePath: filePath.absolutePath, additions: countTranscriptLines(newString), deletions: countTranscriptLines(oldString), diff: buildTranscriptDiff(filePath.path, filePath.path, oldString, newString), }] } if (normalizedToolName === 'apply_patch') { return extractApplyPatchTranscriptChanges(input.patch, baseDir) } return [] } function getToolUseIds(messages: MessageEntry[]): Set { const ids = new Set() for (const message of messages) { if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue for (const block of message.content) { if (!block || typeof block !== 'object') continue const record = block as Record if (record.type === 'tool_use' && typeof record.id === 'string') { ids.add(record.id) } } } return ids } function getTranscriptTurnMessages( activeMessages: MessageEntry[], targetUserMessageId: string, ): MessageEntry[] { const range = getTurnMessageRange(activeMessages, targetUserMessageId) if (!range) return [] const rawTurnMessages = activeMessages.slice(range.start + 1, range.end) const parentTurnMessages = rawTurnMessages.filter((message) => !message.parentToolUseId) const turnToolUseIds = getToolUseIds(parentTurnMessages) if (turnToolUseIds.size === 0) return parentTurnMessages const inlineChildMessages = rawTurnMessages.filter((message) => message.parentToolUseId && turnToolUseIds.has(message.parentToolUseId) ) const turnMessages = [...parentTurnMessages, ...inlineChildMessages] const includedIds = new Set(turnMessages.map((message) => message.id)) const childMessages = activeMessages.filter((message) => message.parentToolUseId && turnToolUseIds.has(message.parentToolUseId) && !includedIds.has(message.id) ) return [...turnMessages, ...childMessages] } function collectTranscriptTurnFileChanges( activeMessages: MessageEntry[], targetUserMessageId: string, baseDir: string, ): TranscriptFileChange[] { const turnMessages = getTranscriptTurnMessages(activeMessages, targetUserMessageId) if (turnMessages.length === 0) return [] const changes = new Map() for (const message of turnMessages) { if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue for (const block of message.content) { if (!block || typeof block !== 'object') continue const record = block as Record if (record.type !== 'tool_use' || typeof record.name !== 'string') continue const input = record.input if (!input || typeof input !== 'object') continue for (const change of extractTranscriptChangesFromTool( record.name, input as Record, baseDir, )) { const existing = changes.get(change.path) if (!existing) { changes.set(change.path, change) continue } changes.set(change.path, { ...existing, additions: existing.additions + change.additions, deletions: existing.deletions + change.deletions, diff: [existing.diff, change.diff].filter(Boolean).join('\n'), }) } } } return [...changes.values()].sort((a, b) => a.path.localeCompare(b.path)) } function buildTranscriptTurnCodePreview( activeMessages: MessageEntry[], targetUserMessageId: string, baseDir: string, ): RewindCodePreview { const changes = collectTranscriptTurnFileChanges(activeMessages, targetUserMessageId, baseDir) if (changes.length === 0) { return { available: false, reason: 'No transcript file changes were recorded for this turn.', filesChanged: [], insertions: 0, deletions: 0, } } return normalizeDiffStats({ filesChanged: changes.map((change) => change.absolutePath), insertions: changes.reduce((total, change) => total + change.additions, 0), deletions: changes.reduce((total, change) => total + change.deletions, 0), }) } function findTranscriptTurnDiff( activeMessages: MessageEntry[], targetUserMessageId: string, baseDir: string, requestedPath: string, ): TranscriptFileChange | null { const changes = collectTranscriptTurnFileChanges(activeMessages, targetUserMessageId, baseDir) return changes.find((change) => matchesCheckpointPath(requestedPath, change.path, baseDir) || normalizeComparablePath(requestedPath) === normalizeComparablePath(change.absolutePath) ) ?? null } async function getTurnBoundaryContents( sessionId: string, checkpointBaseDir: string, trackingPath: string, targetSnapshot: FileHistorySnapshot, nextSnapshot: FileHistorySnapshot | null, ): Promise<{ beforeContent: string | null; afterContent: string | null }> { const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath) const beforeContent = await readBackupContent( sessionId, targetSnapshot.trackedFileBackups[trackingPath]?.backupFileName, ) if (!nextSnapshot) { return { beforeContent: beforeContent ?? null, afterContent: await readFileOrNull(absolutePath), } } const nextContent = await readBackupContent( sessionId, nextSnapshot.trackedFileBackups[trackingPath]?.backupFileName, ) return { beforeContent: beforeContent ?? null, afterContent: nextContent === undefined ? beforeContent ?? null : nextContent, } } async function buildTurnCodePreview( sessionId: string, checkpointBaseDir: string, targetSnapshot: FileHistorySnapshot, nextSnapshot: FileHistorySnapshot | null, ): Promise { const trackedPaths = new Set([ ...Object.keys(targetSnapshot.trackedFileBackups), ...Object.keys(nextSnapshot?.trackedFileBackups ?? {}), ]) const filesChanged: string[] = [] let insertions = 0 let deletions = 0 for (const trackingPath of trackedPaths) { const { beforeContent, afterContent } = await getTurnBoundaryContents( sessionId, checkpointBaseDir, trackingPath, targetSnapshot, nextSnapshot, ) if (beforeContent === afterContent) continue filesChanged.push(expandTrackingPath(checkpointBaseDir, trackingPath)) const stats = countTurnDiffStats(beforeContent, afterContent) insertions += stats.insertions deletions += stats.deletions } return normalizeDiffStats({ filesChanged, insertions, deletions }) } async function hasFileChanged( filePath: string, backupFilePath: string, ): Promise { try { const [currentStat, backupStat] = await Promise.all([ stat(filePath), stat(backupFilePath), ]) if (currentStat.size !== backupStat.size) { return true } const [currentContent, backupContent] = await Promise.all([ readFile(filePath), readFile(backupFilePath), ]) return !currentContent.equals(backupContent) } catch { return true } } async function restoreBackupFile( filePath: string, backupFilePath: string, ): Promise { const backupStats = await stat(backupFilePath) try { await copyFile(backupFilePath, filePath) } catch (error) { const maybeErr = error as NodeJS.ErrnoException if (maybeErr.code !== 'ENOENT') throw error await mkdir(dirname(filePath), { recursive: true }) await copyFile(backupFilePath, filePath) } await chmod(filePath, backupStats.mode) } async function buildCodePreview( sessionId: string, checkpointBaseDir: string, targetUserMessageId: string, ): Promise<{ snapshots: FileHistorySnapshot[] | null preview: RewindCodePreview }> { const snapshots = await loadFileHistorySnapshots(sessionId) if (!snapshots) { return { snapshots: null, preview: { available: false, reason: 'No file checkpoints were recorded for this session.', filesChanged: [], insertions: 0, deletions: 0, }, } } const targetSnapshot = findTargetSnapshot(snapshots, targetUserMessageId) if (!targetSnapshot) { return { snapshots, preview: { available: false, reason: 'No file checkpoint is available for the selected message.', filesChanged: [], insertions: 0, deletions: 0, }, } } const trackedPaths = collectTrackedPaths(snapshots) const filesChanged: string[] = [] let insertions = 0 let deletions = 0 for (const trackingPath of trackedPaths) { const backupFileName = getBackupFileNameForTarget( trackingPath, snapshots, targetSnapshot, ) if (backupFileName === undefined) continue const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath) if (backupFileName === null) { const currentContent = await readFileOrNull(absolutePath) if (currentContent !== null) { filesChanged.push(absolutePath) insertions += countInsertedLines(currentContent) } continue } const backupFilePath = resolveBackupPath(sessionId, backupFileName) if (!(await hasFileChanged(absolutePath, backupFilePath))) { continue } filesChanged.push(absolutePath) const [currentContent, backupContent] = await Promise.all([ readFileOrNull(absolutePath), readFileOrNull(backupFilePath), ]) for (const change of diffLines(currentContent ?? '', backupContent ?? '')) { if (change.added) { insertions += change.count || 0 } if (change.removed) { deletions += change.count || 0 } } } return { snapshots, preview: normalizeDiffStats({ filesChanged, insertions, deletions, }), } } export async function previewSessionRewind( sessionId: string, selector: RewindTargetSelector, ): Promise { const target = await resolveRewindTarget(sessionId, selector) const workDir = await resolveSessionWorkDir(sessionId) const checkpointBaseDir = await resolveCheckpointBaseDir( sessionId, target.targetUserMessageId, workDir, ) const { preview } = await buildCodePreview( sessionId, checkpointBaseDir, target.targetUserMessageId, ) return { target: { targetUserMessageId: target.targetUserMessageId, userMessageIndex: target.userMessageIndex, userMessageCount: target.userMessageCount, }, conversation: { messagesRemoved: target.messagesRemoved, }, code: preview, } } export async function listSessionTurnCheckpoints( sessionId: string, ): Promise { const activeMessages = await sessionService.getSessionMessages(sessionId) const userMessages = activeMessages.filter((message) => message.type === 'user') if (userMessages.length === 0) { return [] } const workDir = await resolveSessionWorkDir(sessionId) const snapshots = await loadFileHistorySnapshots(sessionId) const checkpoints: SessionTurnCheckpointPreview[] = [] for (const [userMessageIndex, userMessage] of userMessages.entries()) { const activeMessageIndex = activeMessages.findIndex( (message) => message.id === userMessage.id, ) if (activeMessageIndex < 0) continue if (!hasCompletedTurn(activeMessages, userMessage.id)) continue const target: RewindTarget = { targetUserMessageId: userMessage.id, userMessageIndex, userMessageCount: userMessages.length, messagesRemoved: activeMessages.length - activeMessageIndex, } const checkpointBaseDir = await resolveCheckpointBaseDir( sessionId, target.targetUserMessageId, workDir, ) const targetSnapshot = snapshots ? findTargetSnapshot(snapshots, target.targetUserMessageId) : null const nextUserMessageId = getNextUserMessageId(userMessages, userMessageIndex) const nextSnapshot = nextUserMessageId && snapshots ? findTargetSnapshot(snapshots, nextUserMessageId) : null const checkpointPreview = targetSnapshot ? await buildTurnCodePreview(sessionId, checkpointBaseDir, targetSnapshot, nextSnapshot) : null const preview = checkpointPreview?.available && checkpointPreview.filesChanged.length > 0 ? checkpointPreview : buildTranscriptTurnCodePreview( activeMessages, target.targetUserMessageId, checkpointBaseDir, ) if (!preview.available || preview.filesChanged.length === 0) continue checkpoints.push(buildTurnPreview(target, preview, checkpointBaseDir)) } return checkpoints } export async function getSessionTurnCheckpointDiff( sessionId: string, selector: RewindTargetSelector, requestedPath: string, ): Promise { const target = await resolveRewindTarget(sessionId, selector) const workDir = await resolveSessionWorkDir(sessionId) const checkpointBaseDir = await resolveCheckpointBaseDir( sessionId, target.targetUserMessageId, workDir, ) const activeMessages = await sessionService.getSessionMessages(sessionId) const snapshots = await loadFileHistorySnapshots(sessionId) const missingResult = { target: buildTurnPreview( target, { available: false, filesChanged: [], insertions: 0, deletions: 0, }, checkpointBaseDir, ).target, workDir: checkpointBaseDir, path: normalizeComparablePath(requestedPath), state: 'missing' as const, } const transcriptChange = findTranscriptTurnDiff( activeMessages, target.targetUserMessageId, checkpointBaseDir, requestedPath, ) const transcriptResult = transcriptChange?.diff ? { target: missingResult.target, workDir: checkpointBaseDir, path: transcriptChange.path, state: 'ok' as const, diff: transcriptChange.diff, } : null if (!snapshots) { return transcriptResult ?? missingResult } const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId) if (!targetSnapshot) { return transcriptResult ?? missingResult } const userMessages = activeMessages.filter((message) => message.type === 'user') const nextUserMessageId = getNextUserMessageId(userMessages, target.userMessageIndex) const nextSnapshot = nextUserMessageId ? findTargetSnapshot(snapshots, nextUserMessageId) : null for (const trackingPath of new Set([ ...Object.keys(targetSnapshot.trackedFileBackups), ...Object.keys(nextSnapshot?.trackedFileBackups ?? {}), ])) { if (!matchesCheckpointPath(requestedPath, trackingPath, checkpointBaseDir)) { continue } const displayPath = toCheckpointResponsePath(trackingPath, checkpointBaseDir) try { const { beforeContent, afterContent } = await getTurnBoundaryContents( sessionId, checkpointBaseDir, trackingPath, targetSnapshot, nextSnapshot, ) if (beforeContent === afterContent) { return { ...missingResult, path: displayPath, } } return { target: missingResult.target, workDir: checkpointBaseDir, path: displayPath, state: 'ok', diff: buildCheckpointDiff( displayPath, beforeContent ?? '', afterContent ?? '', beforeContent !== null, afterContent !== null, ), } } catch (error) { return { target: missingResult.target, workDir: checkpointBaseDir, path: displayPath, state: 'error', error: error instanceof Error ? error.message : String(error), } } } return transcriptResult ?? missingResult } export async function executeSessionRewind( sessionId: string, selector: RewindTargetSelector, ): Promise { const target = await resolveRewindTarget(sessionId, selector) const workDir = await resolveSessionWorkDir(sessionId) const checkpointBaseDir = await resolveCheckpointBaseDir( sessionId, target.targetUserMessageId, workDir, ) const { snapshots, preview } = await buildCodePreview( sessionId, checkpointBaseDir, target.targetUserMessageId, ) if (conversationService.hasSession(sessionId)) { conversationService.stopSession(sessionId) } if (preview.available && snapshots) { const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId) if (!targetSnapshot) { throw ApiError.badRequest('No file checkpoint is available for the selected message.') } for (const trackingPath of collectTrackedPaths(snapshots)) { const backupFileName = getBackupFileNameForTarget( trackingPath, snapshots, targetSnapshot, ) if (backupFileName === undefined) continue const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath) if (backupFileName === null) { try { await unlink(absolutePath) } catch (error) { const maybeErr = error as NodeJS.ErrnoException if (maybeErr.code !== 'ENOENT') throw error } continue } await restoreBackupFile( absolutePath, resolveBackupPath(sessionId, backupFileName), ) } } const trimResult = await sessionService.trimSessionMessagesFrom( sessionId, target.targetUserMessageId, ) return { target: { targetUserMessageId: target.targetUserMessageId, userMessageIndex: target.userMessageIndex, userMessageCount: target.userMessageCount, }, conversation: { messagesRemoved: trimResult.removedCount, removedMessageIds: trimResult.removedMessageIds, }, code: preview, } }