913 lines
31 KiB
TypeScript
913 lines
31 KiB
TypeScript
import { createLogger } from '@shared/utils/logger';
|
||
import { getTaskChangeStateBucket } from '@shared/utils/taskChangeState';
|
||
import { createReadStream } from 'fs';
|
||
import { stat } from 'fs/promises';
|
||
import * as readline from 'readline';
|
||
|
||
import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils';
|
||
import { countLineChanges } from './UnifiedLineCounter';
|
||
|
||
import type { TaskBoundaryParser } from './TaskBoundaryParser';
|
||
import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes';
|
||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||
import type {
|
||
AgentChangeSet,
|
||
FileChangeSummary,
|
||
FileEditEvent,
|
||
FileEditTimeline,
|
||
SnippetDiff,
|
||
TaskChangeScope,
|
||
TaskChangeSetV2,
|
||
} from '@shared/types';
|
||
|
||
const logger = createLogger('Service:TaskChangeComputer');
|
||
const NO_LOG_FILES_FOUND_WARNING = 'No log files found for this task.';
|
||
|
||
interface ParsedSnippetsCacheEntry {
|
||
data: ParsedSnippetRecord[];
|
||
mtime: number;
|
||
expiresAt: number;
|
||
}
|
||
|
||
interface ParsedSnippetsResult {
|
||
snippets: ParsedSnippetRecord[];
|
||
mtime: number;
|
||
}
|
||
|
||
interface ParsedSnippetRecord {
|
||
snippet: SnippetDiff;
|
||
sourceLine: number;
|
||
}
|
||
|
||
interface LogFileRef {
|
||
filePath: string;
|
||
memberName: string;
|
||
}
|
||
|
||
interface MetadataChangePath {
|
||
filePath: string;
|
||
}
|
||
|
||
interface ParsedJsonlEntry {
|
||
entry: Record<string, unknown>;
|
||
lineNumber: number;
|
||
}
|
||
|
||
function shouldWarnAboutUnavailableTaskChangeEvidence(
|
||
input: ResolvedTaskChangeComputeInput
|
||
): boolean {
|
||
const status = input.taskMeta?.status?.trim() || input.effectiveOptions.status?.trim();
|
||
const stateBucket = getTaskChangeStateBucket({
|
||
status,
|
||
reviewState: input.taskMeta?.reviewState,
|
||
historyEvents: input.taskMeta?.historyEvents,
|
||
kanbanColumn: input.taskMeta?.kanbanColumn,
|
||
});
|
||
return stateBucket === 'completed' || stateBucket === 'review' || stateBucket === 'approved';
|
||
}
|
||
|
||
export class TaskChangeComputer {
|
||
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
|
||
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
|
||
private readonly parsedSnippetsCacheTtl = 20 * 1000;
|
||
private readonly maxParsedSnippetsCacheEntries = 1_000;
|
||
private static readonly JSONL_PARSE_CONCURRENCY = 6;
|
||
|
||
constructor(
|
||
private readonly logsFinder: TeamMemberLogsFinder,
|
||
private readonly boundaryParser: TaskBoundaryParser
|
||
) {}
|
||
|
||
async computeAgentChanges(
|
||
teamName: string,
|
||
memberName: string,
|
||
projectPath?: string
|
||
): Promise<{ result: AgentChangeSet; latestMtime: number }> {
|
||
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
|
||
const parseResults = await this.parseJSONLFilesWithConcurrency(paths);
|
||
let latestMtime = 0;
|
||
const merged: SnippetDiff[] = [];
|
||
|
||
for (const result of parseResults) {
|
||
merged.push(...result.snippets.map((record) => record.snippet));
|
||
if (result.mtime > latestMtime) {
|
||
latestMtime = result.mtime;
|
||
}
|
||
}
|
||
|
||
const files = this.aggregateByFile(this.sortSnippetsChronologically(merged), projectPath);
|
||
const taskChangeResult = {
|
||
teamName,
|
||
memberName,
|
||
files,
|
||
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
|
||
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
|
||
totalFiles: files.length,
|
||
computedAt: new Date().toISOString(),
|
||
} satisfies AgentChangeSet;
|
||
|
||
return { result: taskChangeResult, latestMtime };
|
||
}
|
||
|
||
async computeTaskChanges(input: ResolvedTaskChangeComputeInput): Promise<TaskChangeSetV2> {
|
||
const { teamName, taskId, taskMeta, effectiveOptions, projectPath, includeDetails } = input;
|
||
const logRefs = await this.logsFinder.findLogFileRefsForTask(
|
||
teamName,
|
||
taskId,
|
||
effectiveOptions
|
||
);
|
||
if (logRefs.length === 0) {
|
||
return this.emptyTaskChangeSet(input);
|
||
}
|
||
|
||
const allScopes: TaskChangeScope[] = [];
|
||
for (const ref of logRefs) {
|
||
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
|
||
const scope = boundaries.scopes.find((candidate) => candidate.taskId === taskId);
|
||
if (scope) {
|
||
allScopes.push({ ...scope, memberName: ref.memberName });
|
||
}
|
||
}
|
||
|
||
if (allScopes.length === 0) {
|
||
const intervalScoped = await this.buildIntervalScopedTaskChangeSet({
|
||
teamName,
|
||
taskId,
|
||
taskMeta,
|
||
logRefs,
|
||
intervals: effectiveOptions.intervals,
|
||
projectPath,
|
||
includeDetails,
|
||
warningWithFiles: 'Task boundaries missing - scoped by workIntervals timestamps.',
|
||
warningWithoutFiles: 'No file edits found within persisted workIntervals.',
|
||
});
|
||
if (intervalScoped) return intervalScoped;
|
||
|
||
return this.fallbackSingleTaskScope(input, logRefs);
|
||
}
|
||
|
||
const files = await this.extractScopedChanges(logRefs, allScopes, projectPath, includeDetails);
|
||
|
||
const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier));
|
||
if (worstTier >= 3) {
|
||
const intervalScoped = await this.buildIntervalScopedTaskChangeSet({
|
||
teamName,
|
||
taskId,
|
||
taskMeta,
|
||
logRefs: this.selectScopedLogRefs(logRefs, allScopes),
|
||
intervals: effectiveOptions.intervals,
|
||
projectPath,
|
||
includeDetails,
|
||
warningWithFiles:
|
||
'Task start boundary missing - scoped by persisted workIntervals timestamps.',
|
||
warningWithoutFiles: 'No file edits found within persisted workIntervals.',
|
||
});
|
||
if (intervalScoped && intervalScoped.files.length > 0) {
|
||
return intervalScoped;
|
||
}
|
||
}
|
||
|
||
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.'] : [],
|
||
};
|
||
}
|
||
|
||
private selectScopedLogRefs(logRefs: LogFileRef[], scopes: TaskChangeScope[]): LogFileRef[] {
|
||
const scopedMembers = new Set(
|
||
scopes.map((scope) => scope.memberName).filter((memberName) => memberName.length > 0)
|
||
);
|
||
if (scopedMembers.size === 0) {
|
||
return logRefs;
|
||
}
|
||
|
||
const selected = logRefs.filter((ref) => scopedMembers.has(ref.memberName));
|
||
return selected.length > 0 ? selected : logRefs;
|
||
}
|
||
|
||
private async buildIntervalScopedTaskChangeSet(input: {
|
||
teamName: string;
|
||
taskId: string;
|
||
taskMeta: ResolvedTaskChangeComputeInput['taskMeta'];
|
||
logRefs: LogFileRef[];
|
||
intervals?: { startedAt: string; completedAt?: string }[];
|
||
projectPath?: string;
|
||
includeDetails: boolean;
|
||
warningWithFiles: string;
|
||
warningWithoutFiles: string;
|
||
}): Promise<TaskChangeSetV2 | null> {
|
||
const intervals = input.intervals;
|
||
if (!Array.isArray(intervals) || intervals.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
||
await this.extractIntervalScopedChanges(
|
||
input.logRefs,
|
||
intervals,
|
||
input.projectPath,
|
||
input.includeDetails
|
||
);
|
||
|
||
return {
|
||
teamName: input.teamName,
|
||
taskId: input.taskId,
|
||
files,
|
||
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
|
||
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
|
||
totalFiles: files.length,
|
||
confidence: 'medium',
|
||
computedAt: new Date().toISOString(),
|
||
scope: {
|
||
taskId: input.taskId,
|
||
memberName: input.taskMeta?.owner ?? input.logRefs[0]?.memberName ?? '',
|
||
startLine: 0,
|
||
endLine: 0,
|
||
startTimestamp,
|
||
endTimestamp,
|
||
toolUseIds,
|
||
filePaths: files.map((file) => file.filePath),
|
||
confidence: {
|
||
tier: 2,
|
||
label: 'medium',
|
||
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
|
||
},
|
||
},
|
||
warnings: [files.length === 0 ? input.warningWithoutFiles : input.warningWithFiles],
|
||
};
|
||
}
|
||
|
||
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 interval of intervals) {
|
||
const startMs = Date.parse(interval.startedAt);
|
||
if (!Number.isFinite(startMs)) continue;
|
||
const endMsRaw =
|
||
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
|
||
const endMs =
|
||
interval.completedAt === undefined
|
||
? null
|
||
: Number.isFinite(endMsRaw)
|
||
? Math.max(endMsRaw, startMs)
|
||
: startMs;
|
||
normalized.push({
|
||
startMs,
|
||
endMs,
|
||
startedAt: interval.startedAt,
|
||
completedAt: interval.completedAt,
|
||
});
|
||
}
|
||
|
||
normalized.sort((a, b) => a.startMs - b.startMs);
|
||
const startTimestamp = normalized[0]?.startedAt ?? '';
|
||
const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>(
|
||
(acc, item) => {
|
||
if (
|
||
item.endMs == null ||
|
||
typeof item.completedAt !== 'string' ||
|
||
!Number.isFinite(Date.parse(item.completedAt))
|
||
) {
|
||
return acc;
|
||
}
|
||
if (!acc || item.endMs > acc.endMs) {
|
||
return { endMs: item.endMs, endTimestamp: item.completedAt };
|
||
}
|
||
return acc;
|
||
},
|
||
null
|
||
);
|
||
const endTimestamp = maxEnd?.endTimestamp ?? '';
|
||
|
||
const inAnyInterval = (timestamp: string): boolean => {
|
||
const ms = Date.parse(timestamp);
|
||
if (!Number.isFinite(ms)) return false;
|
||
for (const interval of normalized) {
|
||
if (ms < interval.startMs) continue;
|
||
if (interval.endMs == null) return true;
|
||
if (ms <= interval.endMs) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||
const allowedSnippets: SnippetDiff[] = [];
|
||
const toolUseIdsSet = new Set<string>();
|
||
|
||
for (const { snippets } of allParsed) {
|
||
for (const { snippet } of snippets) {
|
||
if (snippet.isError) continue;
|
||
if (!inAnyInterval(snippet.timestamp)) continue;
|
||
allowedSnippets.push(snippet);
|
||
if (snippet.toolUseId) {
|
||
toolUseIdsSet.add(snippet.toolUseId);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
files: this.aggregateByFile(
|
||
this.sortSnippetsChronologically(allowedSnippets),
|
||
projectPath,
|
||
includeDetails
|
||
),
|
||
toolUseIds: [...toolUseIdsSet],
|
||
startTimestamp,
|
||
endTimestamp,
|
||
};
|
||
}
|
||
|
||
private async extractScopedChanges(
|
||
logRefs: LogFileRef[],
|
||
scopes: TaskChangeScope[],
|
||
projectPath?: string,
|
||
includeDetails = true
|
||
): Promise<FileChangeSummary[]> {
|
||
const scopesWithTools = scopes.filter((scope) => scope.toolUseIds.length > 0);
|
||
if (scopesWithTools.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||
const allSnippets: SnippetDiff[] = [];
|
||
|
||
for (let index = 0; index < allParsed.length; index++) {
|
||
const ref = logRefs[index];
|
||
const parsed = allParsed[index];
|
||
if (!ref || !parsed) continue;
|
||
const matchingScopes = this.selectScopesForLogRef(scopesWithTools, ref);
|
||
if (matchingScopes.length === 0) continue;
|
||
|
||
for (const record of parsed.snippets) {
|
||
if (this.recordMatchesAnyScope(record, matchingScopes)) allSnippets.push(record.snippet);
|
||
}
|
||
}
|
||
|
||
return this.aggregateByFile(
|
||
this.sortSnippetsChronologically(allSnippets),
|
||
projectPath,
|
||
includeDetails
|
||
);
|
||
}
|
||
|
||
private selectScopesForLogRef(scopes: TaskChangeScope[], ref: LogFileRef): TaskChangeScope[] {
|
||
return scopes.filter((scope) => {
|
||
if (!scope.memberName) return true;
|
||
return scope.memberName === ref.memberName;
|
||
});
|
||
}
|
||
|
||
private recordMatchesAnyScope(record: ParsedSnippetRecord, scopes: TaskChangeScope[]): boolean {
|
||
return scopes.some((scope) => this.recordMatchesScope(record, scope));
|
||
}
|
||
|
||
private recordMatchesScope(record: ParsedSnippetRecord, scope: TaskChangeScope): boolean {
|
||
const snippet = record.snippet;
|
||
if (!scope.toolUseIds.includes(snippet.toolUseId)) return false;
|
||
if (record.sourceLine < scope.startLine || record.sourceLine > scope.endLine) return false;
|
||
if (!this.timestampMatchesScope(snippet.timestamp, scope)) return false;
|
||
if (!this.filePathMatchesScope(snippet.filePath, scope.filePaths)) return false;
|
||
return true;
|
||
}
|
||
|
||
private timestampMatchesScope(timestamp: string, scope: TaskChangeScope): boolean {
|
||
const snippetMs = Date.parse(timestamp);
|
||
if (!Number.isFinite(snippetMs)) return true;
|
||
|
||
const startMs = scope.startTimestamp ? Date.parse(scope.startTimestamp) : Number.NaN;
|
||
if (Number.isFinite(startMs) && snippetMs < startMs) return false;
|
||
|
||
const endMs = scope.endTimestamp ? Date.parse(scope.endTimestamp) : Number.NaN;
|
||
if (Number.isFinite(endMs) && snippetMs > endMs) return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
private filePathMatchesScope(filePath: string, scopeFilePaths: string[]): boolean {
|
||
if (scopeFilePaths.length === 0) return true;
|
||
const normalizedFilePath = this.normalizeFilePathKey(filePath);
|
||
return scopeFilePaths.some((scopePath) => {
|
||
const normalizedScopePath = this.normalizeFilePathKey(scopePath);
|
||
return (
|
||
normalizedFilePath === normalizedScopePath ||
|
||
normalizedFilePath.endsWith(`/${normalizedScopePath}`) ||
|
||
normalizedScopePath.endsWith(`/${normalizedFilePath}`)
|
||
);
|
||
});
|
||
}
|
||
|
||
private async fallbackSingleTaskScope(
|
||
input: ResolvedTaskChangeComputeInput,
|
||
logRefs: LogFileRef[]
|
||
): Promise<TaskChangeSetV2> {
|
||
const { teamName, taskId, projectPath, includeDetails } = input;
|
||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||
const allSnippets = this.sortSnippetsChronologically(
|
||
allParsed.flatMap((result) => result.snippets.map((record) => record.snippet))
|
||
);
|
||
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
||
const shouldWarn =
|
||
aggregatedFiles.length > 0 || shouldWarnAboutUnavailableTaskChangeEvidence(input);
|
||
|
||
return {
|
||
teamName,
|
||
taskId,
|
||
files: aggregatedFiles,
|
||
totalLinesAdded: aggregatedFiles.reduce((sum, file) => sum + file.linesAdded, 0),
|
||
totalLinesRemoved: aggregatedFiles.reduce((sum, file) => sum + file.linesRemoved, 0),
|
||
totalFiles: aggregatedFiles.length,
|
||
confidence: 'fallback',
|
||
computedAt: new Date().toISOString(),
|
||
scope: {
|
||
taskId,
|
||
memberName: logRefs[0]?.memberName ?? 'unknown',
|
||
startLine: 1,
|
||
endLine: 0,
|
||
startTimestamp: '',
|
||
endTimestamp: '',
|
||
toolUseIds: [],
|
||
filePaths: aggregatedFiles.map((file) => file.filePath),
|
||
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
|
||
},
|
||
warnings: shouldWarn
|
||
? ['No task boundaries found - showing all changes from related sessions.']
|
||
: [],
|
||
};
|
||
}
|
||
|
||
private emptyTaskChangeSet(input: ResolvedTaskChangeComputeInput): TaskChangeSetV2 {
|
||
const { teamName, taskId } = input;
|
||
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: shouldWarnAboutUnavailableTaskChangeEvidence(input)
|
||
? [NO_LOG_FILES_FOUND_WARNING]
|
||
: [],
|
||
};
|
||
}
|
||
|
||
private async parseJSONLFilesWithConcurrency(paths: string[]): Promise<ParsedSnippetsResult[]> {
|
||
if (paths.length === 0) return [];
|
||
|
||
const results = new Array<ParsedSnippetsResult>(paths.length);
|
||
let nextIndex = 0;
|
||
|
||
const worker = async (): Promise<void> => {
|
||
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(TaskChangeComputer.JSONL_PARSE_CONCURRENCY, paths.length) },
|
||
() => worker()
|
||
)
|
||
);
|
||
|
||
return results;
|
||
}
|
||
|
||
private async parseJSONLFile(filePath: string): Promise<ParsedSnippetsResult> {
|
||
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 (error) {
|
||
logger.debug(`Не удалось stat файла ${filePath}: ${String(error)}`);
|
||
return { snippets: [], mtime: 0 };
|
||
}
|
||
|
||
const inFlightKey = `${filePath}:${fileMtime}`;
|
||
const inFlight = this.parsedSnippetsInFlight.get(inFlightKey);
|
||
if (inFlight) return inFlight;
|
||
|
||
const promise = this.parseJSONLFileUncached(filePath, fileMtime).finally(() => {
|
||
if (this.parsedSnippetsInFlight.get(inFlightKey) === promise) {
|
||
this.parsedSnippetsInFlight.delete(inFlightKey);
|
||
}
|
||
});
|
||
this.parsedSnippetsInFlight.set(inFlightKey, promise);
|
||
return promise;
|
||
}
|
||
|
||
private async parseJSONLFileUncached(
|
||
filePath: string,
|
||
fileMtime: number
|
||
): Promise<ParsedSnippetsResult> {
|
||
const entries: ParsedJsonlEntry[] = [];
|
||
|
||
try {
|
||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||
let lineNumber = 0;
|
||
|
||
for await (const line of rl) {
|
||
lineNumber += 1;
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
try {
|
||
entries.push({
|
||
entry: JSON.parse(trimmed) as Record<string, unknown>,
|
||
lineNumber,
|
||
});
|
||
} catch {
|
||
// Ignore invalid JSON lines.
|
||
}
|
||
}
|
||
|
||
rl.close();
|
||
stream.destroy();
|
||
} catch (error) {
|
||
logger.debug(`Не удалось прочитать файл ${filePath}: ${String(error)}`);
|
||
return { snippets: [], mtime: 0 };
|
||
}
|
||
|
||
const erroredIds = this.collectErroredToolUseIds(entries.map((record) => record.entry));
|
||
const snippets: ParsedSnippetRecord[] = [];
|
||
const seenFiles = new Set<string>();
|
||
|
||
for (const { entry, lineNumber } 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 : '';
|
||
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);
|
||
const addSnippet = (snippet: SnippetDiff): void => {
|
||
snippets.push({ snippet, sourceLine: lineNumber });
|
||
};
|
||
|
||
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;
|
||
const hasTextPayload =
|
||
typeof input.old_string === 'string' || typeof input.new_string === 'string';
|
||
const metadataPaths = hasTextPayload ? [] : this.extractMetadataChangePaths(input);
|
||
const targetPaths =
|
||
metadataPaths.length > 0 ? metadataPaths : targetPath ? [{ filePath: targetPath }] : [];
|
||
|
||
for (const target of targetPaths) {
|
||
seenFiles.add(this.normalizeFilePathKey(target.filePath));
|
||
addSnippet({
|
||
toolUseId,
|
||
filePath: target.filePath,
|
||
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);
|
||
addSnippet({
|
||
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<string, unknown>;
|
||
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
|
||
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
|
||
addSnippet({
|
||
toolUseId,
|
||
filePath: targetPath,
|
||
toolName: 'MultiEdit',
|
||
type: 'multi-edit',
|
||
oldString,
|
||
newString,
|
||
replaceAll: false,
|
||
timestamp,
|
||
isError,
|
||
contextHash: this.computeContextHash(oldString, newString),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
this.parsedSnippetsCache.set(filePath, {
|
||
data: snippets,
|
||
mtime: fileMtime,
|
||
expiresAt: Date.now() + this.parsedSnippetsCacheTtl,
|
||
});
|
||
while (this.parsedSnippetsCache.size > this.maxParsedSnippetsCacheEntries) {
|
||
const oldestKey = this.parsedSnippetsCache.keys().next().value;
|
||
if (!oldestKey) break;
|
||
this.parsedSnippetsCache.delete(oldestKey);
|
||
}
|
||
|
||
return { snippets, mtime: fileMtime };
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
private extractMetadataChangePaths(input: Record<string, unknown>): MetadataChangePath[] {
|
||
const changes = Array.isArray(input.changes) ? input.changes : [];
|
||
const paths: MetadataChangePath[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
for (const change of changes) {
|
||
if (!change || typeof change !== 'object') continue;
|
||
const changeObj = change as Record<string, unknown>;
|
||
const filePath = typeof changeObj.path === 'string' ? changeObj.path : '';
|
||
if (!filePath) continue;
|
||
const normalized = this.normalizeFilePathKey(filePath);
|
||
if (seen.has(normalized)) continue;
|
||
seen.add(normalized);
|
||
paths.push({ filePath });
|
||
}
|
||
|
||
return paths;
|
||
}
|
||
|
||
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
|
||
const erroredIds = new Set<string>();
|
||
|
||
for (const entry of entries) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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) {
|
||
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) => {
|
||
let totalAdded = 0;
|
||
let totalRemoved = 0;
|
||
for (const snippet of data.snippets) {
|
||
if (snippet.isError) continue;
|
||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
||
totalAdded += added;
|
||
totalRemoved += removed;
|
||
}
|
||
|
||
const normalizedFilePath = data.filePath.replace(/\\/g, '/');
|
||
const normalizedProjectPath = projectPath?.replace(/\\/g, '/');
|
||
const relativePath = normalizedProjectPath
|
||
? normalizedFilePath.startsWith(normalizedProjectPath + '/')
|
||
? normalizedFilePath.slice(normalizedProjectPath.length + 1)
|
||
: normalizedFilePath.startsWith(normalizedProjectPath)
|
||
? normalizedFilePath.slice(normalizedProjectPath.length)
|
||
: normalizedFilePath.split('/').slice(-3).join('/')
|
||
: normalizedFilePath.split('/').slice(-3).join('/');
|
||
|
||
return {
|
||
filePath: data.filePath,
|
||
relativePath,
|
||
snippets: includeDetails ? data.snippets : [],
|
||
linesAdded: totalAdded,
|
||
linesRemoved: totalRemoved,
|
||
isNewFile: data.isNewFile,
|
||
timeline: includeDetails ? this.buildTimeline(data.filePath, data.snippets) : undefined,
|
||
};
|
||
});
|
||
}
|
||
|
||
private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline {
|
||
const events: FileEditEvent[] = snippets
|
||
.filter((snippet) => !snippet.isError)
|
||
.map((snippet, index) => {
|
||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
||
return {
|
||
toolUseId: snippet.toolUseId,
|
||
toolName: snippet.toolName,
|
||
timestamp: snippet.timestamp,
|
||
summary: this.generateEditSummary(snippet),
|
||
linesAdded: added,
|
||
linesRemoved: removed,
|
||
snippetIndex: index,
|
||
};
|
||
});
|
||
|
||
const timestamps = events
|
||
.map((event) => new Date(event.timestamp).getTime())
|
||
.filter((timestamp) => !Number.isNaN(timestamp));
|
||
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 === '' && snippet.newString === '') return 'File change metadata';
|
||
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';
|
||
}
|
||
}
|
||
|
||
private computeContextHash(oldString: string, newString: string): string {
|
||
const take3 = (value: string): string => {
|
||
const lines = value.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)}`;
|
||
let hash = 5381;
|
||
for (let index = 0; index < raw.length; index++) {
|
||
hash = ((hash << 5) + hash + raw.charCodeAt(index)) | 0;
|
||
}
|
||
return (hash >>> 0).toString(36);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private normalizeFilePathKey(filePath: string): string {
|
||
return normalizeTaskChangePresenceFilePath(filePath);
|
||
}
|
||
}
|