cc-haha/src/server/services/sessionRewindService.ts
程序员阿江(Relakkes) 103a6cc42b fix: restore per-turn changed file cards
The chat timeline lost per-turn changed file cards when the visible user message ids did not match persisted transcript ids, and sessions without SDK file-history snapshots had no fallback source for turn changes. Match live cards by stable user-message index when needed and derive checkpoint previews from transcript tool calls when snapshots are absent.

Constraint: Right-side workspace changes already derive from transcript tool calls, so chat checkpoint data must remain consistent with that source.
Rejected: Force a history reload after every turn | would hide the id mismatch but add churn and still not cover transcript-only sessions.
Confidence: high
Scope-risk: moderate
Directive: Keep snapshot checkpoints as the preferred source, and use transcript extraction only as the compatibility fallback.
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: bun test src/server/__tests__/workspace-service.test.ts
Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test
Tested: git diff --check
Not-tested: Browser E2E against a live model session.
2026-05-01 21:43:17 +08:00

1155 lines
33 KiB
TypeScript

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<string, unknown>
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<RewindTarget> {
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<FileHistorySnapshot[] | null> {
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<string> {
const trackedPaths = new Set<string>()
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<string> {
return (
(conversationService.hasSession(sessionId)
? conversationService.getSessionWorkDir(sessionId)
: null) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
process.cwd()
)
}
async function resolveCheckpointBaseDir(
sessionId: string,
targetUserMessageId: string,
fallbackWorkDir?: string,
): Promise<string> {
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<string | null> {
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<string | null | undefined> {
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<ReturnType<typeof sessionService.getSessionMessages>>,
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<ReturnType<typeof sessionService.getSessionMessages>>,
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<ReturnType<typeof sessionService.getSessionMessages>>,
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<string, unknown>,
): 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<string, unknown>,
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<string, unknown> => !!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<string> {
const ids = new Set<string>()
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<string, unknown>
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<string, TranscriptFileChange>()
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<string, unknown>
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<string, unknown>,
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<RewindCodePreview> {
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<boolean> {
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<void> {
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<SessionRewindPreview> {
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<SessionTurnCheckpointPreview[]> {
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<SessionTurnCheckpointDiffResult> {
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<SessionRewindExecuteResult> {
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,
}
}