agent-ecosystem/src/main/services/team/ChangeExtractorService.ts
iliya 2ceed41e00 fix: resolve all CI lint errors and flaky test
- Fix React hooks violations: ref updates during render (useDraftPersistence,
  useChipDraftPersistence, useAttachments), setState in effects across 15+
  components, useCallback self-reference TDZ in useResizableColumns
- Fix TypeScript lint: remove unnecessary type assertions, replace inline
  import() annotations with direct imports, remove unused variables/imports
- Fix SonarJS issues: prefer-regexp-exec, slow-regex in SubagentResolver,
  no-misleading-array-reverse in TeamProvisioningService, use-type-alias
  in ClaudeLogsSection, variable shadowing in ChangeExtractorService
- Fix accessibility: associate labels with controls in filter popovers
- Fix template expression safety: wrap unknown errors with String()
- Fix flaky FileWatcher test: floor instanceCreatedAt to second granularity
  to match filesystem birthtimeMs resolution on Linux
- Replace TODO comments with NOTE where features are intentionally disabled
- Remove unused leadContextByTeam from TeamDetailView store selector

62 files changed across main process, renderer, shared types, and hooks.
All 1646 tests pass, typecheck clean, 0 lint errors.
2026-03-05 21:09:45 +02:00

811 lines
28 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 { getTasksBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import { readFile, stat } from 'fs/promises';
import * as path from 'path';
import * as readline from 'readline';
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,
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 cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly boundaryParser: TaskBoundaryParser,
private readonly configReader: TeamConfigReader = new TeamConfigReader()
) {}
/** Получить все изменения агента */
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);
const projectPath = await this.resolveProjectPath(teamName);
// Собираем все 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, 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;
}
): Promise<TaskChangeSetV2> {
const taskMeta = await this.readTaskMeta(teamName, taskId);
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, {
owner: options?.owner ?? taskMeta?.owner,
status: options?.status ?? taskMeta?.status,
intervals: options?.intervals ?? taskMeta?.intervals,
since: options?.since,
});
const logRefs = await this.resolveLogFileRefs(teamName, logs);
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 = options?.intervals ?? taskMeta?.intervals;
if (Array.isArray(intervals) && intervals.length > 0) {
const { files, toolUseIds, startTimestamp, endTimestamp } =
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath);
const intervalScope: TaskChangeScope = {
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)',
},
};
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: intervalScope,
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);
}
// Фильтруем snippets по tool_use IDs из scope
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds, projectPath);
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 ----
/** 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 }[];
} | null> {
try {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const raw = await readFile(taskPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
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<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
: undefined;
const derivedIntervals = (() => {
if (Array.isArray(intervals) && intervals.length > 0) return intervals;
const rawHistory = parsed.statusHistory;
if (!Array.isArray(rawHistory)) return undefined;
const transitions = rawHistory
.map((h) => (h && typeof h === 'object' ? (h as Record<string, unknown>) : null))
.filter((h): h is Record<string, unknown> => h !== null)
.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,
};
} catch {
return null;
}
}
/** Получить projectPath из конфига команды */
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
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
): 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 allowedSnippets: SnippetDiff[] = [];
const toolUseIdsSet = new Set<string>();
for (const ref of logRefs) {
const snippets = await this.parseJSONLFile(ref.filePath);
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(allowedSnippets, projectPath);
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);
}
/** Парсить один 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 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(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 isNew = !seenFiles.has(targetPath);
seenFiles.add(targetPath);
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(targetPath);
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: targetPath,
toolName: 'MultiEdit',
type: 'multi-edit',
oldString,
newString,
replaceAll: false,
timestamp,
isError,
contextHash: this.computeContextHash(oldString, newString),
});
}
}
}
// Остальные инструменты (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 } = 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: 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) => {
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<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>,
projectPath?: 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, projectPath);
}
/** Извлечь все изменения из одного файла */
private async extractAllChanges(
filePath: string,
_memberName: string,
projectPath?: string
): Promise<FileChangeSummary[]> {
const snippets = await this.parseJSONLFile(filePath);
return this.aggregateByFile(snippets, projectPath);
}
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
logRefs: LogFileRef[],
projectPath?: string
): Promise<TaskChangeSetV2> {
const allFiles: FileChangeSummary[] = [];
for (const ref of logRefs) {
const files = await this.extractAllChanges(ref.filePath, ref.memberName, projectPath);
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.'],
};
}
}