agent-ecosystem/src/main/services/team/ChangeExtractorService.ts
iliya 190cafdb8e feat: implement diff view with 4 phases — review, accept/reject, task scoping, enhanced UX
Phase 1: Core diff extraction and display
- ChangeExtractorService: JSONL streaming parser with snippet extraction
- FileContentResolver: 3-level content resolution (file-history → snippets → disk)
- ReviewApplierService: hunk-level accept/reject with conflict detection
- CodeMirrorDiffView: unified merge view with syntax highlighting
- ReviewFileTree: file browser with status indicators
- changeReviewSlice: Zustand state for review workflow

Phase 2: Interactive review with accept/reject
- Per-hunk and per-file accept/reject decisions
- Conflict checking before apply
- ReviewToolbar with bulk actions
- DiffErrorBoundary for graceful degradation

Phase 3: Per-task change scoping
- TaskBoundaryParser: detects task boundaries in JSONL (Tier 1-4 confidence)
- TaskChangeSetV2 with scope + warnings
- ConfidenceBadge and ScopeWarningBanner components

Phase 4: Enhanced features
- Keyboard navigation (j/k/n/p/a/x shortcuts via useDiffNavigation)
- Viewed file tracking (localStorage + useViewedFiles hook)
- File edit timeline (chronological events per file)
- Git fallback (GitDiffFallback service for incomplete JSONL data)
- Auto-viewed detection (IntersectionObserver sentinel)
2026-02-24 23:39:41 +02:00

581 lines
20 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 { createLogger } from '@shared/utils/logger';
import { diffLines } from 'diff';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import * as readline from 'readline';
import type { TaskBoundaryParser } from './TaskBoundaryParser';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type {
AgentChangeSet,
ChangeStats,
FileChangeSummary,
FileEditEvent,
FileEditTimeline,
MemberLogSummary,
SnippetDiff,
TaskChangeScope,
TaskChangeSetV2,
} from '@shared/types';
const logger = createLogger('Service:ChangeExtractorService');
/** Кеш-запись: данные + mtime файла + время протухания */
interface CacheEntry {
data: AgentChangeSet;
mtime: number;
expiresAt: number;
}
/** Ссылка на JSONL файл с привязкой к memberName */
interface LogFileRef {
filePath: string;
memberName: string;
}
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly boundaryParser: TaskBoundaryParser
) {}
/** Получить все изменения агента */
async getAgentChanges(teamName: string, memberName: string): Promise<AgentChangeSet> {
const cacheKey = `${teamName}:${memberName}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
// Собираем все snippets из всех JSONL файлов
const allSnippets: SnippetDiff[] = [];
let latestMtime = 0;
for (const filePath of paths) {
try {
const fileStat = await stat(filePath);
if (fileStat.mtimeMs > latestMtime) {
latestMtime = fileStat.mtimeMs;
}
} catch {
// Файл может быть удалён между обнаружением и чтением
}
const snippets = await this.parseJSONLFile(filePath);
allSnippets.push(...snippets);
}
const files = this.aggregateByFile(allSnippets);
let totalLinesAdded = 0;
let totalLinesRemoved = 0;
for (const file of files) {
totalLinesAdded += file.linesAdded;
totalLinesRemoved += file.linesRemoved;
}
const result: AgentChangeSet = {
teamName,
memberName,
files,
totalLinesAdded,
totalLinesRemoved,
totalFiles: files.length,
computedAt: new Date().toISOString(),
};
this.cache.set(cacheKey, {
data: result,
mtime: latestMtime,
expiresAt: Date.now() + this.CACHE_TTL,
});
return result;
}
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
const logs = await this.logsFinder.findLogsForTask(teamName, taskId);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
if (logRefs.length === 0) {
return this.emptyTaskChangeSet(teamName, taskId);
}
// Парсим boundaries для каждого лог-файла и ищем scope данной задачи
const allScopes: TaskChangeScope[] = [];
for (const ref of logRefs) {
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
const scope = boundaries.scopes.find((s) => s.taskId === taskId);
if (scope) {
allScopes.push({ ...scope, memberName: ref.memberName });
}
}
// Если scope не найден — fallback на весь файл
if (allScopes.length === 0) {
return this.fallbackSingleTaskScope(teamName, taskId, logRefs);
}
// Фильтруем snippets по tool_use IDs из scope
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds);
const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
const warnings: string[] = [];
if (worstTier >= 3) {
warnings.push('Some task boundaries could not be precisely determined.');
}
return {
teamName,
taskId,
files,
totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0),
totalFiles: files.length,
confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low',
computedAt: new Date().toISOString(),
scope: allScopes[0],
warnings,
};
}
/** Получить краткую статистику */
async getChangeStats(teamName: string, memberName: string): Promise<ChangeStats> {
const changes = await this.getAgentChanges(teamName, memberName);
return {
linesAdded: changes.totalLinesAdded,
linesRemoved: changes.totalLinesRemoved,
filesChanged: changes.totalFiles,
};
}
// ---- Private methods ----
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
// Сначала считываем все записи в память для двух проходов
const entries: Record<string, unknown>[] = [];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
entries.push(JSON.parse(trimmed) as Record<string, unknown>);
} catch {
// Пропускаем невалидный JSON
}
}
rl.close();
stream.destroy();
} catch (err) {
logger.debug(`Не удалось прочитать файл ${filePath}: ${String(err)}`);
return [];
}
// Проход 1: собираем tool_use_id с ошибками
const erroredIds = this.collectErroredToolUseIds(entries);
// Проход 2: извлекаем snippets из tool_use блоков
const snippets: SnippetDiff[] = [];
// Множество уже встречавшихся файлов (для определения write-new vs write-update)
const seenFiles = new Set<string>();
for (const entry of entries) {
const role = this.extractRole(entry);
if (role !== 'assistant') continue;
const content = this.extractContent(entry);
if (!content) continue;
const timestamp =
typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString();
for (const block of content) {
if (
!block ||
typeof block !== 'object' ||
(block as Record<string, unknown>).type !== 'tool_use'
) {
continue;
}
const toolBlock = block as Record<string, unknown>;
const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : '';
// Убираем proxy_ префикс
const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName;
const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : '';
const input = toolBlock.input as Record<string, unknown> | undefined;
if (!input) continue;
const isError = erroredIds.has(toolUseId);
if (toolName === 'Edit') {
const filePath_ = typeof input.file_path === 'string' ? input.file_path : '';
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
const newString = typeof input.new_string === 'string' ? input.new_string : '';
const replaceAll = input.replace_all === true;
if (filePath_) {
seenFiles.add(filePath_);
snippets.push({
toolUseId,
filePath: filePath_,
toolName: 'Edit',
type: 'edit',
oldString,
newString,
replaceAll,
timestamp,
isError,
});
}
} else if (toolName === 'Write') {
const filePath_ = typeof input.file_path === 'string' ? input.file_path : '';
const writeContent = typeof input.content === 'string' ? input.content : '';
if (filePath_) {
const isNew = !seenFiles.has(filePath_);
seenFiles.add(filePath_);
snippets.push({
toolUseId,
filePath: filePath_,
toolName: 'Write',
type: isNew ? 'write-new' : 'write-update',
oldString: '',
newString: writeContent,
replaceAll: false,
timestamp,
isError,
});
}
} else if (toolName === 'MultiEdit') {
const filePath_ = typeof input.file_path === 'string' ? input.file_path : '';
const edits = Array.isArray(input.edits) ? input.edits : [];
if (filePath_) {
seenFiles.add(filePath_);
for (const edit of edits) {
if (!edit || typeof edit !== 'object') continue;
const editObj = edit as Record<string, unknown>;
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
snippets.push({
toolUseId,
filePath: filePath_,
toolName: 'MultiEdit',
type: 'multi-edit',
oldString,
newString,
replaceAll: false,
timestamp,
isError,
});
}
}
}
// Остальные инструменты (NotebookEdit и пр.) пропускаем
}
}
return snippets;
}
/** Извлечь content array из JSONL entry (оба формата: subagent и main) */
private extractContent(entry: Record<string, unknown>): unknown[] | null {
const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) return message.content as unknown[];
if (Array.isArray(entry.content)) return entry.content as unknown[];
return null;
}
/** Извлечь роль из JSONL entry */
private extractRole(entry: Record<string, unknown>): string | null {
if (typeof entry.role === 'string') return entry.role;
const message = entry.message as Record<string, unknown> | undefined;
if (message && typeof message.role === 'string') return message.role;
return null;
}
/** Собрать errored tool_use_ids из tool_result блоков */
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
const erroredIds = new Set<string>();
for (const entry of entries) {
// tool_result может находиться в entry.content (когда это массив)
if (Array.isArray(entry.content)) {
for (const block of entry.content) {
if (this.isErroredToolResult(block)) {
const toolUseId = (block as Record<string, unknown>).tool_use_id;
if (typeof toolUseId === 'string') {
erroredIds.add(toolUseId);
}
}
}
}
// Также проверяем entry.message.content
const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) {
for (const block of message.content) {
if (this.isErroredToolResult(block)) {
const toolUseId = (block as Record<string, unknown>).tool_use_id;
if (typeof toolUseId === 'string') {
erroredIds.add(toolUseId);
}
}
}
}
}
return erroredIds;
}
/** Проверить, является ли блок tool_result с ошибкой */
private isErroredToolResult(block: unknown): boolean {
if (!block || typeof block !== 'object') return false;
const obj = block as Record<string, unknown>;
return obj.type === 'tool_result' && obj.is_error === true;
}
/** Агрегировать snippets в FileChangeSummary[] */
private aggregateByFile(snippets: SnippetDiff[], projectPath?: string): FileChangeSummary[] {
const fileMap = new Map<string, { snippets: SnippetDiff[]; isNewFile: boolean }>();
for (const snippet of snippets) {
// Пропускаем snippets с ошибками при агрегации
if (snippet.isError) continue;
const existing = fileMap.get(snippet.filePath);
if (existing) {
existing.snippets.push(snippet);
} else {
fileMap.set(snippet.filePath, {
snippets: [snippet],
isNewFile: snippet.type === 'write-new',
});
}
}
return [...fileMap.entries()].map(([fp, data]) => {
let totalAdded = 0;
let totalRemoved = 0;
for (const s of data.snippets) {
if (s.isError) continue;
const { added, removed } = this.countLines(s.oldString, s.newString);
totalAdded += added;
totalRemoved += removed;
}
return {
filePath: fp,
relativePath: projectPath
? fp.replace(projectPath + '/', '')
: fp.split('/').slice(-3).join('/'),
snippets: data.snippets,
linesAdded: totalAdded,
linesRemoved: totalRemoved,
isNewFile: data.isNewFile,
timeline: this.buildTimeline(fp, data.snippets),
};
});
}
/** Build edit timeline from snippets */
private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline {
const events: FileEditEvent[] = snippets
.filter((s) => !s.isError)
.map((s, idx) => ({
toolUseId: s.toolUseId,
toolName: s.toolName as FileEditEvent['toolName'],
timestamp: s.timestamp,
summary: this.generateEditSummary(s),
linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length),
linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length),
snippetIndex: idx,
}));
const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t));
const durationMs =
timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0;
return { filePath, events, durationMs };
}
private generateEditSummary(snippet: SnippetDiff): string {
switch (snippet.type) {
case 'write-new':
return 'Created new file';
case 'write-update':
return 'Wrote full file content';
case 'multi-edit': {
const lines = snippet.oldString.split('\n').length;
return `Multi-edit (${lines} line${lines !== 1 ? 's' : ''})`;
}
case 'edit': {
const added = snippet.newString.split('\n').length;
const removed = snippet.oldString.split('\n').length;
if (removed === 0 || snippet.oldString === '')
return `Added ${added} line${added !== 1 ? 's' : ''}`;
if (added === 0 || snippet.newString === '')
return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
return `Changed ${removed}${added} lines`;
}
default:
return 'File modified';
}
}
/** Подсчёт добавленных/удалённых строк через diff */
private countLines(oldStr: string, newStr: string): { added: number; removed: number } {
if (!oldStr && !newStr) return { added: 0, removed: 0 };
const changes = diffLines(oldStr, newStr);
let added = 0;
let removed = 0;
for (const c of changes) {
if (c.added) added += c.count ?? 0;
if (c.removed) removed += c.count ?? 0;
}
return { added, removed };
}
/** Проверить, содержит ли путь к файлу один из sessionId */
private pathMatchesAnySession(filePath: string, sessionIds: Set<string>): boolean {
for (const sessionId of sessionIds) {
if (filePath.includes(sessionId)) return true;
}
return false;
}
/** Конвертировать MemberLogSummary[] в LogFileRef[] через findMemberLogPaths */
private async resolveLogFileRefs(
teamName: string,
logs: MemberLogSummary[]
): Promise<LogFileRef[]> {
const refs: LogFileRef[] = [];
const byMember = new Map<string, MemberLogSummary[]>();
for (const log of logs) {
const name = log.memberName ?? 'unknown';
if (!byMember.has(name)) byMember.set(name, []);
byMember.get(name)!.push(log);
}
for (const [memberName, memberLogs] of byMember) {
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
for (const log of memberLogs) {
const matchedPath = paths.find((p) =>
log.kind === 'subagent'
? p.includes(log.sessionId) && p.includes(log.subagentId)
: p.includes(log.sessionId) && p.endsWith('.jsonl')
);
if (matchedPath) {
refs.push({ filePath: matchedPath, memberName });
}
}
}
return refs;
}
/** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */
private async extractFilteredChanges(
logRefs: LogFileRef[],
allowedToolUseIds: Set<string>
): Promise<FileChangeSummary[]> {
const allSnippets: SnippetDiff[] = [];
for (const ref of logRefs) {
const snippets = await this.parseJSONLFile(ref.filePath);
if (allowedToolUseIds.size > 0) {
// Фильтруем только по разрешённым tool_use IDs
for (const s of snippets) {
if (allowedToolUseIds.has(s.toolUseId)) {
allSnippets.push(s);
}
}
} else {
allSnippets.push(...snippets);
}
}
return this.aggregateByFile(allSnippets);
}
/** Извлечь все изменения из одного файла */
private async extractAllChanges(
filePath: string,
_memberName: string
): Promise<FileChangeSummary[]> {
const snippets = await this.parseJSONLFile(filePath);
return this.aggregateByFile(snippets);
}
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
logRefs: LogFileRef[]
): Promise<TaskChangeSetV2> {
const allFiles: FileChangeSummary[] = [];
for (const ref of logRefs) {
const files = await this.extractAllChanges(ref.filePath, ref.memberName);
allFiles.push(...files);
}
const fallbackScope: TaskChangeScope = {
taskId,
memberName: logRefs[0]?.memberName ?? 'unknown',
startLine: 1,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: allFiles.map((f) => f.filePath),
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
};
return {
teamName,
taskId,
files: allFiles,
totalLinesAdded: allFiles.reduce((sum, f) => sum + f.linesAdded, 0),
totalLinesRemoved: allFiles.reduce((sum, f) => sum + f.linesRemoved, 0),
totalFiles: allFiles.length,
confidence: 'fallback',
computedAt: new Date().toISOString(),
scope: fallbackScope,
warnings: ['No task boundaries found — showing all changes from related sessions.'],
};
}
/** Пустой TaskChangeSetV2 */
private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 {
return {
teamName,
taskId,
files: [],
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: 0,
confidence: 'fallback',
computedAt: new Date().toISOString(),
scope: {
taskId,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: ['No log files found for this task.'],
};
}
}