agent-ecosystem/src/renderer/components/team/teamChangesRequestPlan.ts

224 lines
7.2 KiB
TypeScript

import {
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import type {
TaskChangeRequestOptions,
TeamTaskChangeSummaryRequest,
TeamTaskWithKanban,
} from '@shared/types';
export const TEAM_CHANGES_MAX_REQUESTS = 120;
export const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32;
export const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300;
interface TeamChangeRequestPlanOptions {
maxRequests?: number;
unknownScanLimit?: number;
satisfiedTaskIds?: ReadonlySet<string>;
}
interface TeamChangeCandidate {
task: TeamTaskWithKanban;
options: TaskChangeRequestOptions;
priority: number;
}
export interface TeamChangeRequestPlan {
requests: TeamTaskChangeSummaryRequest[];
requestOptionsByTaskId: Map<string, TaskChangeRequestOptions>;
eligibleTaskIds: Set<string>;
eligibleCount: number;
requestedCount: number;
deferredCount: number;
nextUnknownScanCursor: number;
}
export function getTeamChangeTaskTimeMs(task: TeamTaskWithKanban): number {
const value = task.updatedAt ?? task.createdAt;
if (!value) return 0;
const ms = new Date(value).getTime();
return Number.isFinite(ms) ? ms : 0;
}
function compareCandidateRecency(a: TeamChangeCandidate, b: TeamChangeCandidate): number {
const priorityDelta = a.priority - b.priority;
if (priorityDelta !== 0) return priorityDelta;
return getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task);
}
function rotateCandidates<T>(items: T[], cursor: number): T[] {
if (items.length === 0) return items;
const start = cursor % items.length;
if (start === 0) return items;
return [...items.slice(start), ...items.slice(0, start)];
}
function normalizePositiveLimit(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value) || !value || value <= 0) {
return fallback;
}
return Math.max(1, Math.floor(value));
}
function hasTaskChangeScanEvidence(task: TeamTaskWithKanban): boolean {
if ((task.workIntervals?.length ?? 0) > 0 || (task.reviewIntervals?.length ?? 0) > 0) {
return true;
}
return (
task.historyEvents?.some((event) => {
if (event.type === 'task_created') {
return false;
}
return event.type === 'status_changed' || event.type.startsWith('review_');
}) ?? false
);
}
function getRelevantHistoryEvents(task: TeamTaskWithKanban): { type: string; timestamp: string }[] {
return (
task.historyEvents
?.filter((event) => event.type === 'status_changed' || event.type.startsWith('review_'))
.map((event) => ({
type: event.type,
timestamp: event.timestamp,
})) ?? []
);
}
export function buildTeamChangeRequestPlan(
tasks: TeamTaskWithKanban[],
unknownScanCursor: number,
forceFresh: boolean,
options: TeamChangeRequestPlanOptions = {}
): TeamChangeRequestPlan {
const maxRequests = normalizePositiveLimit(options.maxRequests, TEAM_CHANGES_MAX_REQUESTS);
const unknownScanLimit = Math.min(
normalizePositiveLimit(options.unknownScanLimit, TEAM_CHANGES_UNKNOWN_SCAN_LIMIT),
maxRequests
);
const satisfiedTaskIds = options.satisfiedTaskIds;
const primary: TeamChangeCandidate[] = [];
const active: TeamChangeCandidate[] = [];
const unknown: TeamChangeCandidate[] = [];
const seenTaskIds = new Set<string>();
for (const task of tasks) {
if (!task.id || task.status === 'deleted' || seenTaskIds.has(task.id)) {
continue;
}
seenTaskIds.add(task.id);
const requestOptions = buildTaskChangeRequestOptions(task, { summaryOnly: true });
const presence = task.changePresence ?? 'unknown';
const canDisplay = canDisplayTaskChangesForOptions(requestOptions);
const shouldScanUnknown =
presence === 'unknown' && (canDisplay || hasTaskChangeScanEvidence(task));
if (
!canDisplay &&
presence !== 'has_changes' &&
presence !== 'needs_attention' &&
!shouldScanUnknown
) {
continue;
}
if (presence === 'has_changes') {
primary.push({ task, options: requestOptions, priority: 0 });
continue;
}
if (presence === 'needs_attention') {
primary.push({ task, options: requestOptions, priority: 1 });
continue;
}
if (requestOptions.stateBucket === 'active' && requestOptions.status === 'in_progress') {
active.push({ task, options: requestOptions, priority: 2 });
continue;
}
if (shouldScanUnknown) {
unknown.push({ task, options: requestOptions, priority: 3 });
}
}
primary.sort(compareCandidateRecency);
active.sort(compareCandidateRecency);
unknown.sort(compareCandidateRecency);
const eligibleTaskIds = new Set(
[...primary, ...active, ...unknown].map((candidate) => candidate.task.id)
);
const satisfiedEligibleTaskIds = new Set<string>();
const filterUnsatisfied = (candidate: TeamChangeCandidate): boolean => {
if (!satisfiedTaskIds?.has(candidate.task.id)) {
return true;
}
satisfiedEligibleTaskIds.add(candidate.task.id);
return false;
};
const requestPrimary = primary.filter(filterUnsatisfied);
const requestActive = active.filter(filterUnsatisfied);
const requestUnknown = unknown.filter(filterUnsatisfied);
const unknownWindow = rotateCandidates(requestUnknown, unknownScanCursor).slice(
0,
unknownScanLimit
);
const selected = [...requestPrimary, ...requestActive, ...unknownWindow].slice(0, maxRequests);
const requestOptionsByTaskId = new Map<string, TaskChangeRequestOptions>();
const requests = selected.map((candidate) => {
const requestOptions = {
...candidate.options,
summaryOnly: true,
forceFresh: forceFresh ? true : candidate.options.forceFresh,
};
requestOptionsByTaskId.set(candidate.task.id, requestOptions);
return {
taskId: candidate.task.id,
options: requestOptions,
};
});
const eligibleCount = primary.length + active.length + unknown.length;
const nextUnknownScanCursor =
requestUnknown.length > 0
? (unknownScanCursor + Math.min(unknownScanLimit, requestUnknown.length)) %
requestUnknown.length
: 0;
return {
requests,
requestOptionsByTaskId,
eligibleTaskIds,
eligibleCount,
requestedCount: requests.length,
deferredCount: Math.max(0, eligibleCount - satisfiedEligibleTaskIds.size - requests.length),
nextUnknownScanCursor,
};
}
export function buildTeamChangesTasksFingerprint(tasks: TeamTaskWithKanban[]): string {
return JSON.stringify(
tasks
.map((task) => ({
id: task.id,
status: task.status,
owner: task.owner ?? '',
updatedAt: task.updatedAt ?? '',
changePresence: task.changePresence ?? 'unknown',
reviewState: task.reviewState ?? '',
kanbanColumn: task.kanbanColumn ?? '',
workIntervals:
task.workIntervals?.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
})) ?? [],
reviewIntervals:
task.reviewIntervals?.map((interval) => ({
reviewer: interval.reviewer,
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
})) ?? [],
historyEvents: getRelevantHistoryEvents(task),
}))
.sort((a, b) => a.id.localeCompare(b.id))
);
}