import { BoardTaskActivityTranscriptReader } from '../taskLogs/activity/BoardTaskActivityTranscriptReader'; import { isBoardTaskActivityReadEnabled } from '../taskLogs/activity/featureGates'; import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscriptSourceLocator'; import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'; import { TeamKanbanManager } from '../TeamKanbanManager'; import { TeamTaskReader } from '../TeamTaskReader'; import { TeamMembersMetaStore } from '../TeamMembersMetaStore'; import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer'; import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource'; import { buildResolvedReviewerIndex } from './reviewerResolution'; import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader'; import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader'; import { inferTeamProviderIdFromModel, normalizeOptionalTeamProviderId, } from '@shared/utils/teamProvider'; import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; import type { TeamTaskStallExactRow, TeamTaskStallSnapshot } from './TeamTaskStallTypes'; import type { TeamConfig, TeamMember, TeamProviderId, TeamTask } from '@shared/types'; function resolveLeadNameFromConfig(config: TeamConfig): string { const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead')); return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; } function normalizeMemberNameKey(name: string | undefined): string | null { const normalized = name?.trim().toLowerCase(); return normalized ? normalized : null; } function resolveMemberProvider(member: TeamMember): TeamProviderId | undefined { const legacyProvider = (member as { provider?: unknown }).provider; return ( normalizeOptionalTeamProviderId(member.providerId) ?? normalizeOptionalTeamProviderId(legacyProvider) ?? inferTeamProviderIdFromModel(member.model) ); } function buildProviderByMemberName(args: { configMembers: TeamMember[]; metaMembers: TeamMember[]; }): Map { const providerByMemberName = new Map(); for (const member of args.configMembers) { const memberName = normalizeMemberNameKey(member.name); const providerId = resolveMemberProvider(member); if (memberName && providerId) { providerByMemberName.set(memberName, providerId); } } for (const member of args.metaMembers) { const memberName = normalizeMemberNameKey(member.name); const providerId = resolveMemberProvider(member); if (memberName && providerId) { providerByMemberName.set(memberName, providerId); } } return providerByMemberName; } export class TeamTaskStallSnapshotSource { constructor( private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(), private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(), private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(), private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(), private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(), private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly openCodeEvidenceSource: OpenCodeTaskStallEvidenceSource = new OpenCodeTaskStallEvidenceSource() ) {} async getSnapshot(teamName: string): Promise { const transcriptContext = await this.transcriptSourceLocator.getContext(teamName); if (!transcriptContext) { return null; } const [activeTasks, deletedTasks, kanbanState, metaMembers] = await Promise.all([ this.taskReader.getTasks(teamName), this.taskReader.getDeletedTasks(teamName), this.kanbanManager.getState(teamName), this.membersMetaStore.getMembers(teamName).catch(() => []), ]); const allTasks = [...activeTasks, ...deletedTasks]; const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const)); const inProgressTasks = activeTasks.filter( (task) => task.status === 'in_progress' && task.reviewState !== 'review' ); const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review'); const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState); const activityReadsEnabled = isBoardTaskActivityReadEnabled(); const exactReadsEnabled = isBoardTaskExactLogsReadEnabled(); const providerByMemberName = buildProviderByMemberName({ configMembers: transcriptContext.config.members ?? [], metaMembers, }); let recordsByTaskId = new Map(); if ( activityReadsEnabled && allTasks.length > 0 && transcriptContext.transcriptFiles.length > 0 ) { const messages = await this.transcriptReader.readFiles(transcriptContext.transcriptFiles); recordsByTaskId = this.activityBatchIndexer.buildIndex({ teamName, tasks: allTasks, messages, }); } const relevantMonitorTasks = [...inProgressTasks, ...reviewOpenTasks]; const relevantExactFiles = this.collectRelevantExactFiles( relevantMonitorTasks, recordsByTaskId ); const [freshnessByTaskId, exactRowsByFilePath, openCodeEvidence] = await Promise.all([ this.freshnessReader.readSignals( transcriptContext.projectDir, relevantMonitorTasks.map((task) => task.id) ), exactReadsEnabled ? this.exactRowReader.parseFiles(relevantExactFiles) : Promise.resolve(new Map()), activityReadsEnabled && exactReadsEnabled ? this.openCodeEvidenceSource.readEvidence({ teamName, tasks: relevantMonitorTasks, providerByMemberName, }) : Promise.resolve({ recordsByTaskId: new Map(), exactRowsByFilePath: new Map(), }), ]); const mergedRecordsByTaskId = this.mergeActivityRecords( recordsByTaskId, openCodeEvidence.recordsByTaskId ); const mergedExactRowsByFilePath = this.mergeExactRows( exactRowsByFilePath, openCodeEvidence.exactRowsByFilePath ); return { teamName, scannedAt: new Date().toISOString(), projectDir: transcriptContext.projectDir, projectId: transcriptContext.projectId, leadName: resolveLeadNameFromConfig(transcriptContext.config), transcriptFiles: transcriptContext.transcriptFiles, activityReadsEnabled, exactReadsEnabled, activeTasks, deletedTasks, allTasksById, inProgressTasks, reviewOpenTasks, resolvedReviewersByTaskId, recordsByTaskId: mergedRecordsByTaskId, freshnessByTaskId, exactRowsByFilePath: mergedExactRowsByFilePath, providerByMemberName, }; } private mergeActivityRecords( base: Map, extra: Map ): Map { if (extra.size === 0) { return base; } const merged = new Map(base); for (const [taskId, records] of extra.entries()) { const existing = merged.get(taskId) ?? []; const seen = new Set(existing.map((record) => record.id)); const next = [...existing]; for (const record of records) { if (!seen.has(record.id)) { next.push(record); seen.add(record.id); } } next.sort((left, right) => { const timeDiff = Date.parse(left.timestamp) - Date.parse(right.timestamp); return timeDiff !== 0 ? timeDiff : left.source.sourceOrder - right.source.sourceOrder; }); merged.set(taskId, next); } return merged; } private mergeExactRows( base: Map, extra: Map ): Map { if (extra.size === 0) { return base; } const merged = new Map(base); for (const [filePath, rows] of extra.entries()) { const existing = merged.get(filePath) ?? []; const seen = new Set(existing.map((row) => `${row.messageUuid}:${row.sourceOrder}`)); const next = [...existing]; for (const row of rows) { const key = `${row.messageUuid}:${row.sourceOrder}`; if (!seen.has(key)) { next.push(row); seen.add(key); } } next.sort((left, right) => { const orderDiff = left.sourceOrder - right.sourceOrder; return orderDiff !== 0 ? orderDiff : Date.parse(left.timestamp) - Date.parse(right.timestamp); }); merged.set(filePath, next); } return merged; } private collectRelevantExactFiles( inProgressTasks: TeamTask[], recordsByTaskId: Map ): string[] { const filePaths = new Set(); for (const task of inProgressTasks) { const records = recordsByTaskId.get(task.id) ?? []; for (const record of records) { filePaths.add(record.source.filePath); } } return [...filePaths].sort((left, right) => left.localeCompare(right)); } }