agent-ecosystem/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts

246 lines
9.4 KiB
TypeScript

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<string, TeamProviderId> {
const providerByMemberName = new Map<string, TeamProviderId>();
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<TeamTaskStallSnapshot | null> {
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<string, BoardTaskActivityRecord[]>();
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<string, BoardTaskActivityRecord[]>,
extra: Map<string, BoardTaskActivityRecord[]>
): Map<string, BoardTaskActivityRecord[]> {
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<string, TeamTaskStallExactRow[]>,
extra: Map<string, TeamTaskStallExactRow[]>
): Map<string, TeamTaskStallExactRow[]> {
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, BoardTaskActivityRecord[]>
): string[] {
const filePaths = new Set<string>();
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));
}
}