import { createLogger } from '@shared/utils/logger'; import { createReadStream } from 'fs'; import { access, readFile } from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; import type { GitDiffFallback } from './GitDiffFallback'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { FileChangeWithContent, SnippetDiff } from '@shared/types'; const logger = createLogger('Service:FileContentResolver'); /** Кеш-запись для resolved content */ interface ContentCacheEntry { original: string | null; modified: string | null; source: FileChangeWithContent['contentSource']; expiresAt: number; } /** * Resolves full file contents (original + modified) for CodeMirror diff view. * * Uses three-level resolution strategy: * 1. File-history backup (most accurate) * 2. Snippet reconstruction (reverse-apply edits from current disk state) * 3. Fallback to current file on disk */ export class FileContentResolver { private cache = new Map(); private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин (same as ChangeExtractorService) constructor( private readonly logsFinder: TeamMemberLogsFinder, private readonly gitFallback?: GitDiffFallback ) {} /** * Resolve full file contents for a single file. * Returns original (before changes) and modified (after changes) content. */ async resolveFileContent( teamName: string, memberName: string, filePath: string, snippets: SnippetDiff[] ): Promise<{ original: string | null; modified: string | null; source: FileChangeWithContent['contentSource']; }> { const cacheKey = `${teamName}:${memberName}:${filePath}`; const cached = this.cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return { original: cached.original, modified: cached.modified, source: cached.source }; } // Read current file from disk (= modified state after agent's changes) let currentContent: string | null = null; try { currentContent = await readFile(filePath, 'utf8'); } catch { logger.debug(`Файл недоступен на диске: ${filePath}`); } // Strategy 1: Try file-history backup const historyResult = await this.tryFileHistoryBackup(teamName, memberName, filePath); if (historyResult) { const result = { original: historyResult, modified: currentContent, source: 'file-history' as const, }; this.cacheResult(cacheKey, result); return result; } // Strategy 2: Try snippet reconstruction const reconstructed = this.trySnippetReconstruction(currentContent, snippets); if (reconstructed !== null) { const result = { original: reconstructed, modified: currentContent, source: 'snippet-reconstruction' as const, }; this.cacheResult(cacheKey, result); return result; } // Strategy 3 (Phase 4): Git fallback if (this.gitFallback) { const gitResult = await this.tryGitFallback(filePath, currentContent, snippets); if (gitResult) { const result = { original: gitResult, modified: currentContent, source: 'git-fallback' as const, }; this.cacheResult(cacheKey, result); return result; } } // Strategy 4: Fallback — only current file on disk if (currentContent !== null) { const result = { original: null, modified: currentContent, source: 'disk-current' as const, }; this.cacheResult(cacheKey, result); return result; } // Nothing available return { original: null, modified: null, source: 'unavailable' }; } /** * Get full file content for a single file (IPC-facing method). * Returns a FileChangeWithContent object ready for the renderer. */ async getFileContent( teamName: string, memberName: string, filePath: string ): Promise { const resolved = await this.resolveFileContent(teamName, memberName, filePath, []); return { filePath, relativePath: filePath.split('/').slice(-3).join('/'), snippets: [], linesAdded: 0, linesRemoved: 0, isNewFile: false, originalFullContent: resolved.original, modifiedFullContent: resolved.modified, contentSource: resolved.source, }; } /** * Resolve full contents for multiple files at once. * Returns a map of filePath -> FileChangeWithContent. */ async resolveAllFileContents( teamName: string, memberName: string, files: { filePath: string; relativePath: string; snippets: SnippetDiff[]; linesAdded: number; linesRemoved: number; isNewFile: boolean; }[] ): Promise> { const results = new Map(); // Resolve all files in parallel const promises = files.map(async (file) => { const resolved = await this.resolveFileContent( teamName, memberName, file.filePath, file.snippets ); const entry: FileChangeWithContent = { filePath: file.filePath, relativePath: file.relativePath, snippets: file.snippets, linesAdded: file.linesAdded, linesRemoved: file.linesRemoved, isNewFile: file.isNewFile, originalFullContent: resolved.original, modifiedFullContent: resolved.modified, contentSource: resolved.source, }; results.set(file.filePath, entry); }); await Promise.all(promises); return results; } // ── Private: Resolution strategies ── /** * Strategy 1: Read original content from Claude's file-history backup. * * Claude saves file snapshots at `~/.claude/file-history/{sessionId}/{backupFileName}`. * The mapping is stored as `type: "file-history-snapshot"` entries in JSONL. */ private async tryFileHistoryBackup( teamName: string, memberName: string, filePath: string ): Promise { let logPaths: string[]; try { logPaths = await this.logsFinder.findMemberLogPaths(teamName, memberName); } catch { return null; } if (logPaths.length === 0) return null; for (const logPath of logPaths) { const sessionId = this.extractSessionId(logPath); if (!sessionId) continue; const backupFileName = await this.findFileHistoryBackup(logPath, filePath); if (!backupFileName) continue; // Construct the file-history path const homeDir = process.env.HOME || process.env.USERPROFILE || ''; const historyPath = path.join(homeDir, '.claude', 'file-history', sessionId, backupFileName); try { await access(historyPath); const content = await readFile(historyPath, 'utf8'); logger.debug(`File-history backup найден: ${historyPath}`); return content; } catch { // Backup file doesn't exist, try next log continue; } } return null; } /** * Extract sessionId from a JSONL log path. * * Paths can be: * - `~/.claude/projects/{encodedPath}/{sessionId}.jsonl` (lead session) * - `~/.claude/projects/{encodedPath}/{sessionId}/subagents/agent-{id}.jsonl` (subagent) * * For lead sessions, sessionId = filename without extension. * For subagents, sessionId = the parent directory's parent name. */ private extractSessionId(logPath: string): string | null { const parts = logPath.split(path.sep); // Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl const subagentsIdx = parts.indexOf('subagents'); if (subagentsIdx > 0) { return parts[subagentsIdx - 1] || null; } // Lead session: .../{sessionId}.jsonl const fileName = parts[parts.length - 1]; if (fileName?.endsWith('.jsonl')) { return fileName.replace('.jsonl', ''); } return null; } /** * Stream a JSONL file looking for file-history-snapshot entries that reference the target file. * Returns the backup file name if found. */ private async findFileHistoryBackup( logPath: string, targetFilePath: string ): Promise { try { const stream = createReadStream(logPath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; // Quick check before JSON parse if (!trimmed.includes('file-history-snapshot')) continue; try { const entry = JSON.parse(trimmed) as Record; if (entry.type !== 'file-history-snapshot') continue; const snapshot = entry.snapshot as Record | undefined; if (!snapshot) continue; const trackedFileBackups = snapshot.trackedFileBackups as | Record | undefined; if (!trackedFileBackups) continue; const backupFileName = trackedFileBackups[targetFilePath]; if (backupFileName) { rl.close(); stream.destroy(); return backupFileName; } } catch { // Skip malformed JSON } } rl.close(); stream.destroy(); } catch { logger.debug(`Не удалось прочитать JSONL для file-history: ${logPath}`); } return null; } /** * Strategy 2: Reconstruct original content by reverse-applying snippets. * * Algorithm: * 1. Start with current file content from disk (= modified state) * 2. Sort snippets by timestamp DESCENDING (newest first) * 3. For each snippet, reverse the edit operation * 4. Result = original content before any agent changes * * Returns null if reconstruction is not possible (chain broken). */ private trySnippetReconstruction( currentContent: string | null, snippets: SnippetDiff[] ): string | null { if (!currentContent) return null; if (snippets.length === 0) return null; // Filter out errored snippets const validSnippets = snippets.filter((s) => !s.isError); if (validSnippets.length === 0) return null; // Sort by timestamp descending (reverse order to undo newest first) const sorted = [...validSnippets].sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); let content = currentContent; for (const snippet of sorted) { switch (snippet.type) { case 'write-new': { // File was created by agent -> original was empty return ''; } case 'write-update': { // Full file overwrite — can't reconstruct previous content from snippets alone return null; } case 'edit': case 'multi-edit': { if (snippet.replaceAll) { // Reverse replaceAll: replace all occurrences of newString -> oldString if (!content.includes(snippet.newString)) { // Chain broken — newString not in current content return null; } content = content.split(snippet.newString).join(snippet.oldString); } else { // Reverse single edit: replace first occurrence of newString -> oldString const idx = content.indexOf(snippet.newString); if (idx === -1) { // Chain broken — can't find the new string to reverse return null; } content = content.substring(0, idx) + snippet.oldString + content.substring(idx + snippet.newString.length); } break; } } } return content; } // ── Private: Git fallback (Phase 4) ── /** * Strategy 3 (Phase 4): Git fallback — find original content from git history. * Uses the timestamp of the first snippet to locate a commit before changes. */ private async tryGitFallback( filePath: string, _currentContent: string | null, snippets: SnippetDiff[] ): Promise { if (!this.gitFallback) return null; // Determine project path from file path (heuristic: find .git parent) const projectPath = this.guessProjectPath(filePath); if (!projectPath) return null; const isGit = await this.gitFallback.isGitRepo(projectPath); if (!isGit) return null; // Use earliest snippet timestamp to find the "before" state const timestamps = snippets .filter((s) => !s.isError && s.timestamp) .map((s) => s.timestamp) .sort((a, b) => a.localeCompare(b)); const firstTimestamp = timestamps[0]; if (!firstTimestamp) return null; const commitHash = await this.gitFallback.findCommitNearTimestamp( projectPath, filePath, firstTimestamp ); if (!commitHash) return null; const original = await this.gitFallback.getFileAtCommit(projectPath, filePath, commitHash); return original; } /** * Guess the project root path from a file path. * Simple heuristic: look for common markers (package.json, .git directory). */ private guessProjectPath(filePath: string): string | null { const parts = filePath.split('/'); // Walk up from file, looking for typical project root indicators for (let i = parts.length - 1; i >= 1; i--) { const candidate = parts.slice(0, i).join('/'); // Simple heuristic: paths with these patterns are likely project roots if (candidate.endsWith('/src') || candidate.endsWith('/lib')) { return parts.slice(0, i - 1).join('/') || null; } } // Fallback: take the first 4-5 components as project path if (parts.length > 4) { return parts.slice(0, Math.min(parts.length - 2, 5)).join('/'); } return null; } // ── Private: Cache helpers ── private cacheResult( key: string, result: { original: string | null; modified: string | null; source: FileChangeWithContent['contentSource']; } ): void { this.cache.set(key, { original: result.original, modified: result.modified, source: result.source, expiresAt: Date.now() + this.CACHE_TTL, }); } }