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 SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
||||||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||||
|
const TEAM_AFFINITY_FILE_CACHE_MAX_ENTRIES = 4_096;
|
||||||
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
||||||
const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000;
|
const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
|
@ -236,12 +237,20 @@ export interface TeamTranscriptProjectLiveBaseContext {
|
||||||
config: TeamConfig;
|
config: TeamConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamAffinityFileCacheEntry {
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
belongsToTeam: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamTranscriptProjectResolver {
|
export class TeamTranscriptProjectResolver {
|
||||||
private readonly contextCache = new Map<
|
private readonly contextCache = new Map<
|
||||||
string,
|
string,
|
||||||
{ value: TeamTranscriptProjectContext; expiresAt: number }
|
{ value: TeamTranscriptProjectContext; expiresAt: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
private readonly teamAffinityFileCache = new Map<string, TeamAffinityFileCacheEntry>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
|
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -994,9 +1003,31 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
|
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 stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
const normalizedTeam = teamName.trim().toLowerCase();
|
let belongsToTeam = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let inspected = 0;
|
let inspected = 0;
|
||||||
|
|
@ -1011,15 +1042,18 @@ export class TeamTranscriptProjectResolver {
|
||||||
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
const directTeamName = extractDirectTeamName(entry);
|
const directTeamName = extractDirectTeamName(entry);
|
||||||
if (directTeamName === normalizedTeam) {
|
if (directTeamName === normalizedTeam) {
|
||||||
return true;
|
belongsToTeam = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||||
return true;
|
belongsToTeam = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textContent = extractTextContent(entry);
|
const textContent = extractTextContent(entry);
|
||||||
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
||||||
return true;
|
belongsToTeam = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed head lines
|
// ignore malformed head lines
|
||||||
|
|
@ -1036,6 +1070,28 @@ export class TeamTranscriptProjectResolver {
|
||||||
stream.destroy();
|
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 fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver';
|
||||||
|
|
@ -521,6 +520,53 @@ describe('TeamTranscriptProjectResolver', () => {
|
||||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
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 () => {
|
it('bounds root session discovery by team lifecycle in fast preview context', async () => {
|
||||||
await setupClaudeRoot();
|
await setupClaudeRoot();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue