246 lines
9.4 KiB
TypeScript
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));
|
|
}
|
|
}
|