From 0b979854740ed088c566dca467a3ce632cb52380 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 29 May 2026 12:46:29 +0300 Subject: [PATCH] perf: cache team transcript affinity checks --- .../team/TeamTranscriptProjectResolver.ts | 66 +++++++++++++++++-- .../TeamTranscriptProjectResolver.test.ts | 48 +++++++++++++- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index b37bb4a1..7f88431c 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -21,6 +21,7 @@ const logger = createLogger('Service:TeamTranscriptProjectResolver'); const SESSION_DISCOVERY_CACHE_TTL = 30_000; const TEAM_AFFINITY_SCAN_LINES = 40; +const TEAM_AFFINITY_FILE_CACHE_MAX_ENTRIES = 4_096; const ROOT_DISCOVERY_CONCURRENCY = 12; const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000; @@ -236,12 +237,20 @@ export interface TeamTranscriptProjectLiveBaseContext { config: TeamConfig; } +interface TeamAffinityFileCacheEntry { + mtimeMs: number; + size: number; + belongsToTeam: boolean; +} + export class TeamTranscriptProjectResolver { private readonly contextCache = new Map< string, { value: TeamTranscriptProjectContext; expiresAt: number } >(); + private readonly teamAffinityFileCache = new Map(); + constructor( private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader() ) {} @@ -994,9 +1003,31 @@ export class TeamTranscriptProjectResolver { } private async fileBelongsToTeam(filePath: string, teamName: string): Promise { + const normalizedTeam = teamName.trim().toLowerCase(); + if (!normalizedTeam) { + return false; + } + + let fileStat: { mtimeMs: number; size: number; isFile: () => boolean }; + try { + fileStat = await fs.stat(filePath); + } catch { + return false; + } + + if (!fileStat.isFile()) { + return false; + } + + const cacheKey = this.buildTeamAffinityFileCacheKey(filePath, normalizedTeam); + const cached = this.teamAffinityFileCache.get(cacheKey); + if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) { + return cached.belongsToTeam; + } + const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - const normalizedTeam = teamName.trim().toLowerCase(); + let belongsToTeam = false; try { let inspected = 0; @@ -1011,15 +1042,18 @@ export class TeamTranscriptProjectResolver { const entry = JSON.parse(trimmed) as Record; const directTeamName = extractDirectTeamName(entry); if (directTeamName === normalizedTeam) { - return true; + belongsToTeam = true; + break; } if (entryContainsNestedTeamName(entry, normalizedTeam)) { - return true; + belongsToTeam = true; + break; } const textContent = extractTextContent(entry); if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { - return true; + belongsToTeam = true; + break; } } catch { // ignore malformed head lines @@ -1036,6 +1070,28 @@ export class TeamTranscriptProjectResolver { stream.destroy(); } - return false; + this.setTeamAffinityFileCacheEntry(cacheKey, { + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + belongsToTeam, + }); + return belongsToTeam; + } + + private buildTeamAffinityFileCacheKey(filePath: string, normalizedTeam: string): string { + return `${normalizedTeam}\0${filePath}`; + } + + private setTeamAffinityFileCacheEntry(cacheKey: string, entry: TeamAffinityFileCacheEntry): void { + if ( + !this.teamAffinityFileCache.has(cacheKey) && + this.teamAffinityFileCache.size >= TEAM_AFFINITY_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.teamAffinityFileCache.keys().next().value; + if (oldestKey) { + this.teamAffinityFileCache.delete(oldestKey); + } + } + this.teamAffinityFileCache.set(cacheKey, entry); } } diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts index 699a915c..786c048e 100644 --- a/test/main/services/team/TeamTranscriptProjectResolver.test.ts +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -1,7 +1,6 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, describe, expect, it, vi } from 'vitest'; import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver'; @@ -521,6 +520,53 @@ describe('TeamTranscriptProjectResolver', () => { expect(context?.config.projectPath).toBe(repairedProjectPath); }); + it('refreshes team affinity cache when a transcript file changes', async () => { + await setupClaudeRoot(); + + const teamName = 'vector-room-55555552'; + const staleProjectPath = '/Users/test/hookplex'; + const repairedProjectPath = '/Users/test/plugin-kit-ai'; + const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); + await fs.mkdir(staleProjectDir, { recursive: true }); + const repaired = await createTeamAwareSessionFile( + repairedProjectPath, + 'lead-1', + teamName, + 'text' + ); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const firstContext = await resolver.getContext(teamName, { forceRefresh: true }); + + expect(firstContext?.projectDir).toBe(repaired.projectDir); + + await fs.writeFile( + repaired.jsonlPath, + `${JSON.stringify({ + type: 'assistant', + timestamp: '2026-04-18T10:01:00.000Z', + cwd: repairedProjectPath, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Resolver probe output without team context' }], + }, + })}\n`, + 'utf8' + ); + const updatedAt = new Date(Date.now() + 5_000); + await fs.utimes(repaired.jsonlPath, updatedAt, updatedAt); + + const secondContext = await resolver.getContext(teamName, { forceRefresh: true }); + + expect(secondContext?.projectDir).toBe(staleProjectDir); + }); + it('bounds root session discovery by team lifecycle in fast preview context', async () => { await setupClaudeRoot();