perf: cache team transcript affinity checks
This commit is contained in:
parent
169ac8bb68
commit
0b97985474
2 changed files with 108 additions and 6 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue