From f4f42e2ca4bc30075bcd782ddfc9a246fffa7cd3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 30 May 2026 23:14:42 +0300 Subject: [PATCH] perf(main): avoid full team scans for global tasks --- src/main/services/team/TeamConfigReader.ts | 2 +- src/main/services/team/TeamDataService.ts | 103 +++++++++++++++--- .../services/team/TeamDataService.test.ts | 58 ++++++++++ 3 files changed, 145 insertions(+), 18 deletions(-) diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 9da8add7..4cd33205 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -109,7 +109,7 @@ function normalizeProjectPathCandidate(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function resolveProjectPathFromConfig( +export function resolveProjectPathFromConfig( config: Pick ): string | undefined { const direct = normalizeProjectPathCandidate(config.projectPath); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index c74c85a5..2bf6ff4d 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -39,7 +39,7 @@ import { choosePreferredLaunchSnapshot, readBootstrapLaunchSnapshot, } from './TeamBootstrapStateReader'; -import { TeamConfigReader } from './TeamConfigReader'; +import { resolveProjectPathFromConfig, TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamKanbanManager } from './TeamKanbanManager'; @@ -108,6 +108,7 @@ const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250; +const GLOBAL_TASK_TEAM_CONFIG_CONCURRENCY = 12; const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE = 'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.'; @@ -233,6 +234,36 @@ interface FileWatchReconcileDiagnostics { lastPressureLogAt: number; } +interface GlobalTaskTeamInfo { + displayName: string; + projectPath?: string; + deletedAt?: string; +} + +async function mapLimitLocal( + items: readonly T[], + limit: number, + mapper: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(Math.max(1, limit), items.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + results[index] = await mapper(items[index]!); + } + }) + ); + + return results; +} + function applyDistinctRosterColors( members: readonly T[] ): T[] { @@ -423,6 +454,58 @@ export class TeamDataService { return readConfigForUiSnapshot(this.configReader, teamName); } + private async readGlobalTaskTeamInfoFromListTeams(): Promise> { + const teams = await this.configReader.listTeams(); + const teamInfoMap = new Map(); + for (const team of teams) { + teamInfoMap.set(team.teamName, { + displayName: team.displayName, + projectPath: team.projectPath, + deletedAt: team.deletedAt, + }); + } + return teamInfoMap; + } + + private async readGlobalTaskTeamInfo( + rawTasks: readonly (TeamTask & { teamName: string })[] + ): Promise> { + const canReadConfigDirectly = + typeof (this.configReader as { getConfigSnapshot?: unknown }).getConfigSnapshot === + 'function' || + typeof (this.configReader as { getConfig?: unknown }).getConfig === 'function'; + if (!canReadConfigDirectly) { + return this.readGlobalTaskTeamInfoFromListTeams(); + } + + const teamNames = [...new Set(rawTasks.map((task) => task.teamName))]; + const entries = await mapLimitLocal( + teamNames, + GLOBAL_TASK_TEAM_CONFIG_CONCURRENCY, + async (teamName) => { + const config = await readConfigForUiSnapshot(this.configReader, teamName).catch(() => null); + const displayName = config?.name?.trim(); + if (!config || !displayName) { + return null; + } + return [ + teamName, + { + displayName, + projectPath: resolveProjectPathFromConfig(config), + deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined, + }, + ] as const; + } + ); + + if (entries.some((entry) => entry === null)) { + return this.readGlobalTaskTeamInfoFromListTeams(); + } + + return new Map(entries.filter((entry): entry is NonNullable => entry !== null)); + } + private invalidateGlobalTaskProjectionCache(): void { TeamTaskReader.invalidateAllTasksCache(); } @@ -1025,21 +1108,7 @@ export class TeamDataService { async getAllTasks(): Promise { const rawTasks = await this.taskReader.getAllTasks(); - const teams = await this.configReader.listTeams(); - - const teamInfoMap = new Map< - string, - { displayName: string; projectPath?: string; deletedAt?: string } - >(); - for (const team of teams) { - teamInfoMap.set(team.teamName, { - displayName: team.displayName, - projectPath: team.projectPath, - deletedAt: team.deletedAt, - }); - } - - const deletedTeams = new Set(teams.filter((t) => t.deletedAt).map((t) => t.teamName)); + const teamInfoMap = await this.readGlobalTaskTeamInfo(rawTasks); const MAX_GLOBAL_TASKS_EXPORTED = 500; let tasksToExport = rawTasks.filter((task) => teamInfoMap.has(task.teamName)); @@ -1118,7 +1187,7 @@ export class TeamDataService { kanbanColumn, teamName: task.teamName, teamDisplayName: info.displayName, - teamDeleted: deletedTeams.has(task.teamName) || undefined, + teamDeleted: Boolean(info.deletedAt) || undefined, }); processed++; if (processed % TASK_MAP_YIELD_EVERY === 0) { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 264ed816..5749843b 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2235,6 +2235,64 @@ describe('TeamDataService', () => { }); }); + it('uses config snapshots instead of full team summaries for global task team info', async () => { + const listTeams = vi.fn(async () => [ + { + teamName: 'my-team', + displayName: 'My team from list', + projectPath: '/repo-from-list', + }, + ]); + const getConfigSnapshot = vi.fn(async (teamName: string) => + teamName === 'my-team' + ? { + name: 'My team from config', + members: [{ name: 'lead', role: 'Team Lead', cwd: '/repo-from-lead' }], + deletedAt: '2026-03-01T12:00:00.000Z', + } + : null + ); + const service = new TeamDataService( + { + listTeams, + getConfigSnapshot, + } as never, + { + getAllTasks: vi.fn(async () => [ + { + id: 'task-global-config', + teamName: 'my-team', + subject: 'Global config task', + status: 'pending', + owner: 'bob', + }, + ]), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { + getState: vi.fn(async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: {}, + })), + } as never + ); + + const tasks = await service.getAllTasks(); + + expect(listTeams).not.toHaveBeenCalled(); + expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(tasks[0]).toMatchObject({ + id: 'task-global-config', + teamDisplayName: 'My team from config', + projectPath: '/repo-from-lead', + teamDeleted: true, + }); + }); + it('caps global task projections before building lightweight comment payloads', async () => { const rawTasks = Array.from({ length: 501 }, (_, index) => ({ id: `task-${index}`,