From 507bf798eb284f0bfb9c396a043f51745fbf16dc Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 17:52:39 +0200 Subject: [PATCH] improvement(task-change): improve task change presence tracking and related IPC handlers - Added support for tracking task change presence with new IPC channels: TEAM_GET_TASK_CHANGE_PRESENCE and TEAM_SET_CHANGE_PRESENCE_TRACKING. - Introduced JsonTaskChangePresenceRepository and TeamLogSourceTracker to manage task change presence data. - Enhanced ChangeExtractorService to utilize task change presence services for improved task change detection. - Updated TeamDataService to integrate task change presence tracking and resolve task change presence states. - Modified UI components to reflect task change presence status in Kanban and task detail views. This feature aims to provide real-time insights into task changes, enhancing user experience and task management capabilities. --- electron.vite.config.ts | 3 +- src/main/index.ts | 11 +- src/main/ipc/teams.ts | 38 + .../services/team/ChangeExtractorService.ts | 932 +++--------------- src/main/services/team/TaskChangeComputer.ts | 706 +++++++++++++ .../services/team/TaskChangeWorkerClient.ts | 267 +++++ src/main/services/team/TeamDataService.ts | 167 +++- .../services/team/TeamLogSourceTracker.ts | 361 +++++++ .../services/team/TeamMemberLogsFinder.ts | 26 + .../cache/JsonTaskChangePresenceRepository.ts | 140 +++ .../cache/TaskChangePresenceRepository.ts | 23 + .../cache/taskChangePresenceCacheSchema.ts | 107 ++ .../cache/taskChangePresenceCacheTypes.ts | 22 + src/main/services/team/index.ts | 1 + .../services/team/taskChangePresenceUtils.ts | 152 +++ .../services/team/taskChangeWorkerTypes.ts | 49 + src/main/utils/pathDecoder.ts | 4 + src/main/workers/task-change-worker.ts | 40 + src/preload/constants/ipcChannels.ts | 6 + src/preload/index.ts | 12 + src/renderer/api/httpClient.ts | 8 + .../components/layout/TeamTabSectionNav.tsx | 8 +- .../team/dialogs/TaskDetailDialog.tsx | 54 +- .../components/team/kanban/KanbanBoard.tsx | 36 +- .../components/team/kanban/KanbanTaskCard.tsx | 522 +++++----- src/renderer/store/index.ts | 154 +++ .../store/slices/changeReviewSlice.ts | 101 +- src/renderer/store/slices/teamSlice.ts | 155 ++- src/shared/types/api.ts | 3 + src/shared/types/team.ts | 5 + test/main/ipc/teams.test.ts | 32 + .../team/ChangeExtractorService.test.ts | 404 +++++++- .../team/TaskChangeWorkerClient.test.ts | 255 +++++ .../services/team/TeamDataService.test.ts | 283 ++++++ test/renderer/store/changeReviewSlice.test.ts | 238 +++++ .../renderer/store/teamChangeThrottle.test.ts | 143 +++ test/renderer/store/teamSlice.test.ts | 30 +- 37 files changed, 4362 insertions(+), 1136 deletions(-) create mode 100644 src/main/services/team/TaskChangeComputer.ts create mode 100644 src/main/services/team/TaskChangeWorkerClient.ts create mode 100644 src/main/services/team/TeamLogSourceTracker.ts create mode 100644 src/main/services/team/cache/JsonTaskChangePresenceRepository.ts create mode 100644 src/main/services/team/cache/TaskChangePresenceRepository.ts create mode 100644 src/main/services/team/cache/taskChangePresenceCacheSchema.ts create mode 100644 src/main/services/team/cache/taskChangePresenceCacheTypes.ts create mode 100644 src/main/services/team/taskChangePresenceUtils.ts create mode 100644 src/main/services/team/taskChangeWorkerTypes.ts create mode 100644 src/main/workers/task-change-worker.ts create mode 100644 test/main/services/team/TaskChangeWorkerClient.test.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f9121fca..2eb209d8 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -76,7 +76,8 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/main/index.ts'), - 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts') + 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'), + 'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts') }, output: { // CJS format so bundled deps can use __dirname/require. diff --git a/src/main/index.ts b/src/main/index.ts index b6916518..e8378d75 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; +import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { CONTEXT_CHANGED, @@ -104,6 +105,7 @@ import { TaskBoundaryParser, TeamDataService, TeamMemberLogsFinder, + TeamLogSourceTracker, TeamProvisioningService, UpdaterService, } from './services'; @@ -780,9 +782,13 @@ function initializeServices(): void { teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request)); const teamMemberLogsFinder = new TeamMemberLogsFinder(); + const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); + const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); const taskBoundaryParser = new TaskBoundaryParser(); const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser); + teamDataService.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); + changeExtractor.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); const gitDiffFallback = new GitDiffFallback(); const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const reviewApplier = new ReviewApplierService(); @@ -839,6 +845,7 @@ function initializeServices(): void { httpServer?.broadcast('team-change', event); }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); + teamLogSourceTracker.setEmitter(teamChangeEmitter); // Allow SchedulerService to push schedule events to renderer schedulerService.setChangeEmitter((event) => { @@ -1321,7 +1328,9 @@ function createWindow(): void { markRendererUnavailable(mainWindow); const activeContext = contextRegistry.getActive(); activeContext?.stopFileWatcher(); - scheduleRendererRecovery(mainWindow); + if (mainWindow) { + scheduleRendererRecovery(mainWindow); + } }); // Set main window reference for notification manager and updater diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 1b12e3b9..c3814203 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -19,6 +19,7 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -46,6 +47,7 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -306,6 +308,8 @@ export function initializeTeamHandlers( export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); + ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); @@ -368,6 +372,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); + ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); @@ -613,6 +619,38 @@ async function handleGetData( return { success: true, data: { ...data, isAlive, messages: merged } }; } +async function handleGetTaskChangePresence( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise>> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + + return wrapTeamHandler('getTaskChangePresence', () => + getTeamDataService().getTaskChangePresence(validated.value!) + ); +} + +async function handleSetChangePresenceTracking( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setChangePresenceTracking', async () => { + getTeamDataService().setTaskChangePresenceTracking(validated.value!, enabled); + }); +} + async function handleDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index fe63d20d..116918a7 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -6,27 +6,29 @@ import { 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 { TaskChangeComputer } from './TaskChangeComputer'; +import { TaskChangeWorkerClient, getTaskChangeWorkerClient } from './TaskChangeWorkerClient'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; import { TeamConfigReader } from './TeamConfigReader'; -import { countLineChanges } from './UnifiedLineCounter'; +import { + buildTaskChangePresenceDescriptor, + computeTaskChangePresenceProjectFingerprint, + normalizeTaskChangePresenceFilePath, +} from './taskChangePresenceUtils'; +import { + type ResolvedTaskChangeComputeInput, + type TaskChangeEffectiveOptions, + type TaskChangeTaskMeta, +} from './taskChangeWorkerTypes'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; -import type { - AgentChangeSet, - ChangeStats, - FileChangeSummary, - FileEditEvent, - FileEditTimeline, - SnippetDiff, - TaskChangeScope, - TaskChangeSetV2, -} from '@shared/types'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; +import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); @@ -42,13 +44,6 @@ interface TaskChangeSummaryCacheEntry { expiresAt: number; } -interface ParsedSnippetsCacheEntry { - data: SnippetDiff[]; - mtime: number; - expiresAt: number; -} - -/** Ссылка на JSONL файл с привязкой к memberName */ interface LogFileRef { filePath: string; memberName: string; @@ -60,22 +55,34 @@ export class ChangeExtractorService { 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'; + private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; + private teamLogSourceTracker: TeamLogSourceTracker | null = null; + private readonly taskChangeComputer: TaskChangeComputer; constructor( private readonly logsFinder: TeamMemberLogsFinder, - private readonly boundaryParser: TaskBoundaryParser, + boundaryParser: TaskBoundaryParser, private readonly configReader: TeamConfigReader = new TeamConfigReader(), - private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository() - ) {} + private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository(), + private readonly taskChangeWorkerClient: TaskChangeWorkerClient = getTaskChangeWorkerClient() + ) { + this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser); + } + + setTaskChangePresenceServices( + repository: TaskChangePresenceRepository, + tracker: TeamLogSourceTracker + ): void { + this.taskChangePresenceRepository = repository; + this.teamLogSourceTracker = tracker; + } /** Получить все изменения агента */ async getAgentChanges(teamName: string, memberName: string): Promise { @@ -85,37 +92,12 @@ export class ChangeExtractorService { 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 = { + const { result, latestMtime } = await this.taskChangeComputer.computeAgentChanges( teamName, memberName, - files, - totalLinesAdded, - totalLinesRemoved, - totalFiles: files.length, - computedAt: new Date().toISOString(), - }; + projectPath + ); this.cache.set(cacheKey, { data: result, @@ -140,14 +122,16 @@ export class ChangeExtractorService { forceFresh?: boolean; } ): Promise { + const initialVersion = this.getTaskChangeSummaryVersion(teamName, taskId); const includeDetails = options?.summaryOnly !== true; const taskMeta = await this.readTaskMeta(teamName, taskId); - const effectiveOptions = { + const effectiveOptions: TaskChangeEffectiveOptions = { owner: options?.owner ?? taskMeta?.owner, status: options?.status ?? taskMeta?.status, intervals: options?.intervals ?? taskMeta?.intervals, since: options?.since, }; + const projectPath = await this.resolveProjectPath(teamName); const effectiveStateBucket = taskMeta ? getTaskChangeStateBucket({ status: effectiveOptions.status, @@ -162,14 +146,27 @@ export class ChangeExtractorService { const summaryCacheableState = isTaskChangeSummaryCacheable(effectiveStateBucket); const shouldUseSummaryCache = !includeDetails && summaryCacheableState; + let version = initialVersion; if (!summaryCacheableState || options?.forceFresh === true) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true, }); + version = this.getTaskChangeSummaryVersion(teamName, taskId); } + const resolvedInput: ResolvedTaskChangeComputeInput = { + teamName, + taskId, + taskMeta, + effectiveOptions, + projectPath, + includeDetails, + }; + if (!shouldUseSummaryCache) { - return this.computeTaskChanges(teamName, taskId, effectiveOptions, includeDetails); + const result = await this.computeTaskChangesPreferred(resolvedInput); + await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); + return result; } const cacheKey = this.buildTaskChangeSummaryCacheKey( @@ -178,11 +175,17 @@ export class ChangeExtractorService { effectiveOptions, effectiveStateBucket ); - const version = this.getTaskChangeSummaryVersion(teamName, taskId); if (options?.forceFresh !== true) { const cached = this.taskChangeSummaryCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + cached.data + ); return cached.data; } this.taskChangeSummaryCache.delete(cacheKey); @@ -201,11 +204,18 @@ export class ChangeExtractorService { ); if (persisted) { this.setTaskChangeSummaryCache(cacheKey, persisted); + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + persisted + ); return persisted; } } - const promise = this.computeTaskChanges(teamName, taskId, effectiveOptions, false) + const promise = this.computeTaskChangesPreferred({ ...resolvedInput, includeDetails: false }) .then(async (result) => { if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) { return result; @@ -220,6 +230,7 @@ export class ChangeExtractorService { result, version ); + await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); return result; }) .finally(() => { @@ -256,101 +267,41 @@ export class ChangeExtractorService { ); } - private async computeTaskChanges( - teamName: string, - taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, - includeDetails: boolean + private async computeTaskChangesPreferred( + input: ResolvedTaskChangeComputeInput ): 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); + if (!this.taskChangeWorkerClient.isAvailable()) { + return this.taskChangeComputer.computeTaskChanges(input); } - 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 }); + try { + const result = await this.taskChangeWorkerClient.computeTaskChanges(input); + if (this.isValidWorkerTaskChangeResult(result, input)) { + return result; } + logger.warn( + `Task change worker returned malformed result for ${input.teamName}/${input.taskId}; falling back inline.` + ); + } catch (error) { + logger.warn( + `Task change worker failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` + ); } - // Если 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 this.taskChangeComputer.computeTaskChanges(input); + } - 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 + private isValidWorkerTaskChangeResult( + result: TaskChangeSetV2, + input: ResolvedTaskChangeComputeInput + ): boolean { + return ( + !!result && + typeof result === 'object' && + result.teamName === input.teamName && + result.taskId === input.taskId && + Array.isArray(result.files) ); - - 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.'] : [], - }; } /** Получить краткую статистику */ @@ -366,17 +317,7 @@ export class ChangeExtractorService { // ---- 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> { + private async readTaskMeta(teamName: string, taskId: string): Promise { try { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const raw = await readFile(taskPath, 'utf8'); @@ -460,606 +401,21 @@ export class ChangeExtractorService { } } - 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; - }, + options: TaskChangeEffectiveOptions, 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()); + return normalizeTaskChangePresenceFilePath(filePath); } private buildTaskSignature( - options: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + options: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket ): string { const owner = typeof options.owner === 'string' ? options.owner.trim() : ''; @@ -1131,19 +487,9 @@ export class ChangeExtractorService { private async readPersistedTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket, - taskMeta: { - status?: string; - reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; - historyEvents?: unknown[]; - kanbanColumn?: 'review' | 'approved'; - } | null + taskMeta: TaskChangeTaskMeta | null ): Promise { if (!this.isPersistedTaskChangeCacheEnabled) { return null; @@ -1197,12 +543,7 @@ export class ChangeExtractorService { private schedulePersistedTaskChangeSummaryValidation( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string ): void { @@ -1237,12 +578,7 @@ export class ChangeExtractorService { private async validatePersistedTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string, version: number @@ -1282,12 +618,7 @@ export class ChangeExtractorService { private async persistTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket, result: TaskChangeSetV2, generation: number @@ -1365,7 +696,58 @@ export class ChangeExtractorService { 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'); + return computeTaskChangePresenceProjectFingerprint(projectPath); + } + + private async recordTaskChangePresence( + teamName: string, + taskId: string, + taskMeta: TaskChangeTaskMeta | null, + effectiveOptions: TaskChangeEffectiveOptions, + result: TaskChangeSetV2 + ): Promise { + if (!this.taskChangePresenceRepository || !this.teamLogSourceTracker || !taskMeta) { + return; + } + + const snapshot = await this.teamLogSourceTracker.ensureTracking(teamName); + if (!snapshot.projectFingerprint || !snapshot.logSourceGeneration) { + return; + } + + if ( + result.files.length === 0 && + result.confidence !== 'high' && + result.confidence !== 'medium' + ) { + return; + } + + const descriptor = buildTaskChangePresenceDescriptor({ + owner: effectiveOptions.owner ?? taskMeta.owner, + status: effectiveOptions.status ?? taskMeta.status, + intervals: effectiveOptions.intervals ?? taskMeta.intervals, + since: effectiveOptions.since, + reviewState: taskMeta.reviewState, + historyEvents: taskMeta.historyEvents, + kanbanColumn: taskMeta.kanbanColumn, + }); + + const now = new Date().toISOString(); + await this.taskChangePresenceRepository.upsertEntry( + teamName, + { + projectFingerprint: snapshot.projectFingerprint, + logSourceGeneration: snapshot.logSourceGeneration, + writtenAt: now, + }, + { + taskId, + taskSignature: descriptor.taskSignature, + presence: result.files.length > 0 ? 'has_changes' : 'no_changes', + writtenAt: now, + logSourceGeneration: snapshot.logSourceGeneration, + } + ); } } diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts new file mode 100644 index 00000000..893a9ee2 --- /dev/null +++ b/src/main/services/team/TaskChangeComputer.ts @@ -0,0 +1,706 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import * as readline from 'readline'; + +import { countLineChanges } from './UnifiedLineCounter'; +import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils'; + +import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { + AgentChangeSet, + FileChangeSummary, + FileEditEvent, + FileEditTimeline, + SnippetDiff, + TaskChangeScope, + TaskChangeSetV2, +} from '@shared/types'; +import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes'; + +const logger = createLogger('Service:TaskChangeComputer'); + +interface ParsedSnippetsCacheEntry { + data: SnippetDiff[]; + mtime: number; + expiresAt: number; +} + +interface LogFileRef { + filePath: string; + memberName: string; +} + +export class TaskChangeComputer { + private parsedSnippetsCache = new Map(); + private readonly parsedSnippetsCacheTtl = 20 * 1000; + 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); + 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 { + const { teamName, taskId, taskMeta, effectiveOptions, projectPath, includeDetails } = input; + const logRefs = await this.logsFinder.findLogFileRefsForTask( + teamName, + taskId, + effectiveOptions + ); + if (logRefs.length === 0) { + return this.emptyTaskChangeSet(teamName, taskId); + } + + 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 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, 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, + memberName: taskMeta?.owner ?? 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 + ? ['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.'] : [], + }; + } + + 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 = Number.isFinite(endMsRaw) ? endMsRaw : null; + 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') 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(); + + 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 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 snippet of snippets) { + if (allowedToolUseIds.has(snippet.toolUseId)) { + allSnippets.push(snippet); + } + } + } else { + allSnippets.push(...snippets); + } + } + + return this.aggregateByFile( + this.sortSnippetsChronologically(allSnippets), + projectPath, + includeDetails + ); + } + + 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((result) => result.snippets) + ); + const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails); + + 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: ['No task boundaries found — showing all changes from related sessions.'], + }; + } + + 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 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(TaskChangeComputer.JSONL_PARSE_CONCURRENCY, paths.length) }, + () => worker() + ) + ); + + return results; + } + + 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 (error) { + logger.debug(`Не удалось stat файла ${filePath}: ${String(error)}`); + 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 { + // Ignore invalid JSON lines. + } + } + + rl.close(); + stream.destroy(); + } catch (error) { + logger.debug(`Не удалось прочитать файл ${filePath}: ${String(error)}`); + return { snippets: [], mtime: 0 }; + } + + const erroredIds = this.collectErroredToolUseIds(entries); + const snippets: SnippetDiff[] = []; + 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 : ''; + 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), + }); + } + } + } + } + } + + this.parsedSnippetsCache.set(filePath, { + data: snippets, + mtime: fileMtime, + expiresAt: Date.now() + this.parsedSnippetsCacheTtl, + }); + + return { snippets, mtime: fileMtime }; + } + + 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; + } + + 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; + } + + private collectErroredToolUseIds(entries: Record[]): Set { + const erroredIds = new Set(); + + 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).tool_use_id; + if (typeof toolUseId === 'string') { + erroredIds.add(toolUseId); + } + } + } + } + + 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; + } + + 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; + } + + 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 as FileEditEvent['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 === '') 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); + } +} diff --git a/src/main/services/team/TaskChangeWorkerClient.ts b/src/main/services/team/TaskChangeWorkerClient.ts new file mode 100644 index 00000000..bee769c5 --- /dev/null +++ b/src/main/services/team/TaskChangeWorkerClient.ts @@ -0,0 +1,267 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { createLogger } from '@shared/utils/logger'; + +import type { + ResolvedTaskChangeComputeInput, + TaskChangeWorkerRequest, + TaskChangeWorkerResponse, +} from './taskChangeWorkerTypes'; +import type { TaskChangeSetV2 } from '@shared/types'; + +const logger = createLogger('Service:TaskChangeWorkerClient'); +const DEFAULT_WORKER_CALL_TIMEOUT_MS = 30_000; + +interface WorkerLike { + on(event: 'message', listener: (msg: TaskChangeWorkerResponse) => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'exit', listener: (code: number) => void): this; + postMessage(message: TaskChangeWorkerRequest): void; + terminate(): Promise; +} + +interface QueueEntry { + id: string; + request: TaskChangeWorkerRequest; + resolve: (value: TaskChangeSetV2) => void; + reject: (error: Error) => void; +} + +function makeId(): string { + return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; +} + +function resolveWorkerPath(): string | null { + const baseDir = + typeof __dirname === 'string' && __dirname.length > 0 + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + + const candidates = [ + path.join(baseDir, 'task-change-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.js'), + ]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // ignore + } + } + + return null; +} + +export class TaskChangeWorkerClient { + private worker: WorkerLike | null = null; + private terminatingWorker: WorkerLike | null = null; + private readonly workerPath: string | null; + private readonly workerFactory: (workerPath: string) => WorkerLike; + private readonly timeoutMs: number; + private readonly enabled: boolean; + private warnedUnavailable = false; + private activeRequestId: string | null = null; + private activeTimeout: ReturnType | null = null; + private terminatingForTimeoutRequestId: string | null = null; + private pending = new Map(); + private queue: QueueEntry[] = []; + + constructor(options?: { + workerPath?: string | null; + workerFactory?: (workerPath: string) => WorkerLike; + timeoutMs?: number; + enabled?: boolean; + }) { + this.workerPath = + options && 'workerPath' in options ? (options.workerPath ?? null) : resolveWorkerPath(); + this.workerFactory = options?.workerFactory ?? ((workerPath) => new Worker(workerPath)); + this.timeoutMs = options?.timeoutMs ?? DEFAULT_WORKER_CALL_TIMEOUT_MS; + this.enabled = options?.enabled ?? process.env.CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER !== '0'; + } + + isAvailable(): boolean { + if (!this.enabled) { + return false; + } + + if (!this.workerPath && !this.warnedUnavailable) { + this.warnedUnavailable = true; + logger.warn('task-change-worker not found; falling back to main-thread extraction.'); + } + + return this.workerPath !== null; + } + + async computeTaskChanges(payload: ResolvedTaskChangeComputeInput): Promise { + if (!this.isAvailable()) { + throw new Error('Task change worker is not available in this environment'); + } + + const id = makeId(); + const entry: QueueEntry = { + id, + request: { id, op: 'computeTaskChanges', payload }, + resolve: () => undefined, + reject: () => undefined, + }; + + return new Promise((resolve, reject) => { + entry.resolve = resolve; + entry.reject = reject; + this.pending.set(id, entry); + this.queue.push(entry); + this.processQueue(); + }); + } + + private ensureWorker(): WorkerLike { + if (!this.workerPath) { + throw new Error('Task change worker is not available in this environment'); + } + if (this.worker) { + return this.worker; + } + + const worker = this.workerFactory(this.workerPath); + worker.on('message', (msg) => this.handleMessage(msg)); + worker.on('error', (error) => this.handleWorkerFailure(worker, error)); + worker.on('exit', (code) => this.handleWorkerExit(worker, code)); + this.worker = worker; + return worker; + } + + private processQueue(): void { + if (this.activeRequestId || this.queue.length === 0) { + return; + } + + const entry = this.queue.shift(); + if (!entry) { + return; + } + + const worker = this.ensureWorker(); + this.activeRequestId = entry.id; + this.activeTimeout = setTimeout(() => { + const activeId = this.activeRequestId; + if (!activeId) { + return; + } + + this.clearActiveState(); + this.terminatingForTimeoutRequestId = activeId; + const pending = this.pending.get(activeId); + if (pending) { + this.pending.delete(activeId); + pending.reject( + new Error(`Worker call timeout after ${this.timeoutMs}ms (computeTaskChanges)`) + ); + } + + try { + const workerToTerminate = this.worker; + this.terminatingWorker = workerToTerminate; + workerToTerminate?.terminate().catch(() => undefined); + } catch { + // ignore + } finally { + this.worker = null; + } + + this.processQueue(); + }, this.timeoutMs); + + try { + worker.postMessage(entry.request); + } catch (error) { + this.clearActiveState(); + this.pending.delete(entry.id); + entry.reject(error instanceof Error ? error : new Error(String(error))); + this.processQueue(); + } + } + + private handleMessage(message: TaskChangeWorkerResponse): void { + const entry = this.pending.get(message.id); + if (!entry) { + return; + } + + this.pending.delete(message.id); + if (this.activeRequestId === message.id) { + this.clearActiveState(); + } + + if (message.ok) { + entry.resolve(message.result); + } else { + entry.reject(new Error(message.error)); + } + + this.processQueue(); + } + + private handleWorkerFailure(worker: WorkerLike, error: Error): void { + logger.error('Task change worker error', error); + if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) { + this.terminatingForTimeoutRequestId = null; + this.terminatingWorker = null; + return; + } + + this.rejectAllPending(error); + this.clearActiveState(); + if (this.worker === worker) { + this.worker = null; + } + } + + private handleWorkerExit(worker: WorkerLike, code: number): void { + if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) { + this.terminatingForTimeoutRequestId = null; + this.terminatingWorker = null; + return; + } + + if (code !== 0) { + logger.warn(`Task change worker exited with code ${code}`); + } + this.rejectAllPending(new Error(`Worker exited with code ${code}`)); + this.clearActiveState(); + if (this.worker === worker) { + this.worker = null; + } + } + + private rejectAllPending(error: Error): void { + for (const entry of this.pending.values()) { + entry.reject(error); + } + this.pending.clear(); + this.queue = []; + } + + private clearActiveState(): void { + this.activeRequestId = null; + if (this.activeTimeout) { + clearTimeout(this.activeTimeout); + this.activeTimeout = null; + } + } +} + +let singleton: TaskChangeWorkerClient | null = null; + +export function getTaskChangeWorkerClient(): TaskChangeWorkerClient { + if (!singleton) { + singleton = new TaskChangeWorkerClient(); + } + return singleton; +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 4d454aec..a79ef673 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -40,6 +40,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; +import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import type { AddMemberRequest, @@ -63,11 +64,15 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, + TaskChangePresenceState, TeamTaskWithKanban, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; import type { AgentTeamsController } from 'agent-teams-controller'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; +import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; const { createController } = agentTeamsControllerModule; @@ -91,6 +96,11 @@ interface EligibleTaskCommentNotification { summary: string; } +interface TaskChangeLogSourceSnapshot { + projectFingerprint: string | null; + logSourceGeneration: string | null; +} + export class TeamDataService { private processHealthTimer: ReturnType | null = null; private processHealthTeams = new Set(); @@ -98,6 +108,8 @@ export class TeamDataService { private notifiedTaskStarts = new Set(); private taskCommentNotificationInitialization: Promise | null = null; private taskCommentNotificationInFlight = new Set(); + private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; + private teamLogSourceTracker: TeamLogSourceTracker | null = null; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -168,6 +180,120 @@ export class TeamDataService { return null; } + setTaskChangePresenceServices( + repository: TaskChangePresenceRepository, + tracker: TeamLogSourceTracker + ): void { + this.taskChangePresenceRepository = repository; + this.teamLogSourceTracker = tracker; + } + + setTaskChangePresenceTracking(teamName: string, enabled: boolean): void { + if (!this.teamLogSourceTracker) { + return; + } + + if (enabled) { + void this.teamLogSourceTracker + .ensureTracking(teamName) + .catch((error) => + logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`) + ); + return; + } + + void this.teamLogSourceTracker + .stopTracking(teamName) + .catch((error) => + logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`) + ); + } + + private resolveTaskChangePresenceMap( + tasks: readonly TeamTaskWithKanban[], + changePresenceEnabled: boolean, + presenceIndex: PersistedTaskChangePresenceIndex | null, + logSourceSnapshot: TaskChangeLogSourceSnapshot | null + ): Record { + const result: Record = {}; + if ( + !changePresenceEnabled || + !presenceIndex || + !logSourceSnapshot?.projectFingerprint || + !logSourceSnapshot.logSourceGeneration || + presenceIndex.projectFingerprint !== logSourceSnapshot.projectFingerprint || + presenceIndex.logSourceGeneration !== logSourceSnapshot.logSourceGeneration + ) { + for (const task of tasks) { + result[task.id] = 'unknown'; + } + return result; + } + + for (const task of tasks) { + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + reviewState: task.reviewState, + historyEvents: task.historyEvents, + kanbanColumn: task.kanbanColumn, + }); + const presenceEntry = presenceIndex.entries[task.id]; + result[task.id] = + presenceEntry && + presenceEntry.taskSignature === descriptor.taskSignature && + presenceEntry.logSourceGeneration === logSourceSnapshot.logSourceGeneration + ? presenceEntry.presence + : 'unknown'; + } + + return result; + } + + async getTaskChangePresence(teamName: string): Promise> { + const config = await this.configReader.getConfig(teamName); + if (!config) { + throw new Error(`Team not found: ${teamName}`); + } + + const changePresenceEnabled = + this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null; + const logSourceSnapshot: TaskChangeLogSourceSnapshot | null = + changePresenceEnabled && + typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown }) + .getSnapshot === 'function' + ? (( + this.teamLogSourceTracker as { + getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null; + } + ).getSnapshot(teamName) ?? null) + : null; + + const [tasks, kanbanState, presenceIndex] = await Promise.all([ + this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]), + this.kanbanManager + .getState(teamName) + .catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState), + changePresenceEnabled && + logSourceSnapshot?.projectFingerprint && + logSourceSnapshot.logSourceGeneration + ? this.taskChangePresenceRepository!.load(teamName) + : Promise.resolve(null), + ]); + + const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) => + this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) + ); + + return this.resolveTaskChangePresenceMap( + tasksWithKanbanBase, + changePresenceEnabled, + presenceIndex, + logSourceSnapshot + ); + } + async listTeams(): Promise { return this.configReader.listTeams(); } @@ -333,6 +459,24 @@ export class TeamDataService { mark('config'); const warnings: string[] = []; + const changePresenceEnabled = + this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null; + const logSourceSnapshot: TaskChangeLogSourceSnapshot | null = + changePresenceEnabled && + typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown }) + .getSnapshot === 'function' + ? (( + this.teamLogSourceTracker as { + getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null; + } + ).getSnapshot(teamName) ?? null) + : null; + const presenceIndexPromise = + changePresenceEnabled && + logSourceSnapshot?.projectFingerprint && + logSourceSnapshot.logSourceGeneration + ? this.taskChangePresenceRepository!.load(teamName) + : Promise.resolve(null); let tasks: TeamTask[] = []; try { @@ -473,10 +617,25 @@ export class TeamDataService { mark('kanbanGc'); - const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => + const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) => this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) ); + const presenceIndex = await presenceIndexPromise; + + const taskChangePresenceById = this.resolveTaskChangePresenceMap( + tasksWithKanbanBase, + changePresenceEnabled, + presenceIndex, + logSourceSnapshot + ); + const tasksWithKanban: TeamTaskWithKanban[] = changePresenceEnabled + ? tasksWithKanbanBase.map((task) => ({ + ...task, + changePresence: taskChangePresenceById[task.id] ?? 'unknown', + })) + : tasksWithKanbanBase; + const members = this.memberResolver.resolveMembers( config, metaMembers, @@ -492,10 +651,6 @@ export class TeamDataService { mark('syncComments'); - const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => - this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) - ); - let processes: TeamProcess[] = []; try { processes = await this.readProcesses(teamName); @@ -530,7 +685,7 @@ export class TeamDataService { return { teamName, config, - tasks: tasksToReturn, + tasks: tasksWithKanban, members, messages, kanbanState, diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts new file mode 100644 index 00000000..863a8b81 --- /dev/null +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -0,0 +1,361 @@ +import { createLogger } from '@shared/utils/logger'; +import { watch } from 'chokidar'; +import { createHash } from 'crypto'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { + computeTaskChangePresenceProjectFingerprint, + normalizeTaskChangePresenceFilePath, +} from './taskChangePresenceUtils'; + +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { TeamChangeEvent } from '@shared/types'; +import type { FSWatcher } from 'chokidar'; + +const logger = createLogger('Service:TeamLogSourceTracker'); + +interface TeamLogSourceSnapshot { + projectFingerprint: string | null; + logSourceGeneration: string | null; +} + +interface TrackingState { + watcher: FSWatcher | null; + projectDir: string | null; + refreshTimer: ReturnType | null; + initializePromise: Promise | null; + initializeVersion: number | null; + recomputePromise: Promise | null; + recomputeVersion: number | null; + snapshot: TeamLogSourceSnapshot; + desiredTracking: boolean; + lifecycleVersion: number; +} + +export class TeamLogSourceTracker { + private readonly stateByTeam = new Map(); + private emitter: ((event: TeamChangeEvent) => void) | null = null; + + constructor(private readonly logsFinder: TeamMemberLogsFinder) {} + + setEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void { + this.emitter = emitter; + } + + getSnapshot(teamName: string): TeamLogSourceSnapshot | null { + const state = this.stateByTeam.get(teamName); + return state ? { ...state.snapshot } : null; + } + + async ensureTracking(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (!state.desiredTracking) { + state.desiredTracking = true; + state.lifecycleVersion += 1; + } + + if ( + state.initializePromise && + state.initializeVersion === state.lifecycleVersion && + state.desiredTracking + ) { + return state.initializePromise; + } + + const initializeVersion = state.lifecycleVersion; + const initializePromise = this.initializeTeam(teamName, initializeVersion) + .catch((error) => { + logger.debug(`Failed to initialize log-source tracker for ${teamName}: ${String(error)}`); + return { projectFingerprint: null, logSourceGeneration: null }; + }) + .finally(() => { + const current = this.stateByTeam.get(teamName); + if (current?.initializePromise === initializePromise) { + current.initializePromise = null; + current.initializeVersion = null; + } + }); + + state.initializePromise = initializePromise; + state.initializeVersion = initializeVersion; + return initializePromise; + } + + async dispose(): Promise { + await Promise.all([...this.stateByTeam.keys()].map((teamName) => this.stopTracking(teamName))); + } + + private getOrCreateState(teamName: string): TrackingState { + const existing = this.stateByTeam.get(teamName); + if (existing) { + return existing; + } + + const created: TrackingState = { + watcher: null, + projectDir: null, + refreshTimer: null, + initializePromise: null, + initializeVersion: null, + recomputePromise: null, + recomputeVersion: null, + snapshot: { projectFingerprint: null, logSourceGeneration: null }, + desiredTracking: false, + lifecycleVersion: 0, + }; + this.stateByTeam.set(teamName, created); + return created; + } + + async stopTracking(teamName: string): Promise { + const state = this.stateByTeam.get(teamName); + if (!state) { + return; + } + + if (state.desiredTracking) { + state.desiredTracking = false; + state.lifecycleVersion += 1; + } + + if (state.refreshTimer) { + clearTimeout(state.refreshTimer); + state.refreshTimer = null; + } + + if (state.watcher) { + await state.watcher.close().catch(() => undefined); + state.watcher = null; + } + + state.projectDir = null; + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + } + + private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { + const state = this.stateByTeam.get(teamName); + return !!state && state.desiredTracking && state.lifecycleVersion === expectedVersion; + } + + private async initializeTeam( + teamName: string, + expectedVersion: number + ): Promise { + const state = this.getOrCreateState(teamName); + const previousGeneration = state.snapshot.logSourceGeneration; + const context = await this.logsFinder.getLogSourceWatchContext(teamName, { + forceRefresh: true, + }); + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + if (!context) { + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + await this.rebuildWatcher(teamName, null, expectedVersion); + return state.snapshot; + } + + const snapshot = await this.computeSnapshot(context); + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + state.snapshot = snapshot; + await this.rebuildWatcher(teamName, context.projectDir, expectedVersion); + if ( + this.isTrackingCurrent(teamName, expectedVersion) && + state.snapshot.logSourceGeneration && + previousGeneration !== state.snapshot.logSourceGeneration + ) { + this.emitter?.({ + type: 'log-source-change', + teamName, + }); + } + return snapshot; + } + + private async rebuildWatcher( + teamName: string, + projectDir: string | null, + expectedVersion: number + ): Promise { + const state = this.stateByTeam.get(teamName); + if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) { + return; + } + if (state.projectDir === projectDir && state.watcher) { + return; + } + + if (state.watcher) { + await state.watcher.close().catch(() => undefined); + state.watcher = null; + } + + state.projectDir = projectDir; + if (!projectDir) { + return; + } + + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + state.projectDir = null; + return; + } + + state.watcher = watch(projectDir, { + ignoreInitial: true, + ignorePermissionErrors: true, + followSymlinks: false, + depth: 3, + awaitWriteFinish: { + stabilityThreshold: 250, + pollInterval: 50, + }, + }); + + const scheduleRecompute = (): void => { + const current = this.stateByTeam.get(teamName); + if (!current || !current.desiredTracking) { + return; + } + if (current.refreshTimer) { + clearTimeout(current.refreshTimer); + } + current.refreshTimer = setTimeout(() => { + current.refreshTimer = null; + void this.recompute(teamName); + }, 300); + }; + + state.watcher.on('add', scheduleRecompute); + state.watcher.on('change', scheduleRecompute); + state.watcher.on('unlink', scheduleRecompute); + state.watcher.on('addDir', scheduleRecompute); + state.watcher.on('unlinkDir', scheduleRecompute); + state.watcher.on('error', (error) => { + logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`); + }); + } + + private async recompute(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (!state.desiredTracking) { + return state.snapshot; + } + if ( + state.recomputePromise && + state.recomputeVersion === state.lifecycleVersion && + state.desiredTracking + ) { + return state.recomputePromise; + } + + const recomputeVersion = state.lifecycleVersion; + const recomputePromise = (async () => { + const previousGeneration = state.snapshot.logSourceGeneration; + const context = await this.logsFinder.getLogSourceWatchContext(teamName, { + forceRefresh: true, + }); + if (!this.isTrackingCurrent(teamName, recomputeVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + + if (!context) { + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + await this.rebuildWatcher(teamName, null, recomputeVersion); + } else { + state.snapshot = await this.computeSnapshot(context); + if (!this.isTrackingCurrent(teamName, recomputeVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + await this.rebuildWatcher(teamName, context.projectDir, recomputeVersion); + } + + if ( + this.isTrackingCurrent(teamName, recomputeVersion) && + previousGeneration && + state.snapshot.logSourceGeneration && + previousGeneration !== state.snapshot.logSourceGeneration + ) { + this.emitter?.({ + type: 'log-source-change', + teamName, + }); + } + + return state.snapshot; + })().finally(() => { + const current = this.stateByTeam.get(teamName); + if (current?.recomputePromise === recomputePromise) { + current.recomputePromise = null; + current.recomputeVersion = null; + } + }); + + state.recomputePromise = recomputePromise; + state.recomputeVersion = recomputeVersion; + return recomputePromise; + } + + private async computeSnapshot(context: { + projectDir: string; + projectPath?: string; + leadSessionId?: string; + sessionIds: string[]; + }): Promise { + const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath); + const parts: string[] = []; + + if (context.leadSessionId) { + const leadLogPath = path.join(context.projectDir, `${context.leadSessionId}.jsonl`); + parts.push(await this.describePath('lead', leadLogPath)); + } + + for (const sessionId of [...context.sessionIds].sort((a, b) => a.localeCompare(b))) { + const sessionDir = path.join(context.projectDir, sessionId); + const subagentsDir = path.join(sessionDir, 'subagents'); + parts.push(await this.describePath('session', sessionDir)); + parts.push(await this.describePath('subagents', subagentsDir)); + + let entries: string[] = []; + try { + entries = await fs.readdir(subagentsDir); + } catch { + entries = []; + } + + for (const fileName of entries + .filter( + (entry) => + entry.startsWith('agent-') && + entry.endsWith('.jsonl') && + !entry.startsWith('agent-acompact') + ) + .sort((a, b) => a.localeCompare(b))) { + parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName))); + } + } + + const sourceMaterial = + parts.length > 0 + ? parts.join('|') + : `empty:${normalizeTaskChangePresenceFilePath(context.projectDir)}`; + + return { + projectFingerprint, + logSourceGeneration: createHash('sha256').update(sourceMaterial).digest('hex'), + }; + } + + private async describePath(kind: string, targetPath: string): Promise { + const normalizedPath = normalizeTaskChangePresenceFilePath(targetPath); + try { + const stats = await fs.stat(targetPath); + const type = stats.isDirectory() ? 'dir' : 'file'; + return `${kind}:${type}:${normalizedPath}:${stats.size}:${stats.mtimeMs}`; + } catch { + return `${kind}:missing:${normalizedPath}`; + } + } +} diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 6bdfa8d5..ea15c5fb 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -173,6 +173,32 @@ export class TeamMemberLogsFinder { ); } + async getLogSourceWatchContext( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise<{ + projectDir: string; + projectPath?: string; + leadSessionId?: string; + sessionIds: string[]; + } | null> { + if (options?.forceRefresh) { + this.discoveryCache.delete(teamName); + } + + const discovery = await this.discoverProjectSessions(teamName); + if (!discovery) { + return null; + } + + return { + projectDir: discovery.projectDir, + projectPath: discovery.config.projectPath, + leadSessionId: discovery.config.leadSessionId, + sessionIds: [...discovery.sessionIds], + }; + } + /** * Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.). * When the task is in_progress and has an owner, also includes that owner's session logs so diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts new file mode 100644 index 00000000..1d1df59b --- /dev/null +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -0,0 +1,140 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { getTaskChangePresenceBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + normalizePersistedTaskChangePresenceIndex, + toPersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheSchema'; +import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes'; + +import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; +import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes'; + +const logger = createLogger('Service:JsonTaskChangePresenceRepository'); + +const READ_TIMEOUT_MS = 5_000; + +function encodeFileSegment(value: string): string { + return encodeURIComponent(value); +} + +export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepository { + private readonly writeChains = new Map>(); + + private get basePath(): string { + return getTaskChangePresenceBasePath(); + } + + private filePath(teamName: string): string { + return path.join(this.basePath, `${encodeFileSegment(teamName)}.json`); + } + + private async readIndex(teamName: string): Promise { + const filePath = this.filePath(teamName); + let content: string; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS); + try { + content = await fs.promises.readFile(filePath, { + encoding: 'utf8', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.warn(`Failed to read task-change presence index ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + logger.warn(`Corrupted task-change presence index ${filePath}: ${String(error)}`); + await fs.promises.unlink(filePath).catch(() => undefined); + return null; + } + + const normalized = normalizePersistedTaskChangePresenceIndex(parsed); + if (!normalized) { + await fs.promises.unlink(filePath).catch(() => undefined); + return null; + } + + return normalized; + } + + async load(teamName: string): Promise { + return this.readIndex(teamName); + } + + async upsertEntry( + teamName: string, + metadata: { + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + }, + entry: { + taskId: string; + taskSignature: string; + presence: 'has_changes' | 'no_changes'; + writtenAt: string; + logSourceGeneration: string; + } + ): Promise { + const write = async (): Promise => { + const current = + (await this.readIndex(teamName)) ?? + ({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName, + projectFingerprint: metadata.projectFingerprint, + logSourceGeneration: metadata.logSourceGeneration, + writtenAt: metadata.writtenAt, + entries: {}, + } satisfies PersistedTaskChangePresenceIndex); + + const next = toPersistedTaskChangePresenceIndex({ + ...current, + projectFingerprint: metadata.projectFingerprint, + logSourceGeneration: metadata.logSourceGeneration, + writtenAt: metadata.writtenAt, + entries: { + ...current.entries, + [entry.taskId]: { + taskId: entry.taskId, + taskSignature: entry.taskSignature, + presence: entry.presence, + writtenAt: entry.writtenAt, + logSourceGeneration: entry.logSourceGeneration, + }, + }, + }); + + await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2)); + }; + + const previous = this.writeChains.get(teamName) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(write) + .finally(() => { + if (this.writeChains.get(teamName) === next) { + this.writeChains.delete(teamName); + } + }); + + this.writeChains.set(teamName, next); + await next; + } +} diff --git a/src/main/services/team/cache/TaskChangePresenceRepository.ts b/src/main/services/team/cache/TaskChangePresenceRepository.ts new file mode 100644 index 00000000..e07910fa --- /dev/null +++ b/src/main/services/team/cache/TaskChangePresenceRepository.ts @@ -0,0 +1,23 @@ +import type { + PersistedTaskChangePresence, + PersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheTypes'; + +export interface TaskChangePresenceRepository { + load(teamName: string): Promise; + upsertEntry( + teamName: string, + metadata: { + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + }, + entry: { + taskId: string; + taskSignature: string; + presence: PersistedTaskChangePresence; + writtenAt: string; + logSourceGeneration: string; + } + ): Promise; +} diff --git a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts new file mode 100644 index 00000000..16c5f78b --- /dev/null +++ b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts @@ -0,0 +1,107 @@ +import { + TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + type PersistedTaskChangePresence, + type PersistedTaskChangePresenceEntry, + type PersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheTypes'; + +function isIsoString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value)); +} + +function normalizePresence(value: unknown): PersistedTaskChangePresence | null { + return value === 'has_changes' || value === 'no_changes' ? value : null; +} + +function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + const normalizedPresence = normalizePresence(raw.presence); + if ( + typeof raw.taskSignature !== 'string' || + !normalizedPresence || + !isIsoString(raw.writtenAt) || + typeof raw.logSourceGeneration !== 'string' || + raw.logSourceGeneration.length === 0 + ) { + return null; + } + + return { + taskId, + taskSignature: raw.taskSignature, + presence: normalizedPresence, + writtenAt: raw.writtenAt, + logSourceGeneration: raw.logSourceGeneration, + }; +} + +export function normalizePersistedTaskChangePresenceIndex( + value: unknown +): PersistedTaskChangePresenceIndex | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + if ( + raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION || + typeof raw.teamName !== 'string' || + typeof raw.projectFingerprint !== 'string' || + raw.projectFingerprint.length === 0 || + typeof raw.logSourceGeneration !== 'string' || + raw.logSourceGeneration.length === 0 || + !isIsoString(raw.writtenAt) || + !raw.entries || + typeof raw.entries !== 'object' + ) { + return null; + } + + const normalizedEntries: Record = {}; + for (const [taskId, entryValue] of Object.entries(raw.entries as Record)) { + if (typeof taskId !== 'string' || taskId.length === 0) { + continue; + } + const normalized = normalizeEntry(taskId, entryValue); + if (normalized) { + normalizedEntries[taskId] = normalized; + } + } + + return { + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: raw.teamName, + projectFingerprint: raw.projectFingerprint, + logSourceGeneration: raw.logSourceGeneration, + writtenAt: raw.writtenAt, + entries: normalizedEntries, + }; +} + +export function toPersistedTaskChangePresenceIndex( + value: PersistedTaskChangePresenceIndex +): PersistedTaskChangePresenceIndex { + return { + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: value.teamName, + projectFingerprint: value.projectFingerprint, + logSourceGeneration: value.logSourceGeneration, + writtenAt: value.writtenAt, + entries: Object.fromEntries( + Object.entries(value.entries).map(([taskId, entry]) => [ + taskId, + { + taskId, + taskSignature: entry.taskSignature, + presence: entry.presence, + writtenAt: entry.writtenAt, + logSourceGeneration: entry.logSourceGeneration, + }, + ]) + ), + }; +} diff --git a/src/main/services/team/cache/taskChangePresenceCacheTypes.ts b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts new file mode 100644 index 00000000..f06f853f --- /dev/null +++ b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts @@ -0,0 +1,22 @@ +import type { TaskChangePresenceState } from '@shared/types/team'; + +export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1; + +export type PersistedTaskChangePresence = Exclude; + +export interface PersistedTaskChangePresenceEntry { + taskId: string; + taskSignature: string; + presence: PersistedTaskChangePresence; + writtenAt: string; + logSourceGeneration: string; +} + +export interface PersistedTaskChangePresenceIndex { + version: typeof TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION; + teamName: string; + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + entries: Record; +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 7cfb94e8..e2df6084 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -17,6 +17,7 @@ export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxWriter } from './TeamInboxWriter'; export { TeamKanbanManager } from './TeamKanbanManager'; export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +export { TeamLogSourceTracker } from './TeamLogSourceTracker'; export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMembersMetaStore } from './TeamMembersMetaStore'; export { TeamProvisioningService } from './TeamProvisioningService'; diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts new file mode 100644 index 00000000..58cc6c90 --- /dev/null +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -0,0 +1,152 @@ +import { + getTaskChangeStateBucket, + type TaskChangeStateBucket, +} from '@shared/utils/taskChangeState'; +import { createHash } from 'crypto'; + +export interface TaskChangePresenceInterval { + startedAt: string; + completedAt?: string; +} + +export interface TaskChangePresenceDescriptorInput { + owner?: string; + status?: string; + intervals?: TaskChangePresenceInterval[]; + since?: string; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; +} + +export interface TaskChangePresenceDescriptor { + stateBucket: TaskChangeStateBucket; + taskSignature: string; + effectiveOptions: { + owner?: string; + status?: string; + intervals?: TaskChangePresenceInterval[]; + since?: string; + }; +} + +function deriveIntervalsFromHistory( + historyEvents?: unknown[] +): TaskChangePresenceInterval[] | undefined { + if (!Array.isArray(historyEvents) || historyEvents.length === 0) { + return undefined; + } + + const transitions = historyEvents + .map((event) => + event && typeof event === 'object' ? (event as Record) : null + ) + .filter((event): event is Record => event !== null) + .filter((event) => event.type === 'status_changed') + .map((event) => ({ + to: typeof event.to === 'string' ? event.to : null, + timestamp: typeof event.timestamp === 'string' ? event.timestamp : null, + })) + .filter( + (transition): transition is { to: string; timestamp: string } => + transition.to !== null && transition.timestamp !== null + ) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + + if (transitions.length === 0) { + return undefined; + } + + const derived: TaskChangePresenceInterval[] = []; + let currentStart: string | null = null; + + for (const transition of transitions) { + if (transition.to === 'in_progress') { + if (!currentStart) { + currentStart = transition.timestamp; + } + continue; + } + + if (currentStart) { + derived.push({ startedAt: currentStart, completedAt: transition.timestamp }); + currentStart = null; + } + } + + if (currentStart) { + derived.push({ startedAt: currentStart }); + } + + return derived.length > 0 ? derived : undefined; +} + +export function normalizeTaskChangePresenceFilePath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); +} + +export function computeTaskChangePresenceProjectFingerprint( + projectPath?: string | null +): string | null { + const normalizedProjectPath = typeof projectPath === 'string' ? projectPath.trim() : ''; + if (!normalizedProjectPath) { + return null; + } + + return createHash('sha256') + .update(normalizeTaskChangePresenceFilePath(normalizedProjectPath)) + .digest('hex'); +} + +export function buildTaskChangePresenceDescriptor( + input: TaskChangePresenceDescriptorInput +): TaskChangePresenceDescriptor { + const effectiveIntervals = + Array.isArray(input.intervals) && input.intervals.length > 0 + ? input.intervals.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt ?? '', + })) + : (deriveIntervalsFromHistory(input.historyEvents)?.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt ?? '', + })) ?? []); + + const stateBucket = getTaskChangeStateBucket({ + status: input.status, + reviewState: input.reviewState, + historyEvents: input.historyEvents, + kanbanColumn: input.kanbanColumn, + }); + + const effectiveOptions = { + owner: typeof input.owner === 'string' ? input.owner.trim() : '', + status: typeof input.status === 'string' ? input.status.trim() : '', + intervals: effectiveIntervals, + since: typeof input.since === 'string' ? input.since : '', + }; + + return { + stateBucket, + taskSignature: JSON.stringify({ + owner: effectiveOptions.owner, + status: effectiveOptions.status, + since: effectiveOptions.since, + stateBucket, + intervals: effectiveIntervals, + }), + effectiveOptions: { + owner: effectiveOptions.owner || undefined, + status: effectiveOptions.status || undefined, + intervals: + effectiveIntervals.length > 0 + ? effectiveIntervals.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt || undefined, + })) + : undefined, + since: effectiveOptions.since || undefined, + }, + }; +} diff --git a/src/main/services/team/taskChangeWorkerTypes.ts b/src/main/services/team/taskChangeWorkerTypes.ts new file mode 100644 index 00000000..5bc0f105 --- /dev/null +++ b/src/main/services/team/taskChangeWorkerTypes.ts @@ -0,0 +1,49 @@ +import type { TaskChangeSetV2 } from '@shared/types'; + +export interface TaskChangeTaskMeta { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; +} + +export interface TaskChangeEffectiveOptions { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; +} + +export interface ResolvedTaskChangeComputeInput { + teamName: string; + taskId: string; + taskMeta: TaskChangeTaskMeta | null; + effectiveOptions: TaskChangeEffectiveOptions; + projectPath?: string; + includeDetails: boolean; +} + +export interface ComputeTaskChangesRequest { + id: string; + op: 'computeTaskChanges'; + payload: ResolvedTaskChangeComputeInput; +} + +export interface ComputeTaskChangesSuccessResponse { + id: string; + ok: true; + result: TaskChangeSetV2; +} + +export interface ComputeTaskChangesErrorResponse { + id: string; + ok: false; + error: string; +} + +export type TaskChangeWorkerRequest = ComputeTaskChangesRequest; +export type TaskChangeWorkerResponse = + | ComputeTaskChangesSuccessResponse + | ComputeTaskChangesErrorResponse; diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 4590181c..f302ff1a 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -393,6 +393,10 @@ export function getTaskChangeSummariesBasePath(): string { return path.join(getClaudeBasePath(), 'task-change-summaries'); } +export function getTaskChangePresenceBasePath(): string { + return path.join(getClaudeBasePath(), 'task-change-presence'); +} + /** * Get the backups directory path for the app's own storage. */ diff --git a/src/main/workers/task-change-worker.ts b/src/main/workers/task-change-worker.ts new file mode 100644 index 00000000..06f92042 --- /dev/null +++ b/src/main/workers/task-change-worker.ts @@ -0,0 +1,40 @@ +import { parentPort } from 'node:worker_threads'; + +import { TaskBoundaryParser } from '@main/services/team/TaskBoundaryParser'; +import { TaskChangeComputer } from '@main/services/team/TaskChangeComputer'; +import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; + +import type { + TaskChangeWorkerRequest, + TaskChangeWorkerResponse, +} from '@main/services/team/taskChangeWorkerTypes'; + +const logsFinder = new TeamMemberLogsFinder(); +const boundaryParser = new TaskBoundaryParser(); +const computer = new TaskChangeComputer(logsFinder, boundaryParser); + +function postMessage(message: TaskChangeWorkerResponse): void { + parentPort?.postMessage(message); +} + +parentPort?.on('message', async (message: TaskChangeWorkerRequest) => { + if (!message || message.op !== 'computeTaskChanges') { + postMessage({ + id: message?.id ?? 'unknown', + ok: false, + error: `Unsupported task change worker op: ${String(message?.op)}`, + }); + return; + } + + try { + const result = await computer.computeTaskChanges(message.payload); + postMessage({ id: message.id, ok: true, result }); + } catch (error) { + postMessage({ + id: message.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 7ced81a6..30ac6dee 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -210,6 +210,12 @@ export const TEAM_LIST = 'team:list'; /** Get detailed team data */ export const TEAM_GET_DATA = 'team:getData'; +/** Get lightweight task change presence map for the currently viewed team */ +export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence'; + +/** Enable or disable task change presence tracking for a visible team tab */ +export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking'; + /** Get buffered Claude CLI logs (paged, newest-first) */ export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; diff --git a/src/preload/index.ts b/src/preload/index.ts index e9f97771..df252ede 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,6 +121,7 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -148,6 +149,7 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -260,6 +262,7 @@ import type { SshConnectionStatus, SshLastConnection, TaskAttachmentMeta, + TaskChangePresenceState, TaskChangeSetV2, TaskComment, TeamChangeEvent, @@ -800,6 +803,15 @@ const electronAPI: ElectronAPI = { getData: async (teamName: string) => { return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, + getTaskChangePresence: async (teamName: string) => { + return invokeIpcWithResult>( + TEAM_GET_TASK_CHANGE_PRESENCE, + teamName + ); + }, + setChangePresenceTracking: async (teamName: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled); + }, getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 2160f340..20a6568a 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -668,6 +668,14 @@ export class HttpAPIClient implements ElectronAPI { getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, + getTaskChangePresence: async (): Promise< + Record + > => { + return {}; + }, + setChangePresenceTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, getClaudeLogs: async ( _teamName: string, _query?: TeamClaudeLogsQuery diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index c43125ff..e3466adf 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { useStore } from '@renderer/store'; import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; @@ -22,11 +23,16 @@ export const TeamTabSectionNav = ({ teamName, onActivate, }: TeamTabSectionNavProps): React.JSX.Element => { + const messagesPanelMode = useStore((s) => s.messagesPanelMode); const [open, setOpen] = useState(false); const [hoveredId, setHoveredId] = useState(null); const buttonRef = useRef(null); const menuRef = useRef(null); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); + const visibleSections = SECTIONS.filter( + (section) => + messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs') + ); const handleNavigate = useCallback( (sectionId: string) => { @@ -99,7 +105,7 @@ export const TeamTabSectionNav = ({ if (e.key === 'Escape') setOpen(false); }} > - {SECTIONS.map((section) => { + {visibleSections.map((section) => { const SectionIcon = section.icon; return ( @@ -215,246 +216,201 @@ const TaskActionIconButton = ({ ); -export const KanbanTaskCard = ({ - task, - teamName, - columnId, - kanbanTaskState, - hasReviewers, - compact, - taskMap, - members, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onDeleteTask, -}: KanbanTaskCardProps): React.JSX.Element => { - const colorMap = useMemo(() => buildMemberColorMap(members), [members]); - const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); - const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; - const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; - const hasBlockedBy = blockedByIds.length > 0; - const hasBlocks = blocksIds.length > 0; - - // Lazy-check if task has file changes - const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); - const canDisplay = useMemo( - () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, - [taskChangeRequestOptions, onViewChanges] - ); - const cacheKey = useMemo( - () => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions), - [teamName, task.id, taskChangeRequestOptions] - ); - const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]); - const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); - - useEffect(() => { - if (canDisplay && taskHasChanges === undefined) { - void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions); - } - }, [ - canDisplay, - task.id, +export const KanbanTaskCard = memo( + function KanbanTaskCard({ + task, teamName, - taskHasChanges, - checkTaskHasChanges, - taskChangeRequestOptions, - ]); + columnId, + kanbanTaskState, + hasReviewers, + compact, + taskMap, + memberColorMap, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onDeleteTask, + }: KanbanTaskCardProps): React.JSX.Element { + const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); + const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; + const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; + const hasBlockedBy = blockedByIds.length > 0; + const hasBlocks = blocksIds.length > 0; - const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; - const metaActions = ( - <> - {canDisplay && taskHasChanges === true ? ( - } - variant="ghost" - className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" - onClick={(e) => { - e.stopPropagation(); - onViewChanges!(task.id); - }} - /> - ) : null} - - {onDeleteTask ? ( - } - variant="ghost" - className="text-red-400 hover:bg-red-500/10 hover:text-red-300" - onClick={(e) => { - e.stopPropagation(); - onDeleteTask(task.id); - }} - /> - ) : null} - - ); + const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); + const canDisplay = useMemo( + () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, + [taskChangeRequestOptions, onViewChanges] + ); - return ( -
onTaskClick?.(task)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onTaskClick?.(task); - } - }} - > - - {formatTaskDisplayLabel(task)} - - {task.owner ? ( - - + const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; + const metaActions = ( + <> + {canDisplay && task.changePresence === 'has_changes' ? ( + } + variant="ghost" + className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" + onClick={(e) => { + e.stopPropagation(); + onViewChanges!(task.id); + }} + /> + ) : canDisplay && task.changePresence === 'no_changes' ? ( + + No changes + + ) : null} + + {onDeleteTask ? ( + } + variant="ghost" + className="text-red-400 hover:bg-red-500/10 hover:text-red-300" + onClick={(e) => { + e.stopPropagation(); + onDeleteTask(task.id); + }} + /> + ) : null} + + ); + + return ( +
onTaskClick?.(task)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onTaskClick?.(task); + } + }} + > + + {formatTaskDisplayLabel(task)} - ) : null} -
- {!compact && } - {task.needsClarification ? ( - - - {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} + {task.owner ? ( + + ) : null} - {task.reviewState === 'needsFix' ? ( - - {REVIEW_STATE_DISPLAY.needsFix.label} - +
+ {!compact && } + {task.needsClarification ? ( + + + {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} + + ) : null} + {task.reviewState === 'needsFix' ? ( + + {REVIEW_STATE_DISPLAY.needsFix.label} + + ) : null} + {compact && } +
+ + {hasBlockedBy ? ( +
+ + + Blocked by + + {blockedByIds.map((id) => ( + + ))} +
) : null} - {compact && } -
- {hasBlockedBy ? ( -
- - - Blocked by - - {blockedByIds.map((id) => ( - - ))} -
- ) : null} - - {hasBlocks ? ( -
- - - Blocks - - {blocksIds.map((id) => ( - - ))} -
- ) : null} - -
-
- {columnId === 'todo' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onStartTask(task.id); - }} + {hasBlocks ? ( +
+ + + Blocks + + {blocksIds.map((id) => ( + - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - ) : null} + ))} +
+ ) : null} - {columnId === 'in_progress' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - - ) : null} +
+
+ {columnId === 'todo' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onStartTask(task.id); + }} + /> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(task.id); + }} + /> + + ) : null} - {columnId === 'done' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onApprove(task.id); - }} - /> - } - className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" - onClick={(e) => { - e.stopPropagation(); - onRequestReview(task.id); - }} - /> - - ) : null} + {columnId === 'in_progress' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(task.id); + }} + /> + + + ) : null} - {columnId === 'review' ? ( -
- {isReviewManual ? ( -
- Manual review -
- ) : null} -
+ {columnId === 'done' ? ( + <> } @@ -465,34 +421,84 @@ export const KanbanTaskCard = ({ }} /> } - variant="destructive" - className="bg-red-500/90 text-white hover:bg-red-500" + label="Request review" + icon={} + className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" onClick={(e) => { e.stopPropagation(); - onRequestChanges(task.id); + onRequestReview(task.id); }} /> + + ) : null} + + {columnId === 'review' ? ( +
+ {isReviewManual ? ( +
+ Manual review +
+ ) : null} +
+ } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onApprove(task.id); + }} + /> + } + variant="destructive" + className="bg-red-500/90 text-white hover:bg-red-500" + onClick={(e) => { + e.stopPropagation(); + onRequestChanges(task.id); + }} + /> +
-
- ) : null} + ) : null} - {columnId === 'approved' ? ( - } - className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" - onClick={(e) => { - e.stopPropagation(); - onMoveBackToDone(task.id); - }} - /> - ) : null} + {columnId === 'approved' ? ( + } + className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" + onClick={(e) => { + e.stopPropagation(); + onMoveBackToDone(task.id); + }} + /> + ) : null} +
+ +
{metaActions}
- -
{metaActions}
-
- ); -}; + ); + }, + (prev, next) => + prev.task === next.task && + prev.teamName === next.teamName && + prev.columnId === next.columnId && + prev.kanbanTaskState === next.kanbanTaskState && + prev.hasReviewers === next.hasReviewers && + prev.compact === next.compact && + prev.taskMap === next.taskMap && + prev.memberColorMap === next.memberColorMap && + prev.onRequestReview === next.onRequestReview && + prev.onApprove === next.onApprove && + prev.onRequestChanges === next.onRequestChanges && + prev.onMoveBackToDone === next.onMoveBackToDone && + prev.onStartTask === next.onStartTask && + prev.onCompleteTask === next.onCompleteTask && + prev.onCancelTask === next.onCancelTask && + prev.onScrollToTask === next.onScrollToTask && + prev.onTaskClick === next.onTaskClick && + prev.onViewChanges === next.onViewChanges && + prev.onDeleteTask === next.onDeleteTask +); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 28c9506e..418a64e8 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -5,6 +5,11 @@ import { api } from '@renderer/api'; import { syncRendererTelemetry } from '@renderer/sentry'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; +import { + buildTaskChangePresenceKey, + buildTaskChangeRequestOptions, + canDisplayTaskChangesForOptions, +} from '@renderer/utils/taskChangeRequest'; import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; @@ -41,6 +46,9 @@ import type { UpdaterStatus, } from '@shared/types'; +const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false; +const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000; + // ============================================================================= // Store Creation // ============================================================================= @@ -135,15 +143,25 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); }); + const inProgressChangePresencePollTimer = setInterval(() => { + void pollVisibleTeamInProgressChangePresence(); + }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); + cleanupFns.push(() => { + clearInterval(inProgressChangePresencePollTimer); + }); const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); let teamRefreshTimers = new Map>(); + let teamPresenceRefreshTimers = new Map>(); + let inProgressChangePresencePollInFlight = false; + const inProgressChangePresenceCursorByTeam = new Map(); let teamListRefreshTimer: ReturnType | null = null; let globalTasksRefreshTimer: ReturnType | null = null; const SESSION_REFRESH_DEBOUNCE_MS = 150; const PROJECT_REFRESH_DEBOUNCE_MS = 300; const TEAM_REFRESH_THROTTLE_MS = 800; + const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; const getBaseProjectId = (projectId: string | null | undefined): string | null => { @@ -152,6 +170,69 @@ export function initializeNotificationListeners(): () => void { return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId; }; + const pollVisibleTeamInProgressChangePresence = async (): Promise => { + if (inProgressChangePresencePollInFlight) { + return; + } + + const state = useStore.getState(); + const selectedTeamName = state.selectedTeamName; + const selectedTeamData = state.selectedTeamData; + if ( + !selectedTeamName || + !selectedTeamData || + selectedTeamData.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return; + } + + const candidateTasks = selectedTeamData.tasks.filter((task) => { + if (task.status !== 'in_progress') { + return false; + } + return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task)); + }); + if (candidateTasks.length === 0) { + inProgressChangePresenceCursorByTeam.delete(selectedTeamName); + return; + } + + inProgressChangePresencePollInFlight = true; + try { + const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0; + const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); + const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; + const nextTask = sourceTasks[cursor % sourceTasks.length]; + + inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length); + + const current = useStore.getState(); + if ( + current.selectedTeamName !== selectedTeamName || + !current.selectedTeamData || + current.selectedTeamData.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return; + } + + const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id); + if (!currentTask || currentTask.status !== 'in_progress') { + return; + } + + const requestOptions = buildTaskChangeRequestOptions(currentTask); + const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions); + current.invalidateTaskChangePresence([cacheKey]); + await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions); + } catch { + // Best-effort polling for in-progress tasks only. + } finally { + inProgressChangePresencePollInFlight = false; + } + }; + const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { const key = `${projectId}/${sessionId}`; // Throttle (not trailing debounce): keep at most one pending refresh per session. @@ -257,6 +338,61 @@ export function initializeNotificationListeners(): () => void { }); }; + const getTrackedChangePresenceTeams = (): Set => { + const { selectedTeamName, selectedTeamData } = useStore.getState(); + if ( + !selectedTeamName || + !selectedTeamData || + selectedTeamData.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return new Set(); + } + return new Set([selectedTeamName]); + }; + + if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) { + let trackedTeamNames = new Set(); + const syncVisibleTeamTracking = (): void => { + const nextTrackedTeamNames = getTrackedChangePresenceTeams(); + + for (const teamName of nextTrackedTeamNames) { + if (!trackedTeamNames.has(teamName)) { + void api.teams.setChangePresenceTracking(teamName, true).catch(() => undefined); + } + } + + for (const teamName of trackedTeamNames) { + if (!nextTrackedTeamNames.has(teamName)) { + void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined); + } + } + + trackedTeamNames = nextTrackedTeamNames; + }; + + syncVisibleTeamTracking(); + + const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => { + if ( + state.paneLayout === prevState.paneLayout && + state.selectedTeamName === prevState.selectedTeamName && + state.selectedTeamData === prevState.selectedTeamData + ) { + return; + } + syncVisibleTeamTracking(); + }); + + cleanupFns.push(() => { + unsubscribeVisibleTeamTracking(); + for (const teamName of trackedTeamNames) { + void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined); + } + trackedTeamNames.clear(); + }); + } + // Listen for task-list file changes to refresh currently viewed session metadata if (api.onTodoChange) { const cleanup = api.onTodoChange((event) => { @@ -474,6 +610,22 @@ export function initializeNotificationListeners(): () => void { return; } + if (event.type === 'log-source-change') { + if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { + return; + } + if (teamPresenceRefreshTimers.has(event.teamName)) { + return; + } + const timer = setTimeout(() => { + teamPresenceRefreshTimers.delete(event.teamName); + const current = useStore.getState(); + void current.refreshSelectedTeamChangePresence(event.teamName); + }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); + teamPresenceRefreshTimers.set(event.teamName, timer); + return; + } + // Throttled refresh of summary list (keeps TeamListView current without flooding). if (!teamListRefreshTimer) { teamListRefreshTimer = setTimeout(() => { @@ -513,6 +665,8 @@ export function initializeNotificationListeners(): () => void { cleanup(); for (const t of teamRefreshTimers.values()) clearTimeout(t); teamRefreshTimers = new Map(); + for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t); + teamPresenceRefreshTimers = new Map(); if (teamListRefreshTimer) { clearTimeout(teamListRefreshTimer); teamListRefreshTimer = null; diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index aceba6dd..98044fb2 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -16,6 +16,7 @@ const taskChangesPresenceRevalidationInFlight = new Set(); /** Negative results cached with timestamp — recheck after 30s */ const taskChangesNegativeCache = new Map(); const NEGATIVE_CACHE_TTL = 30_000; +const TASK_CHANGE_WARM_CONCURRENCY = 4; const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now(); let latestTaskChangesRequestToken = 0; @@ -77,6 +78,16 @@ function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean { return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME; } +function resolveTaskChangePresenceFromResult( + data: Pick +): 'has_changes' | 'no_changes' | null { + if (data.files.length > 0) { + return 'has_changes'; + } + + return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +} + export interface ChangeReviewSlice { // Phase 1 state activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null; @@ -503,10 +514,14 @@ export const createChangeReviewSlice: StateCreator 0 }, }); + if (nextPresence) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence); + } if (data.files.length > 0) { taskChangesNegativeCache.delete(cacheKey); } else { @@ -1310,12 +1325,20 @@ export const createChangeReviewSlice: StateCreator { + const selectedTask = + get().selectedTeamName === teamName + ? get().selectedTeamData?.tasks.find((task) => task.id === taskId) + : undefined; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); const summaryCacheable = isTaskSummaryCacheableForOptions(options); - if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return; + if (summaryCacheable && get().taskHasChanges[cacheKey] === true) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); + return; + } if (taskChangesCheckInFlight.has(cacheKey)) return; const negativeTs = taskChangesNegativeCache.get(cacheKey); - if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return; + const hasUnknownPresence = selectedTask?.changePresence === 'unknown'; + if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL && !hasUnknownPresence) return; taskChangesCheckInFlight.add(cacheKey); try { @@ -1323,11 +1346,13 @@ export const createChangeReviewSlice: StateCreator 0) { set((s) => ({ taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, })); taskChangesNegativeCache.delete(cacheKey); + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); if (wasRestoredBeforeCurrentSession(data)) { void revalidateTaskChangePresence(teamName, taskId, options); } @@ -1336,6 +1361,11 @@ export const createChangeReviewSlice: StateCreator { - if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) - return; + const entries = [...uniqueRequests.entries()]; + const runWarmRequest = async ( + cacheKey: string, + request: { teamName: string; taskId: string; options: TaskChangeRequestOptions } + ): Promise => { + if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) { + return; + } - taskChangesCheckInFlight.add(cacheKey); - try { - const data = await api.review.getTaskChanges(request.teamName, request.taskId, { - ...request.options, - summaryOnly: true, - }); - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, - })); - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - if (wasRestoredBeforeCurrentSession(data)) { - void revalidateTaskChangePresence( - request.teamName, - request.taskId, - request.options - ); - } - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); + taskChangesCheckInFlight.add(cacheKey); + try { + const data = await api.review.getTaskChanges(request.teamName, request.taskId, { + ...request.options, + summaryOnly: true, + }); + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, + })); + if (data.files.length > 0) { + taskChangesNegativeCache.delete(cacheKey); + if (wasRestoredBeforeCurrentSession(data)) { + void revalidateTaskChangePresence(request.teamName, request.taskId, request.options); } - } catch { - // Best-effort warm path. - } finally { - taskChangesCheckInFlight.delete(cacheKey); + } else { + taskChangesNegativeCache.set(cacheKey, Date.now()); } - }) - ); + } catch { + // Best-effort warm path. + } finally { + taskChangesCheckInFlight.delete(cacheKey); + } + }; + + for (let index = 0; index < entries.length; index += TASK_CHANGE_WARM_CONCURRENCY) { + await Promise.all( + entries + .slice(index, index + TASK_CHANGE_WARM_CONCURRENCY) + .map(([cacheKey, request]) => runWarmRequest(cacheKey, request)) + ); + } }, invalidateTaskChangePresence: (cacheKeys) => { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 7d84b85c..05d0c52c 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -3,7 +3,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, - isTaskSummaryCacheableForOptions, + canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; @@ -57,6 +57,43 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } +async function refreshTaskChangePresenceForUpdatedTask( + getState: () => AppState, + teamName: string, + taskId: string +): Promise { + const state = getState(); + if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + return; + } + + const task = state.selectedTeamData.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + return; + } + + const options = buildTaskChangeRequestOptions(task); + if (!canDisplayTaskChangesForOptions(options)) { + return; + } + + if ( + typeof state.invalidateTaskChangePresence !== 'function' || + typeof state.checkTaskHasChanges !== 'function' + ) { + return; + } + + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); + state.invalidateTaskChangePresence([cacheKey]); + + try { + await state.checkTaskHasChanges(teamName, taskId, options); + } catch { + // Best-effort refresh after explicit task transition. + } +} + async function pollProvisioningStatus( getState: () => TeamSlice, runId: string, @@ -105,6 +142,7 @@ import type { SendMessageRequest, SendMessageResult, TaskComment, + TaskChangePresenceState, TeamCreateRequest, TeamData, TeamLaunchRequest, @@ -445,19 +483,6 @@ function collectTaskChangeInvalidationState( }; } -function buildTaskChangeWarmRequests( - teamName: string, - tasks: TeamData['tasks'] -): { teamName: string; taskId: string; options: TaskChangeRequestOptions }[] { - return tasks.flatMap((task) => { - const options = buildTaskChangeRequestOptions(task); - if (!isTaskSummaryCacheableForOptions(options)) { - return []; - } - return [{ teamName, taskId: task.id, options }]; - }); -} - function mapSendMessageError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; @@ -556,6 +581,12 @@ export interface TeamSlice { openTeamsTab: () => void; openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void; clearKanbanFilter: () => void; + setSelectedTeamTaskChangePresence: ( + teamName: string, + taskId: string, + presence: TaskChangePresenceState + ) => void; + refreshSelectedTeamChangePresence: (teamName: string) => Promise; selectTeam: ( teamName: string, opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } @@ -1099,6 +1130,89 @@ export const createTeamSlice: StateCreator = (set, set({ kanbanFilterQuery: null }); }, + setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => { + set((state) => { + let selectedChanged = false; + const nextSelectedTeamData = + state.selectedTeamName === teamName && state.selectedTeamData + ? { + ...state.selectedTeamData, + tasks: state.selectedTeamData.tasks.map((task) => { + if (task.id !== taskId || task.changePresence === presence) { + return task; + } + selectedChanged = true; + return { ...task, changePresence: presence }; + }), + } + : state.selectedTeamData; + + let globalChanged = false; + const nextGlobalTasks = state.globalTasks.map((task) => { + if (task.teamName !== teamName || task.id !== taskId || task.changePresence === presence) { + return task; + } + globalChanged = true; + return { ...task, changePresence: presence }; + }); + + if (!selectedChanged && !globalChanged) { + return {}; + } + + return { + ...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}), + ...(globalChanged ? { globalTasks: nextGlobalTasks } : {}), + }; + }); + }, + + refreshSelectedTeamChangePresence: async (teamName: string) => { + const selected = get().selectedTeamData; + if (get().selectedTeamName !== teamName || !selected) { + return; + } + + try { + const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () => + api.teams.getTaskChangePresence(teamName) + ); + + if (get().selectedTeamName !== teamName || !get().selectedTeamData) { + return; + } + + set((state) => { + if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + return {}; + } + + let changed = false; + const nextTasks = state.selectedTeamData.tasks.map((task) => { + const nextPresence = presenceByTaskId[task.id] ?? 'unknown'; + if (task.changePresence === nextPresence) { + return task; + } + changed = true; + return { ...task, changePresence: nextPresence }; + }); + + if (!changed) { + return {}; + } + + return { + selectedTeamData: { + ...state.selectedTeamData, + tasks: nextTasks, + }, + }; + }); + } catch { + // best-effort lightweight refresh; keep current UI state on failure + } + }, + selectTeam: async (teamName: string, opts) => { const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. @@ -1170,11 +1284,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); - if (warmRequests.length > 0) { - void get().warmTaskChangeSummaries(warmRequests); - } - // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; const allTabs = get().getAllPaneTabs(); @@ -1295,10 +1404,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); - if (warmRequests.length > 0) { - void get().warmTaskChangeSummaries(warmRequests); - } } catch (error) { if (get().selectedTeamName !== teamName) { return; @@ -1424,6 +1529,7 @@ export const createTeamSlice: StateCreator = (set, set({ reviewActionError: null }); await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId)); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); } catch (error) { set({ reviewActionError: mapReviewError(error), @@ -1441,6 +1547,7 @@ export const createTeamSlice: StateCreator = (set, startTask: async (teamName: string, taskId: string) => { const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId)); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); return result; }, @@ -1449,6 +1556,7 @@ export const createTeamSlice: StateCreator = (set, api.teams.startTaskByUser(teamName, taskId) ); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); return result; }, @@ -1457,6 +1565,7 @@ export const createTeamSlice: StateCreator = (set, api.teams.updateTaskStatus(teamName, taskId, status) ); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); }, updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 5bae8b21..88aa7202 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -73,6 +73,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TaskChangePresenceState, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -416,6 +417,8 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; getData: (teamName: string) => Promise; + getTaskChangePresence: (teamName: string) => Promise>; + setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise; deleteTeam: (teamName: string) => Promise; restoreTeam: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f87cba50..74afbd92 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -218,11 +218,15 @@ export interface TeamTask { } /** Task enriched for UI/DTO use (overlay from kanban-state.json). */ +export type TaskChangePresenceState = 'has_changes' | 'no_changes' | 'unknown'; + export interface TeamTaskWithKanban extends TeamTask { /** Set when task is in team kanban (review or approved column). */ kanbanColumn?: 'review' | 'approved'; /** Reviewer assigned in kanban state, when applicable. */ reviewer?: string | null; + /** Cheap persisted change-presence state for kanban rendering. */ + changePresence?: TaskChangePresenceState; } /** Metadata for an attachment associated with a task or comment. */ @@ -502,6 +506,7 @@ export interface TeamChangeEvent { type: | 'config' | 'inbox' + | 'log-source-change' | 'task' | 'lead-activity' | 'lead-context' diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 78905fc0..1dba68ff 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -43,6 +43,7 @@ import { TEAM_PROVISIONING_STATUS, TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_GET_ALL_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -56,6 +57,7 @@ import { TEAM_ADD_TASK_COMMENT, TEAM_GET_ATTACHMENTS, TEAM_GET_DELETED_TASKS, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_PROJECT_BRANCH, TEAM_KILL_PROCESS, TEAM_LEAD_ACTIVITY, @@ -105,7 +107,9 @@ describe('ipc teams handlers', () => { kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], })), + getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })), reconcileTeamArtifacts: vi.fn(async () => undefined), + setTaskChangePresenceTracking: vi.fn(() => undefined), deleteTeam: vi.fn(async () => undefined), getLeadMemberName: vi.fn(async () => 'team-lead'), getTeamDisplayName: vi.fn(async () => 'My Team'), @@ -175,6 +179,8 @@ describe('ipc teams handlers', () => { it('registers all expected handlers', () => { expect(handlers.has(TEAM_LIST)).toBe(true); expect(handlers.has(TEAM_GET_DATA)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true); + expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true); expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true); expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(true); expect(handlers.has(TEAM_CREATE)).toBe(true); @@ -224,6 +230,32 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true); }); + it('updates change presence tracking for a team', async () => { + const handler = handlers.get(TEAM_SET_CHANGE_PRESENCE_TRACKING); + expect(handler).toBeDefined(); + + const result = (await handler!({} as never, 'my-team', true)) as { + success: boolean; + data?: void; + }; + + expect(result.success).toBe(true); + expect(service.setTaskChangePresenceTracking).toHaveBeenCalledWith('my-team', true); + }); + + it('returns lightweight task change presence for a team', async () => { + const handler = handlers.get(TEAM_GET_TASK_CHANGE_PRESENCE); + expect(handler).toBeDefined(); + + const result = (await handler!({} as never, 'my-team')) as { + success: boolean; + data?: Record; + }; + + expect(result).toEqual({ success: true, data: { 'task-1': 'has_changes' } }); + expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team'); + }); + it('returns success false on invalid sendMessage args', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index fd312b2a..6dfe9f72 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs/promises'; import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService'; +import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; const TEAM_NAME = 'team-a'; @@ -70,31 +71,134 @@ function persistedEntryPath(baseDir: string): string { return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeTaskChangeResult( + taskId = TASK_ID, + overrides: Partial<{ + teamName: string; + taskId: string; + filePath: string; + confidence: 'high' | 'medium' | 'low' | 'fallback'; + content: string; + warning: string; + }> = {} +) { + const teamName = overrides.teamName ?? TEAM_NAME; + const targetTaskId = overrides.taskId ?? taskId; + const filePath = overrides.filePath ?? '/repo/src/file.ts'; + const content = overrides.content ?? 'export const value = 1;\n'; + const confidence = overrides.confidence ?? 'high'; + const confidenceTierByLabel = { + high: 1, + medium: 2, + low: 3, + fallback: 4, + } as const; + const files = + content.length > 0 + ? [ + { + filePath, + relativePath: 'src/file.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ] + : []; + + return { + teamName, + taskId: targetTaskId, + files, + totalFiles: files.length, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + confidence, + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: targetTaskId, + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: files.map((file) => file.filePath), + confidence: { + tier: confidenceTierByLabel[confidence], + label: confidence, + reason: 'test fixture', + }, + }, + warnings: overrides.warning ? [overrides.warning] : [], + }; +} + function createService(params: { logPaths: string[]; projectPath?: string; findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise; + taskChangePresenceRepository?: { upsertEntry: ReturnType }; + teamLogSourceTracker?: { + ensureTracking: ReturnType< + typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>> + >; + }; + taskChangeWorkerClient?: { + isAvailable: ReturnType boolean>>; + computeTaskChanges: ReturnType Promise>>; + }; }) { const findLogFileRefsForTask = params.findLogFileRefsForTask ?? vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' }))); + const taskChangeWorkerClient = + params.taskChangeWorkerClient ?? + ({ + isAvailable: vi.fn(() => false), + computeTaskChanges: vi.fn(async () => { + throw new Error('worker disabled in test'); + }), + } as const); + const service = new ChangeExtractorService( + { + findLogFileRefsForTask, + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => ({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + })), + } as any, + { getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any, + undefined, + taskChangeWorkerClient as any + ); + + if (params.taskChangePresenceRepository && params.teamLogSourceTracker) { + service.setTaskChangePresenceServices( + params.taskChangePresenceRepository as any, + params.teamLogSourceTracker as any + ); + } + return { findLogFileRefsForTask, - service: new ChangeExtractorService( - { - findLogFileRefsForTask, - findMemberLogPaths: vi.fn(async () => []), - } as any, - { - parseBoundaries: vi.fn(async () => ({ - boundaries: [], - scopes: [], - isSingleTaskSession: true, - detectedMechanism: 'none' as const, - })), - } as any, - { getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any - ), + service, }; } @@ -337,7 +441,14 @@ describe('ChangeExtractorService', () => { detectedMechanism: 'none' as const, })), } as any, - { getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any + { getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any, + undefined, + { + isAvailable: vi.fn(() => false), + computeTaskChanges: vi.fn(async () => { + throw new Error('worker disabled in test'); + }), + } as any ); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); @@ -373,4 +484,265 @@ describe('ChangeExtractorService', () => { expect(result.files[0]?.relativePath).toBe('src/same.ts'); expect(result.totalLinesAdded).toBe(2); }); + + it('prefers worker task-change results when the worker is available', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const workerResult = makeTaskChangeResult(); + const computeTaskChanges = vi.fn(async () => workerResult); + const { service, findLogFileRefsForTask } = createService({ + logPaths: [], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => true), + computeTaskChanges, + }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + }); + + expect(result).toEqual(workerResult); + expect(computeTaskChanges).toHaveBeenCalledTimes(1); + expect(findLogFileRefsForTask).not.toHaveBeenCalled(); + }); + + it('falls back inline when task-change worker is unavailable', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const computeTaskChanges = vi.fn(); + const { service, findLogFileRefsForTask } = createService({ + logPaths: [logPath], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => false), + computeTaskChanges, + }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + }); + + expect(result.files).toHaveLength(1); + expect(findLogFileRefsForTask).toHaveBeenCalled(); + expect(computeTaskChanges).not.toHaveBeenCalled(); + }); + + it('falls back inline when task-change worker throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const computeTaskChanges = vi.fn(async () => { + throw new Error('worker failed'); + }); + const { service, findLogFileRefsForTask } = createService({ + logPaths: [logPath], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => true), + computeTaskChanges, + }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + }); + + expect(result.files).toHaveLength(1); + expect(computeTaskChanges).toHaveBeenCalledTimes(1); + expect(findLogFileRefsForTask).toHaveBeenCalled(); + }); + + it('keeps summary cache in main and skips worker on repeat terminal summary requests', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const computeTaskChanges = vi.fn(async () => makeTaskChangeResult()); + const { service } = createService({ + logPaths: [logPath], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => true), + computeTaskChanges, + }, + }); + + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(computeTaskChanges).toHaveBeenCalledTimes(1); + }); + + it('restores persisted summaries without invoking worker compute', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const firstWorker = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult()), + }; + await createService({ + logPaths: [logPath], + taskChangeWorkerClient: firstWorker, + }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + const secondWorker = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: 'stale\n' })), + }; + const restored = await createService({ + logPaths: [logPath], + taskChangeWorkerClient: secondWorker, + }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(restored.files).toHaveLength(1); + expect(secondWorker.computeTaskChanges).not.toHaveBeenCalled(); + }); + + it('does not let stale worker results populate summary cache after invalidation', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const first = deferred>(); + const worker = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(async () => + makeTaskChangeResult(TASK_ID, { filePath: '/repo/src/newer.ts' }) + ), + }; + const { service } = createService({ + logPaths: [], + taskChangeWorkerClient: worker, + }); + + const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true }); + const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + first.resolve(makeTaskChangeResult()); + const stale = await stalePromise; + const fresh = await freshPromise; + + expect(stale.files[0]?.filePath).toBe('/repo/src/file.ts'); + expect(fresh.files[0]?.filePath).toBe('/repo/src/newer.ts'); + expect(worker.computeTaskChanges).toHaveBeenCalledTimes(2); + }); + + it('writes has_changes presence entries after successful task diff computation', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-presence.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), + ]); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })); + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult()), + }; + const { service } = createService({ + logPaths: [logPath], + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + taskChangeWorkerClient: workerClient, + }); + + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(upsertEntry).toHaveBeenCalledWith( + TEAM_NAME, + expect.objectContaining({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + }), + expect.objectContaining({ + taskId: TASK_ID, + presence: 'has_changes', + taskSignature: buildTaskChangePresenceDescriptor({ + owner: 'alice', + status: 'completed', + intervals: [ + { + startedAt: '2026-03-01T10:00:00.000Z', + completedAt: '2026-03-01T10:10:00.000Z', + }, + ], + reviewState: 'none', + historyEvents: [], + }).taskSignature, + }) + ); + }); + + it('does not write no_changes presence entries for uncertain empty task diff results', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })); + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })), + }; + const { service } = createService({ + logPaths: [], + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + taskChangeWorkerClient: workerClient, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(result.files).toHaveLength(0); + expect(result.confidence === 'high' || result.confidence === 'medium').toBe(false); + expect(upsertEntry).not.toHaveBeenCalled(); + }); }); diff --git a/test/main/services/team/TaskChangeWorkerClient.test.ts b/test/main/services/team/TaskChangeWorkerClient.test.ts new file mode 100644 index 00000000..fc695cb7 --- /dev/null +++ b/test/main/services/team/TaskChangeWorkerClient.test.ts @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TaskChangeWorkerClient } from '../../../../src/main/services/team/TaskChangeWorkerClient'; + +import type { TaskChangeSetV2 } from '../../../../src/shared/types'; +import type { TaskChangeWorkerRequest, TaskChangeWorkerResponse } from '../../../../src/main/services/team/taskChangeWorkerTypes'; + +class FakeWorker { + readonly posted: TaskChangeWorkerRequest[] = []; + readonly terminate = vi.fn(async () => 0); + private readonly listeners: { + message: Array<(message: TaskChangeWorkerResponse) => void>; + error: Array<(error: Error) => void>; + exit: Array<(code: number) => void>; + } = { + message: [], + error: [], + exit: [], + }; + + on(event: 'message' | 'error' | 'exit', listener: ((value: any) => void) & ((value: any) => void)) { + if (event === 'message') this.listeners.message.push(listener as (message: TaskChangeWorkerResponse) => void); + if (event === 'error') this.listeners.error.push(listener as (error: Error) => void); + if (event === 'exit') this.listeners.exit.push(listener as (code: number) => void); + return this; + } + + postMessage(message: TaskChangeWorkerRequest): void { + this.posted.push(message); + } + + emitMessage(message: TaskChangeWorkerResponse): void { + for (const listener of this.listeners.message) { + listener(message); + } + } + + emitError(error: Error): void { + for (const listener of this.listeners.error) { + listener(error); + } + } + + emitExit(code: number): void { + for (const listener of this.listeners.exit) { + listener(code); + } + } +} + +function makePayload(taskId = 'task-1') { + return { + teamName: 'team-a', + taskId, + taskMeta: { + owner: 'alice', + status: 'completed', + intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], + reviewState: 'none' as const, + historyEvents: [], + }, + effectiveOptions: { + owner: 'alice', + status: 'completed', + intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], + }, + projectPath: '/repo', + includeDetails: false, + }; +} + +function makeResult(taskId = 'task-1', filePath = '/repo/src/file.ts'): TaskChangeSetV2 { + return { + teamName: 'team-a', + taskId, + files: [ + { + filePath, + relativePath: 'src/file.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ], + totalFiles: 1, + totalLinesAdded: 1, + totalLinesRemoved: 0, + confidence: 'high' as const, + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId, + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [filePath], + confidence: { tier: 1, label: 'high', reason: 'test fixture' }, + }, + warnings: [], + }; +} + +describe('TaskChangeWorkerClient', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves successful worker responses', async () => { + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const promise = client.computeTaskChanges(makePayload()); + const request = workers[0]!.posted[0]!; + workers[0]!.emitMessage({ id: request.id, ok: true, result: makeResult() }); + + await expect(promise).resolves.toEqual(makeResult()); + }); + + it('times out the active request, terminates the worker, and recreates it on the next call', async () => { + vi.useFakeTimers(); + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + timeoutMs: 25, + enabled: true, + }); + + const firstPromise = client.computeTaskChanges(makePayload('task-timeout')); + const firstExpectation = expect(firstPromise).rejects.toThrow('Worker call timeout'); + await vi.advanceTimersByTimeAsync(25); + await firstExpectation; + expect(workers[0]!.terminate).toHaveBeenCalledTimes(1); + + const secondPromise = client.computeTaskChanges(makePayload('task-next')); + const request = workers[1]!.posted[0]!; + workers[1]!.emitMessage({ + id: request.id, + ok: true, + result: makeResult('task-next', '/repo/src/next.ts'), + }); + + await expect(secondPromise).resolves.toEqual(makeResult('task-next', '/repo/src/next.ts')); + expect(workers).toHaveLength(2); + }); + + it('rejects all pending requests on worker error and clears queued work', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const first = client.computeTaskChanges(makePayload('task-1')); + const second = client.computeTaskChanges(makePayload('task-2')); + workers[0]!.emitError(new Error('boom')); + + await expect(first).rejects.toThrow('boom'); + await expect(second).rejects.toThrow('boom'); + + const third = client.computeTaskChanges(makePayload('task-3')); + const request = workers[1]!.posted[0]!; + workers[1]!.emitMessage({ id: request.id, ok: true, result: makeResult('task-3') }); + await expect(third).resolves.toEqual(makeResult('task-3')); + }); + + it('rejects all pending requests on worker exit', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const first = client.computeTaskChanges(makePayload('task-1')); + const second = client.computeTaskChanges(makePayload('task-2')); + workers[0]!.emitExit(9); + + await expect(first).rejects.toThrow('Worker exited with code 9'); + await expect(second).rejects.toThrow('Worker exited with code 9'); + }); + + it('executes queued requests sequentially in FIFO order', async () => { + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const first = client.computeTaskChanges(makePayload('task-1')); + const second = client.computeTaskChanges(makePayload('task-2')); + + expect(workers[0]!.posted).toHaveLength(1); + expect(workers[0]!.posted[0]!.payload.taskId).toBe('task-1'); + + workers[0]!.emitMessage({ + id: workers[0]!.posted[0]!.id, + ok: true, + result: makeResult('task-1'), + }); + + expect(workers[0]!.posted).toHaveLength(2); + expect(workers[0]!.posted[1]!.payload.taskId).toBe('task-2'); + + workers[0]!.emitMessage({ + id: workers[0]!.posted[1]!.id, + ok: true, + result: makeResult('task-2'), + }); + + await expect(first).resolves.toEqual(makeResult('task-1')); + await expect(second).resolves.toEqual(makeResult('task-2')); + }); + + it('reports unavailable when the worker file is missing', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = new TaskChangeWorkerClient({ + workerPath: null, + enabled: true, + }); + + expect(client.isAvailable()).toBe(false); + }); +}); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index cc1e45ed..42565b41 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; import type { TeamTask } from '../../../../src/shared/types/team'; @@ -145,6 +146,51 @@ describe('TeamDataService', () => { expect(reconcileArtifacts).toHaveBeenCalledWith({ reason: 'file-watch' }); }); + it('starts and stops task change presence tracking outside getTeamData', async () => { + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'generation-1', + })); + const stopTracking = vi.fn(async () => undefined); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [] })), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + garbageCollect: vi.fn(async () => undefined), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => null), + save: vi.fn(async () => undefined), + deleteTasks: vi.fn(async () => undefined), + } as never, + { + ensureTracking, + stopTracking, + } as never + ); + + service.setTaskChangePresenceTracking('my-team', true); + service.setTaskChangePresenceTracking('my-team', false); + await Promise.resolve(); + + expect(ensureTracking).toHaveBeenCalledWith('my-team'); + expect(stopTracking).toHaveBeenCalledWith('my-team'); + }); + it('surfaces controller reconcile failures', async () => { const reconcileArtifacts = vi.fn(() => { throw new Error('reconcile failed'); @@ -1662,4 +1708,241 @@ describe('TeamDataService', () => { else process.env[TASK_COMMENT_FORWARDING_ENV] = previous; } }); + + it('returns unknown changePresence when no cached presence entry exists', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + const load = vi.fn(async () => null); + + service.setTaskChangePresenceServices( + { + load, + upsertEntry: vi.fn(async () => undefined), + } as never, + { + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTeamData('my-team'); + + expect(data.tasks[0]?.changePresence).toBe('unknown'); + expect(load).not.toHaveBeenCalled(); + }); + + it('returns cached changePresence only when signature and generation still match', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + historyEvents: task.historyEvents, + reviewState: 'none', + }); + + const createServiceWithPresence = ( + load: ReturnType, + trackerSnapshot: { projectFingerprint: string; logSourceGeneration: string } | null + ) => { + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load, + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => trackerSnapshot), + ensureTracking: vi.fn(async () => trackerSnapshot), + } as never + ); + + return service; + }; + + const matched = await createServiceWithPresence( + vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + { + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + } + ).getTeamData('my-team'); + expect(matched.tasks[0]?.changePresence).toBe('has_changes'); + + const mismatched = await createServiceWithPresence( + vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'stale-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'stale-generation', + }, + }, + })), + { + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + } + ).getTeamData('my-team'); + expect(mismatched.tasks[0]?.changePresence).toBe('unknown'); + }); + + it('returns lightweight task change presence without loading full team data', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + historyEvents: task.historyEvents, + reviewState: 'none', + }); + const getMessages = vi.fn(async () => []); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages, + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTaskChangePresence('my-team'); + + expect(data).toEqual({ 'task-1': 'has_changes' }); + expect(getMessages).not.toHaveBeenCalled(); + }); }); diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 234cd038..8ef2d282 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -37,6 +37,7 @@ vi.mock('@renderer/api', () => ({ function createSliceStore() { return create()((set, get, store) => ({ ...createChangeReviewSlice(set as never, get as never, store as never), + setSelectedTeamTaskChangePresence: vi.fn(), })); } @@ -203,6 +204,164 @@ describe('changeReviewSlice task changes', () => { expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); }); + it('updates selected team task changePresence after a positive summary check', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit')); + + await store.getState().checkTaskHasChanges('team-a', 'presence-hit', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'presence-hit', + 'has_changes' + ); + }); + + it('updates selected team task changePresence to no_changes only for confirmed empty summaries', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-empty', + confidence: 'high', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-empty', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 1, label: 'high', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-empty', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'presence-empty', + 'no_changes' + ); + }); + + it('keeps changePresence unknown for fallback empty summaries', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-unknown', + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-unknown', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-unknown', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith( + 'team-a', + 'presence-unknown', + 'no_changes' + ); + }); + + it('downgrades stale known presence to unknown for fallback empty summaries', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'team-a', + selectedTeamData: { + tasks: [{ id: 'presence-stale', changePresence: 'has_changes' }], + }, + }); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-stale', + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-stale', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-stale', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'presence-stale', + 'unknown' + ); + }); + + it('bypasses stale negative cache when selected team task presence is unknown', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-bypass', + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-bypass', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A); + store.setState({ + selectedTeamName: 'team-a', + selectedTeamData: { + tasks: [{ id: 'presence-bypass', changePresence: 'unknown' }], + }, + }); + await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); + }); + it('ignores stale fetchTaskChanges responses when a newer task request wins', async () => { const store = createSliceStore(); const first = deferred(); @@ -399,6 +558,85 @@ describe('changeReviewSlice task changes', () => { ).toBe(true); }); + it('warms task summaries with bounded concurrency', async () => { + const store = createSliceStore(); + const pending = Array.from({ length: 6 }, () => deferred()); + let callIndex = 0; + hoisted.getTaskChanges.mockImplementation(() => pending[callIndex++].promise); + + const requests = Array.from({ length: 6 }, (_, index) => ({ + teamName: 'team-a', + taskId: `task-${index}`, + options: { + owner: 'alice', + status: 'completed', + intervals: [{ startedAt: `2026-03-01T1${index}:00:00.000Z` }], + since: `2026-03-01T0${index}:58:00.000Z`, + stateBucket: 'completed' as const, + }, + })); + + const warmPromise = store.getState().warmTaskChangeSummaries(requests); + await flushAsyncWork(); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(4); + + for (let index = 0; index < 4; index++) { + pending[index].resolve({ + teamName: 'team-a', + taskId: `task-${index}`, + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + confidence: 'fallback', + computedAt: '2026-12-01T12:00:00.000Z', + scope: { + taskId: `task-${index}`, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: [], + }); + } + await flushAsyncWork(); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(6); + + for (let index = 4; index < 6; index++) { + pending[index].resolve({ + teamName: 'team-a', + taskId: `task-${index}`, + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + confidence: 'fallback', + computedAt: '2026-12-01T12:00:00.000Z', + scope: { + taskId: `task-${index}`, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: [], + }); + } + + await warmPromise; + }); + it('clears optimistic terminal presence after background forceFresh revalidation', async () => { const store = createSliceStore(); const teamName = 'team-revalidate'; diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index c48bfd34..1a882134 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -31,6 +31,7 @@ vi.mock('@renderer/api', () => ({ })), }, teams: { + setChangePresenceTracking: vi.fn(async () => undefined), onTeamChange: vi.fn( (cb: (event: unknown, data: { teamName: string }) => void): (() => void) => { hoisted.onTeamChangeCb = cb; @@ -58,6 +59,7 @@ vi.mock('@renderer/api', () => ({ })); import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; +import { api } from '@renderer/api'; describe('team change throttling', () => { let cleanup: (() => void) | null = null; @@ -66,10 +68,14 @@ describe('team change throttling', () => { vi.useFakeTimers(); const fetchTeams = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); + const refreshSelectedTeamChangePresence = vi.fn(async () => undefined); useStore.setState({ fetchTeams, refreshTeamData, + refreshSelectedTeamChangePresence, + selectedTeamName: null, + selectedTeamData: null, paneLayout: { focusedPaneId: 'p1', panes: [ @@ -165,6 +171,99 @@ describe('team change throttling', () => { expect(fetchAllTasksSpy).not.toHaveBeenCalled(); }); + it('log-source-change refreshes only task change presence', async () => { + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + } as never); + + const state = useStore.getState(); + const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + const refreshSelectedTeamChangePresenceSpy = vi.spyOn( + state, + 'refreshSelectedTeamChangePresence' + ); + + hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(399); + expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1); + expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(fetchTeamsSpy).not.toHaveBeenCalled(); + }); + + it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => { + const invalidateTaskChangePresence = vi.fn(); + const checkTaskHasChanges = vi.fn(async () => undefined); + + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-1', + owner: 'alice', + status: 'in_progress', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], + historyEvents: [], + reviewState: 'none', + changePresence: 'unknown', + }, + { + id: 'task-2', + owner: 'alice', + status: 'in_progress', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], + historyEvents: [], + reviewState: 'none', + changePresence: 'unknown', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + invalidateTaskChangePresence, + checkTaskHasChanges, + } as never); + + await vi.advanceTimersByTimeAsync(10_000); + expect(checkTaskHasChanges).toHaveBeenNthCalledWith( + 1, + 'my-team', + 'task-1', + expect.objectContaining({ status: 'in_progress', owner: 'alice' }) + ); + + await vi.advanceTimersByTimeAsync(10_000); + expect(checkTaskHasChanges).toHaveBeenNthCalledWith( + 2, + 'my-team', + 'task-2', + expect.objectContaining({ status: 'in_progress', owner: 'alice' }) + ); + }); + it('per-team throttling: busy team does not block another visible team', async () => { // Add a second visible team tab useStore.setState({ @@ -204,4 +303,48 @@ describe('team change throttling', () => { expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team'); }); + + it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => { + const setChangePresenceTrackingSpy = vi.mocked(api.teams.setChangePresenceTracking); + setChangePresenceTrackingSpy.mockClear(); + + expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); + + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + } as never); + + await Promise.resolve(); + + expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); + + useStore.setState({ + selectedTeamName: 'other-team', + selectedTeamData: null, + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }], + activeTabId: 't2', + }, + ], + }, + } as never); + + await Promise.resolve(); + + expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); + }); }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 98a48107..1764c682 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -144,6 +144,30 @@ describe('teamSlice actions', () => { ); }); + it('does not warm task-change summaries on team open', async () => { + const store = createSliceStore(); + hoisted.getData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [ + { + id: 'completed-1', + owner: 'alice', + status: 'completed', + createdAt: '2026-03-20T08:00:00.000Z', + updatedAt: '2026-03-20T12:00:00.000Z', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); + }); + describe('refreshTeamData provisioning safety', () => { it('does not set fatal error on TEAM_PROVISIONING', async () => { const store = createSliceStore(); @@ -265,7 +289,7 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamError).toBe('Team not found'); }); - it('invalidates changed task summaries and warms only cacheable terminal tasks', async () => { + it('invalidates changed task summaries without warming task availability on refresh', async () => { const store = createSliceStore(); const invalidateTaskChangePresence = vi.fn(); const warmTaskChangeSummaries = vi.fn(async () => undefined); @@ -367,9 +391,7 @@ describe('teamSlice actions', () => { expect(hoisted.invalidateTaskChangeSummaries).toHaveBeenCalledWith('my-team', ['task-1']); expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1); - expect(warmTaskChangeSummaries).toHaveBeenCalledWith([ - expect.objectContaining({ teamName: 'my-team', taskId: 'task-2' }), - ]); + expect(warmTaskChangeSummaries).not.toHaveBeenCalled(); }); });