import { getTasksBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { getTaskChangeStateBucket, isTaskChangeSummaryCacheable, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; import { createHash } from 'crypto'; import { createReadStream } from 'fs'; import { readFile, stat } from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; import { TeamConfigReader } from './TeamConfigReader'; import { countLineChanges } from './UnifiedLineCounter'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { AgentChangeSet, ChangeStats, FileChangeSummary, FileEditEvent, FileEditTimeline, SnippetDiff, TaskChangeScope, TaskChangeSetV2, } from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); /** Кеш-запись: данные + mtime файла + время протухания */ interface CacheEntry { data: AgentChangeSet; mtime: number; expiresAt: number; } interface TaskChangeSummaryCacheEntry { data: TaskChangeSetV2; expiresAt: number; } interface ParsedSnippetsCacheEntry { data: SnippetDiff[]; mtime: number; expiresAt: number; } /** Ссылка на JSONL файл с привязкой к memberName */ interface LogFileRef { filePath: string; memberName: string; } export class ChangeExtractorService { private cache = new Map(); private taskChangeSummaryCache = new Map(); private taskChangeSummaryInFlight = new Map>(); private taskChangeSummaryVersionByTask = new Map(); private taskChangeSummaryValidationInFlight = new Set(); private parsedSnippetsCache = new Map(); private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk private readonly taskChangeSummaryCacheTtl = 60 * 1000; private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000; private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000; private readonly maxTaskChangeSummaryCacheEntries = 200; private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets private readonly isPersistedTaskChangeCacheEnabled = process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0'; constructor( private readonly logsFinder: TeamMemberLogsFinder, private readonly boundaryParser: TaskBoundaryParser, private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository() ) {} /** Получить все изменения агента */ async getAgentChanges(teamName: string, memberName: string): Promise { 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); const projectPath = await this.resolveProjectPath(teamName); // Собираем все snippets из всех JSONL файлов параллельно const parseResults = await this.parseJSONLFilesWithConcurrency(paths); let latestMtime = 0; const merged: SnippetDiff[] = []; for (const r of parseResults) { merged.push(...r.snippets); if (r.mtime > latestMtime) latestMtime = r.mtime; } const allSnippets = this.sortSnippetsChronologically(merged); const files = this.aggregateByFile(allSnippets, projectPath); 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.cacheTtl, }); return result; } /** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */ async getTaskChanges( teamName: string, taskId: string, options?: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; stateBucket?: TaskChangeStateBucket; summaryOnly?: boolean; forceFresh?: boolean; } ): Promise { const includeDetails = options?.summaryOnly !== true; const taskMeta = await this.readTaskMeta(teamName, taskId); const effectiveOptions = { owner: options?.owner ?? taskMeta?.owner, status: options?.status ?? taskMeta?.status, intervals: options?.intervals ?? taskMeta?.intervals, since: options?.since, }; const effectiveStateBucket = taskMeta ? getTaskChangeStateBucket({ status: effectiveOptions.status, reviewState: taskMeta.reviewState, historyEvents: taskMeta.historyEvents, kanbanColumn: taskMeta.kanbanColumn, }) : (options?.stateBucket ?? getTaskChangeStateBucket({ status: effectiveOptions.status, })); const summaryCacheableState = isTaskChangeSummaryCacheable(effectiveStateBucket); const shouldUseSummaryCache = !includeDetails && summaryCacheableState; if (!summaryCacheableState || options?.forceFresh === true) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true, }); } if (!shouldUseSummaryCache) { return this.computeTaskChanges(teamName, taskId, effectiveOptions, includeDetails); } const cacheKey = this.buildTaskChangeSummaryCacheKey( teamName, taskId, effectiveOptions, effectiveStateBucket ); const version = this.getTaskChangeSummaryVersion(teamName, taskId); if (options?.forceFresh !== true) { const cached = this.taskChangeSummaryCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.data; } this.taskChangeSummaryCache.delete(cacheKey); const inFlight = this.taskChangeSummaryInFlight.get(cacheKey); if (inFlight) { return inFlight; } const persisted = await this.readPersistedTaskChangeSummary( teamName, taskId, effectiveOptions, effectiveStateBucket, taskMeta ); if (persisted) { this.setTaskChangeSummaryCache(cacheKey, persisted); return persisted; } } const promise = this.computeTaskChanges(teamName, taskId, effectiveOptions, false) .then(async (result) => { if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) { return result; } this.setTaskChangeSummaryCache(cacheKey, result); await this.persistTaskChangeSummary( teamName, taskId, effectiveOptions, effectiveStateBucket, result, version ); return result; }) .finally(() => { this.taskChangeSummaryInFlight.delete(cacheKey); }); this.taskChangeSummaryInFlight.set(cacheKey, promise); return promise; } async invalidateTaskChangeSummaries( teamName: string, taskIds: string[], options?: { deletePersisted?: boolean } ): Promise { const uniqueTaskIds = [...new Set(taskIds.filter((taskId) => taskId.length > 0))]; await Promise.all( uniqueTaskIds.map(async (taskId) => { this.bumpTaskChangeSummaryVersion(teamName, taskId); for (const key of [...this.taskChangeSummaryCache.keys()]) { if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) { this.taskChangeSummaryCache.delete(key); } } for (const key of [...this.taskChangeSummaryInFlight.keys()]) { if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) { this.taskChangeSummaryInFlight.delete(key); } } if (options?.deletePersisted !== false && this.isPersistedTaskChangeCacheEnabled) { await this.taskChangeSummaryRepository.delete(teamName, taskId); } }) ); } private async computeTaskChanges( teamName: string, taskId: string, effectiveOptions: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, includeDetails: boolean ): Promise { const taskMeta = await this.readTaskMeta(teamName, taskId); const logRefs = await this.logsFinder.findLogFileRefsForTask( teamName, taskId, effectiveOptions ); if (logRefs.length === 0) { return this.emptyTaskChangeSet(teamName, taskId); } const projectPath = await this.resolveProjectPath(teamName); // Парсим 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 не найден — try deterministic interval scoping, else fallback to whole file if (allScopes.length === 0) { const intervals = effectiveOptions.intervals; if (Array.isArray(intervals) && intervals.length > 0) { const { files, toolUseIds, startTimestamp, endTimestamp } = await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails); 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: 'medium', computedAt: new Date().toISOString(), scope: { taskId, memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '', startLine: 0, endLine: 0, startTimestamp, endTimestamp, toolUseIds, filePaths: files.map((f) => f.filePath), confidence: { tier: 2, label: 'medium', reason: 'Scoped by persisted task workIntervals (timestamp-based)', }, }, warnings: files.length === 0 ? ['No file edits found within persisted workIntervals.'] : ['Task boundaries missing — scoped by workIntervals timestamps.'], }; } return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails); } const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds)); const files = await this.extractFilteredChanges( logRefs, allowedToolUseIds, projectPath, includeDetails ); const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier)); return { teamName, taskId, files, totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), totalFiles: files.length, confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', computedAt: new Date().toISOString(), scope: allScopes[0], warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [], }; } /** Получить краткую статистику */ async getChangeStats(teamName: string, memberName: string): Promise { const changes = await this.getAgentChanges(teamName, memberName); return { linesAdded: changes.totalLinesAdded, linesRemoved: changes.totalLinesRemoved, filesChanged: changes.totalFiles, }; } // ---- Private methods ---- /** Read task metadata (owner, status) from the task JSON file */ private async readTaskMeta( teamName: string, taskId: string ): Promise<{ owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; historyEvents?: unknown[]; kanbanColumn?: 'review' | 'approved'; } | null> { try { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const raw = await readFile(taskPath, 'utf8'); const parsed = JSON.parse(raw) as Record; const intervals = Array.isArray(parsed.workIntervals) ? (parsed.workIntervals as unknown[]).filter( (i): i is { startedAt: string; completedAt?: string } => Boolean(i) && typeof i === 'object' && typeof (i as Record).startedAt === 'string' && ((i as Record).completedAt === undefined || typeof (i as Record).completedAt === 'string') ) : undefined; const derivedIntervals = (() => { if (Array.isArray(intervals) && intervals.length > 0) return intervals; const rawHistory = parsed.historyEvents; if (!Array.isArray(rawHistory)) return undefined; const transitions = rawHistory .map((h) => (h && typeof h === 'object' ? (h as Record) : null)) .filter((h): h is Record => h !== null) .filter((h) => h.type === 'status_changed') .map((h) => ({ to: typeof h.to === 'string' ? h.to : null, timestamp: typeof h.timestamp === 'string' ? h.timestamp : null, })) .filter( (t): t is { to: string; timestamp: string } => t.to !== null && t.timestamp !== null ) .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); if (transitions.length === 0) return undefined; const derived: { startedAt: string; completedAt?: string }[] = []; let currentStart: string | null = null; for (const t of transitions) { if (t.to === 'in_progress') { if (!currentStart) currentStart = t.timestamp; continue; } if (currentStart) { derived.push({ startedAt: currentStart, completedAt: t.timestamp }); currentStart = null; } } if (currentStart) derived.push({ startedAt: currentStart }); return derived.length > 0 ? derived : undefined; })(); return { owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, status: typeof parsed.status === 'string' ? parsed.status : undefined, intervals: derivedIntervals, reviewState: parsed.reviewState === 'review' || parsed.reviewState === 'needsFix' || parsed.reviewState === 'approved' ? parsed.reviewState : 'none', historyEvents: Array.isArray(parsed.historyEvents) ? parsed.historyEvents : undefined, kanbanColumn: parsed.kanbanColumn === 'review' || parsed.kanbanColumn === 'approved' ? parsed.kanbanColumn : undefined, }; } catch (error) { logger.debug(`Failed to read task meta for ${teamName}/${taskId}: ${String(error)}`); return null; } } /** Получить projectPath из конфига команды */ private async resolveProjectPath(teamName: string): Promise { try { const config = await this.configReader.getConfig(teamName); return config?.projectPath?.trim() || undefined; } catch { return undefined; } } private async extractIntervalScopedChanges( logRefs: LogFileRef[], intervals: { startedAt: string; completedAt?: string }[], projectPath?: string, includeDetails = true ): Promise<{ files: FileChangeSummary[]; toolUseIds: string[]; startTimestamp: string; endTimestamp: string; }> { const normalized: { startMs: number; endMs: number | null; startedAt: string; completedAt?: string; }[] = []; for (const i of intervals) { const startMs = Date.parse(i.startedAt); if (!Number.isFinite(startMs)) continue; const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; normalized.push({ startMs, endMs, startedAt: i.startedAt, completedAt: i.completedAt }); } normalized.sort((a, b) => a.startMs - b.startMs); const startTimestamp = normalized[0]?.startedAt ?? ''; const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>((acc, it) => { if (it.endMs == null || typeof it.completedAt !== 'string') return acc; if (!acc || it.endMs > acc.endMs) return { endMs: it.endMs, endTimestamp: it.completedAt }; return acc; }, null); const endTimestamp = maxEnd?.endTimestamp ?? ''; const inAnyInterval = (ts: string): boolean => { const ms = Date.parse(ts); if (!Number.isFinite(ms)) return false; for (const it of normalized) { if (ms < it.startMs) continue; if (it.endMs == null) return true; if (ms <= it.endMs) return true; } return false; }; const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); const allowedSnippets: SnippetDiff[] = []; const toolUseIdsSet = new Set(); for (const { snippets } of allParsed) { for (const s of snippets) { if (s.isError) continue; if (!inAnyInterval(s.timestamp)) continue; allowedSnippets.push(s); if (s.toolUseId) toolUseIdsSet.add(s.toolUseId); } } const files = this.aggregateByFile( this.sortSnippetsChronologically(allowedSnippets), projectPath, includeDetails ); return { files, toolUseIds: [...toolUseIdsSet], startTimestamp, endTimestamp, }; } /** * Compute a context hash from old/newString for reliable hunk↔snippet matching. * Uses first+last 3 lines of both strings as a fingerprint. */ private computeContextHash(oldString: string, newString: string): string { const take3 = (s: string): string => { const lines = s.split('\n'); const head = lines.slice(0, 3).join('\n'); const tail = lines.length > 3 ? lines.slice(-3).join('\n') : ''; return `${head}|${tail}`; }; const raw = `${take3(oldString)}::${take3(newString)}`; // Simple hash: DJB2 variant (fast, no crypto needed) let hash = 5381; for (let i = 0; i < raw.length; i++) { hash = ((hash << 5) + hash + raw.charCodeAt(i)) | 0; } return (hash >>> 0).toString(36); } /** Deterministic sort: timestamp → filePath → toolUseId → originalIndex */ private sortSnippetsChronologically(snippets: SnippetDiff[]): SnippetDiff[] { return snippets .map((snippet, originalIndex) => ({ snippet, originalIndex })) .sort((a, b) => { const aMs = Date.parse(a.snippet.timestamp); const bMs = Date.parse(b.snippet.timestamp); const safeA = Number.isFinite(aMs) ? aMs : Number.MAX_SAFE_INTEGER; const safeB = Number.isFinite(bMs) ? bMs : Number.MAX_SAFE_INTEGER; if (safeA !== safeB) return safeA - safeB; if (a.snippet.filePath !== b.snippet.filePath) return a.snippet.filePath.localeCompare(b.snippet.filePath); if (a.snippet.toolUseId !== b.snippet.toolUseId) return a.snippet.toolUseId.localeCompare(b.snippet.toolUseId); return a.originalIndex - b.originalIndex; }) .map(({ snippet }) => snippet); } /** Parse multiple JSONL files with bounded concurrency (worker-pool) */ private static readonly JSONL_PARSE_CONCURRENCY = 6; private async parseJSONLFilesWithConcurrency( paths: string[] ): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> { if (paths.length === 0) return []; const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length); let nextIndex = 0; const worker = async (): Promise => { while (true) { const currentIndex = nextIndex++; if (currentIndex >= paths.length) return; results[currentIndex] = await this.parseJSONLFile(paths[currentIndex]); } }; await Promise.all( Array.from( { length: Math.min(ChangeExtractorService.JSONL_PARSE_CONCURRENCY, paths.length) }, () => worker() ) ); return results; } /** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */ private async parseJSONLFile( filePath: string ): Promise<{ snippets: SnippetDiff[]; mtime: number }> { let fileMtime = 0; try { const fileStat = await stat(filePath); fileMtime = fileStat.mtimeMs; const cached = this.parsedSnippetsCache.get(filePath); if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) { return { snippets: cached.data, mtime: fileMtime }; } } catch (err) { logger.debug(`Не удалось stat файла ${filePath}: ${String(err)}`); return { snippets: [], mtime: 0 }; } // Сначала считываем все записи в память для двух проходов const entries: Record[] = []; 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); } catch { // Пропускаем невалидный JSON } } rl.close(); stream.destroy(); } catch (err) { logger.debug(`Не удалось прочитать файл ${filePath}: ${String(err)}`); return { snippets: [], mtime: 0 }; } // Проход 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(); 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).type !== 'tool_use' ) { continue; } const toolBlock = block as Record; 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 | undefined; if (!input) continue; const isError = erroredIds.has(toolUseId); if (toolName === 'Edit') { const targetPath = 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 (targetPath) { seenFiles.add(this.normalizeFilePathKey(targetPath)); snippets.push({ toolUseId, filePath: targetPath, toolName: 'Edit', type: 'edit', oldString, newString, replaceAll, timestamp, isError, contextHash: this.computeContextHash(oldString, newString), }); } } else if (toolName === 'Write') { const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const writeContent = typeof input.content === 'string' ? input.content : ''; if (targetPath) { const normalizedTargetPath = this.normalizeFilePathKey(targetPath); const isNew = !seenFiles.has(normalizedTargetPath); seenFiles.add(normalizedTargetPath); snippets.push({ toolUseId, filePath: targetPath, toolName: 'Write', type: isNew ? 'write-new' : 'write-update', oldString: '', newString: writeContent, replaceAll: false, timestamp, isError, contextHash: this.computeContextHash('', writeContent), }); } } else if (toolName === 'MultiEdit') { const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const edits = Array.isArray(input.edits) ? input.edits : []; if (targetPath) { seenFiles.add(this.normalizeFilePathKey(targetPath)); for (const edit of edits) { if (!edit || typeof edit !== 'object') continue; const editObj = edit as Record; const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : ''; const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; snippets.push({ toolUseId, filePath: targetPath, toolName: 'MultiEdit', type: 'multi-edit', oldString, newString, replaceAll: false, timestamp, isError, contextHash: this.computeContextHash(oldString, newString), }); } } } // Остальные инструменты (NotebookEdit и пр.) пропускаем } } this.parsedSnippetsCache.set(filePath, { data: snippets, mtime: fileMtime, expiresAt: Date.now() + this.parsedSnippetsCacheTtl, }); return { snippets, mtime: fileMtime }; } /** Извлечь content array из JSONL entry (оба формата: subagent и main) */ private extractContent(entry: Record): unknown[] | null { const message = entry.message as Record | 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 | null { if (typeof entry.role === 'string') return entry.role; const message = entry.message as Record | undefined; if (message && typeof message.role === 'string') return message.role; return null; } /** Собрать errored tool_use_ids из tool_result блоков */ private collectErroredToolUseIds(entries: Record[]): Set { const erroredIds = new Set(); 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).tool_use_id; if (typeof toolUseId === 'string') { erroredIds.add(toolUseId); } } } } // Также проверяем entry.message.content const message = entry.message as Record | undefined; if (message && Array.isArray(message.content)) { for (const block of message.content) { if (this.isErroredToolResult(block)) { const toolUseId = (block as Record).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; return obj.type === 'tool_result' && obj.is_error === true; } /** Агрегировать snippets в FileChangeSummary[] */ private aggregateByFile( snippets: SnippetDiff[], projectPath?: string, includeDetails = true ): FileChangeSummary[] { const fileMap = new Map< string, { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } >(); for (const snippet of snippets) { // Пропускаем snippets с ошибками при агрегации if (snippet.isError) continue; const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath); const existing = fileMap.get(normalizedFilePath); if (existing) { existing.snippets.push(snippet); if (snippet.type === 'write-new') existing.isNewFile = true; } else { fileMap.set(normalizedFilePath, { filePath: snippet.filePath, snippets: [snippet], isNewFile: snippet.type === 'write-new', }); } } return [...fileMap.values()].map((data) => { const fp = data.filePath; let totalAdded = 0; let totalRemoved = 0; for (const s of data.snippets) { if (s.isError) continue; const { added, removed } = countLineChanges(s.oldString, s.newString); totalAdded += added; totalRemoved += removed; } // Normalize separators for cross-platform path stripping const normalizedFp = fp.replace(/\\/g, '/'); const normalizedProject = projectPath?.replace(/\\/g, '/'); const relative = normalizedProject ? normalizedFp.startsWith(normalizedProject + '/') ? normalizedFp.slice(normalizedProject.length + 1) : normalizedFp.startsWith(normalizedProject) ? normalizedFp.slice(normalizedProject.length) : normalizedFp.split('/').slice(-3).join('/') : normalizedFp.split('/').slice(-3).join('/'); return { filePath: fp, relativePath: relative, snippets: includeDetails ? data.snippets : [], linesAdded: totalAdded, linesRemoved: totalRemoved, isNewFile: data.isNewFile, timeline: includeDetails ? this.buildTimeline(fp, data.snippets) : undefined, }; }); } /** Build edit timeline from snippets */ private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { const events: FileEditEvent[] = snippets .filter((s) => !s.isError) .map((s, idx) => { const { added, removed } = countLineChanges(s.oldString, s.newString); return { toolUseId: s.toolUseId, toolName: s.toolName as FileEditEvent['toolName'], timestamp: s.timestamp, summary: this.generateEditSummary(s), linesAdded: added, linesRemoved: removed, 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 { added, removed } = countLineChanges(snippet.oldString, snippet.newString); const total = added + removed; return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`; } case 'edit': { const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`; if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; return `Changed ${removed} → ${added} lines`; } default: return 'File modified'; } } /** Проверить, содержит ли путь к файлу один из sessionId */ private pathMatchesAnySession(filePath: string, sessionIds: Set): boolean { for (const sessionId of sessionIds) { if (filePath.includes(sessionId)) return true; } return false; } /** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */ private async extractFilteredChanges( logRefs: LogFileRef[], allowedToolUseIds: Set, projectPath?: string, includeDetails = true ): Promise { const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); const allSnippets: SnippetDiff[] = []; for (const { snippets } of allParsed) { if (allowedToolUseIds.size > 0) { for (const s of snippets) { if (allowedToolUseIds.has(s.toolUseId)) { allSnippets.push(s); } } } else { allSnippets.push(...snippets); } } return this.aggregateByFile( this.sortSnippetsChronologically(allSnippets), projectPath, includeDetails ); } /** Извлечь все изменения из одного файла */ private async extractAllChanges( filePath: string, _memberName: string, projectPath?: string, includeDetails = true ): Promise { const { snippets } = await this.parseJSONLFile(filePath); return this.aggregateByFile(snippets, projectPath, includeDetails); } /** Fallback: вернуть все изменения из лог-файлов как Tier 4 */ private async fallbackSingleTaskScope( teamName: string, taskId: string, logRefs: LogFileRef[], projectPath?: string, includeDetails = true ): Promise { const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); const allSnippets = this.sortSnippetsChronologically(allParsed.flatMap((r) => r.snippets)); const allFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails); 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.'], }; } private buildTaskChangeSummaryCacheKey( teamName: string, taskId: string, options: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, stateBucket: TaskChangeStateBucket ): string { return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`; } private normalizeFilePathKey(filePath: string): string { const normalized = filePath.replace(/\\/g, '/'); return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); } private buildTaskSignature( options: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, stateBucket: TaskChangeStateBucket ): string { const owner = typeof options.owner === 'string' ? options.owner.trim() : ''; const status = typeof options.status === 'string' ? options.status.trim() : ''; const since = typeof options.since === 'string' ? options.since : ''; const intervals = Array.isArray(options.intervals) ? options.intervals.map((interval) => ({ startedAt: interval.startedAt, completedAt: interval.completedAt ?? '', })) : []; return JSON.stringify({ owner, status, since, stateBucket, intervals }); } private setTaskChangeSummaryCache(cacheKey: string, result: TaskChangeSetV2): void { this.pruneExpiredTaskChangeSummaryCache(); this.taskChangeSummaryCache.set(cacheKey, { data: result, expiresAt: Date.now() + (result.files.length > 0 ? this.taskChangeSummaryCacheTtl : this.emptyTaskChangeSummaryCacheTtl), }); while (this.taskChangeSummaryCache.size > this.maxTaskChangeSummaryCacheEntries) { const oldestKey = this.taskChangeSummaryCache.keys().next().value; if (!oldestKey) break; this.taskChangeSummaryCache.delete(oldestKey); } } private pruneExpiredTaskChangeSummaryCache(): void { const now = Date.now(); for (const [key, entry] of this.taskChangeSummaryCache.entries()) { if (entry.expiresAt <= now) { this.taskChangeSummaryCache.delete(key); } } } private getTaskChangeSummaryVersionKey(teamName: string, taskId: string): string { return `${teamName}:${taskId}`; } private getTaskChangeSummaryVersion(teamName: string, taskId: string): number { return ( this.taskChangeSummaryVersionByTask.get( this.getTaskChangeSummaryVersionKey(teamName, taskId) ) ?? 0 ); } private bumpTaskChangeSummaryVersion(teamName: string, taskId: string): void { const key = this.getTaskChangeSummaryVersionKey(teamName, taskId); this.taskChangeSummaryVersionByTask.set( key, this.getTaskChangeSummaryVersion(teamName, taskId) + 1 ); } private isTaskChangeSummaryCacheKeyForTask( cacheKey: string, teamName: string, taskId: string ): boolean { return cacheKey.startsWith(`${teamName}:${taskId}:`); } private async readPersistedTaskChangeSummary( teamName: string, taskId: string, effectiveOptions: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, stateBucket: TaskChangeStateBucket, taskMeta: { status?: string; reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; historyEvents?: unknown[]; kanbanColumn?: 'review' | 'approved'; } | null ): Promise { if (!this.isPersistedTaskChangeCacheEnabled) { return null; } if (!taskMeta || !isTaskChangeSummaryCacheable(stateBucket)) { await this.taskChangeSummaryRepository.delete(teamName, taskId); return null; } const currentBucket = getTaskChangeStateBucket({ status: taskMeta.status, reviewState: taskMeta.reviewState, historyEvents: taskMeta.historyEvents, kanbanColumn: taskMeta.kanbanColumn, }); if (!isTaskChangeSummaryCacheable(currentBucket)) { await this.taskChangeSummaryRepository.delete(teamName, taskId); return null; } const entry = await this.taskChangeSummaryRepository.load(teamName, taskId); if (!entry) { return null; } const projectFingerprint = await this.computeProjectFingerprint(teamName); const taskSignature = this.buildTaskSignature(effectiveOptions, currentBucket); if ( !projectFingerprint || entry.taskSignature !== taskSignature || entry.projectFingerprint !== projectFingerprint || entry.stateBucket !== currentBucket ) { logger.debug(`Rejecting persisted task-change summary for ${teamName}/${taskId}`); await this.taskChangeSummaryRepository.delete(teamName, taskId); return null; } this.schedulePersistedTaskChangeSummaryValidation( teamName, taskId, effectiveOptions, currentBucket, entry.sourceFingerprint ); return entry.summary; } private schedulePersistedTaskChangeSummaryValidation( teamName: string, taskId: string, effectiveOptions: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string ): void { const validationKey = `${teamName}:${taskId}`; if (this.taskChangeSummaryValidationInFlight.has(validationKey)) { return; } const version = this.getTaskChangeSummaryVersion(teamName, taskId); this.taskChangeSummaryValidationInFlight.add(validationKey); setTimeout(() => { void this.validatePersistedTaskChangeSummary( teamName, taskId, effectiveOptions, expectedBucket, expectedSourceFingerprint, version ) .catch((error) => { logger.debug( `Background persisted summary validation failed for ${teamName}/${taskId}: ${String(error)}` ); }) .finally(() => { this.taskChangeSummaryValidationInFlight.delete(validationKey); }); }, 0); } private async validatePersistedTaskChangeSummary( teamName: string, taskId: string, effectiveOptions: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string, version: number ): Promise { if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) { return; } const taskMeta = await this.readTaskMeta(teamName, taskId); if (!taskMeta) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true }); return; } const currentBucket = getTaskChangeStateBucket({ status: taskMeta.status ?? effectiveOptions.status, reviewState: taskMeta.reviewState, historyEvents: taskMeta.historyEvents, kanbanColumn: taskMeta.kanbanColumn, }); if (!isTaskChangeSummaryCacheable(currentBucket) || currentBucket !== expectedBucket) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true }); return; } const logRefs = await this.logsFinder.findLogFileRefsForTask( teamName, taskId, effectiveOptions ); const sourceFingerprint = await this.computeSourceFingerprint(logRefs); if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true }); } } private async persistTaskChangeSummary( teamName: string, taskId: string, effectiveOptions: { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, stateBucket: TaskChangeStateBucket, result: TaskChangeSetV2, generation: number ): Promise { if (!this.isPersistedTaskChangeCacheEnabled) return; if (!isTaskChangeSummaryCacheable(stateBucket)) return; if (result.files.length === 0) return; if (result.confidence !== 'high' && result.confidence !== 'medium') { await this.taskChangeSummaryRepository.delete(teamName, taskId); return; } if (this.getTaskChangeSummaryVersion(teamName, taskId) !== generation) { return; } const currentTaskMeta = await this.readTaskMeta(teamName, taskId); if (!currentTaskMeta) return; const currentBucket = getTaskChangeStateBucket({ status: currentTaskMeta.status ?? effectiveOptions.status, reviewState: currentTaskMeta.reviewState, historyEvents: currentTaskMeta.historyEvents, kanbanColumn: currentTaskMeta.kanbanColumn, }); if (!isTaskChangeSummaryCacheable(currentBucket)) { await this.taskChangeSummaryRepository.delete(teamName, taskId); return; } const logRefs = await this.logsFinder.findLogFileRefsForTask( teamName, taskId, effectiveOptions ); const sourceFingerprint = await this.computeSourceFingerprint(logRefs); const projectFingerprint = await this.computeProjectFingerprint(teamName); if (!sourceFingerprint || !projectFingerprint) { return; } const expiresAt = new Date(Date.now() + this.persistedTaskChangeSummaryTtl).toISOString(); await this.taskChangeSummaryRepository.save( { version: 1, teamName, taskId, stateBucket: currentBucket === 'approved' ? 'approved' : 'completed', taskSignature: this.buildTaskSignature(effectiveOptions, currentBucket), sourceFingerprint, projectFingerprint, writtenAt: new Date().toISOString(), expiresAt, extractorConfidence: result.confidence, summary: result, debugMeta: { sourceCount: logRefs.length, projectPathHash: projectFingerprint, }, }, { generation } ); } private async computeSourceFingerprint(logRefs: LogFileRef[]): Promise { if (logRefs.length === 0) return null; const parts: string[] = []; for (const ref of [...logRefs].sort((a, b) => a.filePath.localeCompare(b.filePath))) { try { const stats = await stat(ref.filePath); parts.push(`${this.normalizeFilePathKey(ref.filePath)}:${stats.size}:${stats.mtimeMs}`); } catch { return null; } } return createHash('sha256').update(parts.join('|')).digest('hex'); } private async computeProjectFingerprint(teamName: string): Promise { const projectPath = await this.resolveProjectPath(teamName); if (!projectPath) return null; return createHash('sha256').update(this.normalizeFilePathKey(projectPath)).digest('hex'); } }