agent-ecosystem/src/main/services/team/FileContentResolver.ts

699 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { normalizePathForComparison } from '@shared/utils/platformPath';
import { createHash } from 'crypto';
import { diffLines } from 'diff';
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'];
validationFingerprint: string;
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<string, ContentCacheEntry>();
private readonly provisionalCacheTtl = 5 * 1000;
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly gitFallback?: GitDiffFallback
) {}
/** Invalidate cached content for a file (e.g. after user saves edits) */
invalidateFile(filePath: string): void {
const normalizedFilePath = this.normalizeResolverPath(filePath);
for (const key of this.cache.keys()) {
if (key.endsWith(`:${normalizedFilePath}`)) {
this.cache.delete(key);
}
}
}
/**
* 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 ledgerResult = this.tryLedgerContent(snippets);
if (ledgerResult) {
return ledgerResult;
}
// 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}`);
}
const cacheKey = this.buildCacheKey(teamName, memberName, filePath);
const validationFingerprint = this.buildValidationFingerprint(
filePath,
currentContent,
snippets
);
const cached = this.cache.get(cacheKey);
if (
cached &&
cached.expiresAt > Date.now() &&
cached.validationFingerprint === validationFingerprint
) {
return { original: cached.original, modified: cached.modified, source: cached.source };
}
// Fast path: if the agent created the file and it still exists on disk,
// the original content is definitely empty, so skip expensive history lookup.
const hasWriteNew = snippets.some((s) => !s.isError && s.type === 'write-new');
if (hasWriteNew && currentContent !== null) {
const result = {
original: '',
modified: currentContent,
source: 'snippet-reconstruction' as const,
};
this.cacheResult(cacheKey, validationFingerprint, result);
return result;
}
// 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, validationFingerprint, 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, validationFingerprint, 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, validationFingerprint, 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, validationFingerprint, result);
return result;
}
// Nothing available
const unavailable = { original: null, modified: null, source: 'unavailable' as const };
this.cacheResult(cacheKey, validationFingerprint, unavailable);
return 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,
snippets: SnippetDiff[] = []
): Promise<FileChangeWithContent> {
const resolved = await this.resolveFileContent(teamName, memberName, filePath, snippets);
// Compute accurate stats from full content diff
let linesAdded = 0;
let linesRemoved = 0;
if (resolved.original !== null && resolved.modified !== null) {
const changes = diffLines(resolved.original, resolved.modified);
for (const c of changes) {
if (c.added) linesAdded += c.count ?? 0;
if (c.removed) linesRemoved += c.count ?? 0;
}
} else if (resolved.original === null && resolved.modified !== null) {
// Use diffLines for consistency with ChangeExtractorService.countLines()
const changes = diffLines('', resolved.modified);
for (const c of changes) {
if (c.added) linesAdded += c.count ?? 0;
}
}
const isNewFile = snippets.some(
(s) => s.type === 'write-new' || s.ledger?.operation === 'create'
);
return {
filePath,
relativePath: this.getDisplayRelativePath(filePath, 3),
snippets,
linesAdded,
linesRemoved,
isNewFile,
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<Map<string, FileChangeWithContent>> {
const results = new Map<string, FileChangeWithContent>();
// Resolve all files in parallel
const promises = files.map(async (file) => {
const resolved = await this.resolveFileContent(
teamName,
memberName,
file.filePath,
file.snippets
);
// Compute accurate stats from full content diff
let linesAdded = file.linesAdded;
let linesRemoved = file.linesRemoved;
if (resolved.original !== null && resolved.modified !== null) {
linesAdded = 0;
linesRemoved = 0;
const changes = diffLines(resolved.original, resolved.modified);
for (const c of changes) {
if (c.added) linesAdded += c.count ?? 0;
if (c.removed) linesRemoved += c.count ?? 0;
}
}
const entry: FileChangeWithContent = {
filePath: file.filePath,
relativePath: file.relativePath,
snippets: file.snippets,
linesAdded,
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 ──
private tryLedgerContent(snippets: SnippetDiff[]): {
original: string | null;
modified: string | null;
source: FileChangeWithContent['contentSource'];
} | null {
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
if (ledgerSnippets.length === 0) {
return null;
}
const first = ledgerSnippets[0]?.ledger;
const last = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
if (!first || !last) {
return null;
}
const canUseSyntheticOriginal =
first.originalFullContent === null &&
first.operation === 'create' &&
last.modifiedFullContent !== null &&
!first.beforeState?.unavailableReason;
const canUseSyntheticModified =
last.modifiedFullContent === null &&
last.operation === 'delete' &&
first.originalFullContent !== null &&
!last.afterState?.unavailableReason;
const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null);
const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null);
if (original === null && modified === null) {
const hasUnavailableLedgerState = ledgerSnippets.some(
(snippet) =>
snippet.ledger?.beforeState?.unavailableReason ||
snippet.ledger?.afterState?.unavailableReason ||
snippet.ledger?.textAvailability === 'unavailable'
);
if (hasUnavailableLedgerState) {
return { original: null, modified: null, source: 'unavailable' };
}
return null;
}
const hasSnapshot = ledgerSnippets.some(
(snippet) => snippet.ledger?.source === 'ledger-snapshot'
);
return {
original,
modified,
source: hasSnapshot ? 'ledger-snapshot' : 'ledger-exact',
};
}
/**
* 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<string | null> {
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 historyPath = path.join(
getHomeDir(),
'.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 = path
.normalize(logPath)
.split(/[/\\]+/)
.filter(Boolean);
// 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<string | null> {
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<string, unknown>;
if (entry.type !== 'file-history-snapshot') continue;
const snapshot = entry.snapshot as Record<string, unknown> | undefined;
if (!snapshot) continue;
const trackedFileBackups = snapshot.trackedFileBackups as
| Record<string, string>
| 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 {
// `readFile()` can legitimately return an empty string for empty files.
// Only treat `null` as "missing on disk".
if (currentContent === null) 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 'notebook-edit':
case 'shell-snapshot':
case 'hook-snapshot': {
// Snapshot/full-file changes are only safe when ledger content is available.
return null;
}
case 'edit':
case 'multi-edit': {
// Guard: empty newString means deletion — can't find position to reverse
if (!snippet.newString) return null;
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<string | null> {
if (!this.gitFallback) return null;
// Determine project path from file path (heuristic: find .git parent)
const projectPath = await 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 async guessProjectPath(filePath: string): Promise<string | null> {
const normalized = path.normalize(filePath);
let dir = path.dirname(normalized);
const parsed = path.parse(dir);
const root = parsed.root;
const markers = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml'] as const;
const hasMarker = async (candidateDir: string): Promise<boolean> => {
for (const marker of markers) {
try {
await access(path.join(candidateDir, marker));
return true;
} catch {
// ignore
}
}
return false;
};
// Walk up from file directory; prefer stable "real" roots over string heuristics.
// This keeps git fallback working on Windows (\\ separators) and with mixed separators.
const MAX_UP = 30;
for (let i = 0; i < MAX_UP; i++) {
const base = path.basename(dir);
const candidate = base === 'src' || base === 'lib' ? path.dirname(dir) : dir;
if (await hasMarker(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// Safety: if we can't confidently find a project root, don't guess.
// Returning null avoids running git in the wrong directory.
// (The resolver will still fall back to other content strategies.)
if (!root) return null;
return null;
}
private getDisplayRelativePath(filePath: string, segmentCount: number): string {
const normalized = path.normalize(filePath);
const parts = normalized.split(/[/\\]+/).filter(Boolean);
return parts.slice(-segmentCount).join('/');
}
// ── Private: Cache helpers ──
private normalizeResolverPath(filePath: string): string {
return normalizePathForComparison(filePath);
}
private buildCacheKey(teamName: string, memberName: string, filePath: string): string {
return `${teamName}:${memberName}:${this.normalizeResolverPath(filePath)}`;
}
private hashString(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
private buildDiskFingerprint(currentContent: string | null): string {
if (currentContent === null) return 'missing';
return this.hashString(`present:${currentContent}`);
}
private buildSnippetFingerprint(snippets: SnippetDiff[]): string {
const hash = createHash('sha256');
for (const snippet of snippets) {
hash.update('\u0000snippet\u0000');
hash.update(this.normalizeResolverPath(snippet.filePath));
hash.update('\u0000');
hash.update(snippet.toolUseId);
hash.update('\u0000');
hash.update(snippet.type);
hash.update('\u0000');
hash.update(snippet.oldString);
hash.update('\u0000');
hash.update(snippet.newString);
hash.update('\u0000');
hash.update(snippet.replaceAll ? '1' : '0');
hash.update('\u0000');
hash.update(snippet.timestamp);
hash.update('\u0000');
hash.update(snippet.isError ? '1' : '0');
hash.update('\u0000');
hash.update(snippet.contextHash ?? '');
}
return hash.digest('hex');
}
private buildValidationFingerprint(
filePath: string,
currentContent: string | null,
snippets: SnippetDiff[]
): string {
const normalizedPath = this.normalizeResolverPath(filePath);
const diskFingerprint = this.buildDiskFingerprint(currentContent);
const snippetFingerprint = this.buildSnippetFingerprint(snippets);
return this.hashString(`${normalizedPath}|${diskFingerprint}|${snippetFingerprint}`);
}
private getCacheTtlForSource(_source: FileChangeWithContent['contentSource']): number {
return this.provisionalCacheTtl;
}
private cacheResult(
key: string,
validationFingerprint: 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,
validationFingerprint,
expiresAt: Date.now() + this.getCacheTtlForSource(result.source),
});
}
}