mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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.
1155 lines
33 KiB
TypeScript
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,
|
|
}
|
|
}
|