perf: cache team transcript affinity checks

This commit is contained in:
777genius 2026-05-29 12:46:29 +03:00
parent 169ac8bb68
commit 0b97985474
2 changed files with 108 additions and 6 deletions

View file

@ -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<string, TeamAffinityFileCacheEntry>();
constructor(
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
) {}
@ -994,9 +1003,31 @@ export class TeamTranscriptProjectResolver {
}
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
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<string, unknown>;
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);
}
}

View file

@ -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();