From bd781aed2f7b65152a2622bf118942ac181420fd Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 4 Mar 2026 15:44:30 +0200 Subject: [PATCH] feat: enhance ChangeExtractorService with interval-based scoping - Implemented deterministic interval scoping in ChangeExtractorService to improve task change retrieval when no specific scope is found. - Added a new method, extractIntervalScopedChanges, to handle changes based on provided intervals, enhancing the accuracy of change tracking. - Updated the logic for deriving intervals from task status history, ensuring better handling of task transitions. - Refactored related code for improved clarity and maintainability, including better type definitions and error handling. --- .../services/team/ChangeExtractorService.ts | 146 +++++++++++++++++- .../services/team/TeamMemberLogsFinder.ts | 125 ++++++++++----- 2 files changed, 227 insertions(+), 44 deletions(-) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 03ed46ac..4112cc9b 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -139,8 +139,46 @@ export class ChangeExtractorService { } } - // Если scope не найден — fallback на весь файл + // Если 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); } @@ -203,10 +241,46 @@ export class ChangeExtractorService { typeof (i as Record).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) : null)) + .filter((h): h is Record => 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, + intervals: derivedIntervals, }; } catch { return null; @@ -223,6 +297,74 @@ export class ChangeExtractorService { } } + 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(); + + 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. diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 4979863a..73c3653a 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -472,52 +472,86 @@ export class TeamMemberLogsFinder { teamName: string, taskId: string ): Promise { - const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const numericTaskId = /^\d+$/.test(taskId) ? taskId : null; - const teamEscaped = escapeRegex(teamName); - const teamPatterns: RegExp[] = [ - // Team tool inputs often include team_name - new RegExp(`"team_name"\\s*:\\s*"${teamEscaped}"`, 'i'), - // Some variants may use teamName or team - new RegExp(`"teamName"\\s*:\\s*"${teamEscaped}"`, 'i'), - new RegExp(`"team"\\s*:\\s*"${teamEscaped}"`, 'i'), - // CLI usage: node ".../teamctl.js" --team team-alpha task start 9 - new RegExp(`\\b--team\\b\\s*(?:=\\s*)?(?:"${teamEscaped}"|${teamEscaped})\\b`, 'i'), - ]; - const patterns: RegExp[] = [ - new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'), - new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'), - ]; - if (numericTaskId) { - patterns.push( - new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`), - new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`), - // Support teamctl command lines (may appear in tool output). - // Example: node ".../teamctl.js" --team "t" task start 10 - new RegExp(`\\bteamctl(?:\\.js)?\\b.{0,350}\\b${numericTaskId}\\b`, 'i') - ); - } + const teamLower = teamName.trim().toLowerCase(); + const taskIdStr = taskId.trim(); + + const extractTaskIdFromUnknown = (raw: unknown): string | null => { + if (typeof raw === 'string') return raw.trim(); + if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); + return null; + }; + + const extractTeamFromInput = (input: Record): string | null => { + const raw = + typeof input.team_name === 'string' + ? input.team_name + : typeof input.teamName === 'string' + ? input.teamName + : typeof input.team === 'string' + ? input.team + : null; + return typeof raw === 'string' ? raw.trim() : null; + }; + + const matchesTeamctlCommand = (command: string): boolean => { + if (!/\bteamctl(?:\.js)?\b/i.test(command)) return false; + + const teamMatch = /\s--team(?:\s+|=)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i.exec(command); + const cmdTeam = (teamMatch?.[1] ?? teamMatch?.[2] ?? teamMatch?.[3])?.trim(); + if (cmdTeam?.toLowerCase() !== teamLower) return false; + + const taskMatch = /\btask\s+(?:start|complete|set-status)\s+(\d+)\b/i.exec(command); + const cmdTaskId = taskMatch?.[1]; + return Boolean(cmdTaskId && cmdTaskId === taskIdStr); + }; + try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - let foundTask = false; - let foundTeam = false; for await (const line of rl) { - // We require BOTH taskId and teamName to avoid cross-team collisions when multiple - // teams share the same projectPath (task IDs are only unique per team). - // - // But they often appear on different lines (e.g. team_name is in Task tool input, while - // taskId appears in a tool result or CLI output). So we track them independently. - if (!foundTask && patterns.some((re) => re.test(line))) { - foundTask = true; - } - if (!foundTeam && teamPatterns.some((re) => re.test(line))) { - foundTeam = true; - } - if (foundTask && foundTeam) { - rl.close(); - stream.destroy(); - return true; + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed) as Record; + const content = this.extractEntryContent(entry); + if (!Array.isArray(content)) continue; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type !== 'tool_use') continue; + + const rawName = typeof b.name === 'string' ? b.name : ''; + const toolName = rawName.replace(/^proxy_/, ''); + const input = b.input as Record | undefined; + if (!input) continue; + + // Deterministic structured match: any tool whose input references this task+team. + const inputTeam = extractTeamFromInput(input); + const rawTaskId = input.taskId ?? input.task_id; + const inputTaskId = extractTaskIdFromUnknown(rawTaskId); + if ( + inputTeam?.toLowerCase() === teamLower && + inputTaskId && + inputTaskId === taskIdStr + ) { + rl.close(); + stream.destroy(); + return true; + } + + // Deterministic CLI match: teamctl command line (Bash tool). + if (toolName === 'Bash') { + const command = typeof input.command === 'string' ? input.command : ''; + if (command && matchesTeamctlCommand(command)) { + rl.close(); + stream.destroy(); + return true; + } + } + } + } catch { + // ignore parse errors } } rl.close(); @@ -528,6 +562,13 @@ export class TeamMemberLogsFinder { return false; } + private extractEntryContent(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; + } + private async listSessionDirs(projectDir: string): Promise { try { const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });