From 180bdb7575ca7883381ff2e26a616b8719fd7c14 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 30 May 2026 18:39:16 +0300 Subject: [PATCH] perf(team): cache transcript affinity verdicts --- .../team/TeamTranscriptProjectResolver.ts | 374 ++++++++++++-- .../JsonTeamTranscriptAffinityIndexStore.ts | 170 +++++++ .../teamTranscriptAffinityIndexSchema.ts | 162 ++++++ .../cache/teamTranscriptAffinityIndexTypes.ts | 47 ++ ...onTeamTranscriptAffinityIndexStore.test.ts | 153 ++++++ .../TeamTranscriptProjectResolver.test.ts | 472 +++++++++++++++++- 6 files changed, 1332 insertions(+), 46 deletions(-) create mode 100644 src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts create mode 100644 src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts create mode 100644 src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts create mode 100644 test/main/services/team/JsonTeamTranscriptAffinityIndexStore.test.ts diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index fc02ab3a..9f10fc19 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -8,13 +8,22 @@ import { } from '@main/utils/pathDecoder'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; import { type Dirent } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; import { StringDecoder } from 'string_decoder'; +import { JsonTeamTranscriptAffinityIndexStore } from './cache/JsonTeamTranscriptAffinityIndexStore'; import { TeamConfigReader } from './TeamConfigReader'; +import type { + PersistedTeamTranscriptAffinityEntry, + PersistedTeamTranscriptAffinityIndex, + TeamTranscriptAffinityFileSignature, + TeamTranscriptAffinityIndexStore, + TeamTranscriptAffinityMatchSource, +} from './cache/teamTranscriptAffinityIndexTypes'; import type { TeamConfig } from '@shared/types'; const logger = createLogger('Service:TeamTranscriptProjectResolver'); @@ -63,6 +72,13 @@ interface TeamTranscriptProjectContextOptions { includeTeamSubagentSessionDiscovery?: boolean; } +type TeamTranscriptFileStat = { + mtimeMs: number; + size: number; + ctimeMs?: number; + isFile: () => boolean; +}; + type ScannedSessionProjectMatch = Omit & { projectPath?: string; }; @@ -255,7 +271,10 @@ export interface TeamTranscriptProjectLiveBaseContext { interface TeamAffinityFileCacheEntry { mtimeMs: number; size: number; + ctimeMs?: number; belongsToTeam: boolean; + inspectedLineCount: number; + headFingerprint: string; // True when the verdict was decided after inspecting a FULL head window // (>= TEAM_AFFINITY_SCAN_LINES non-empty lines). For append-only transcripts the // head is immutable, so a `false` verdict from a full window stays valid while the @@ -272,13 +291,21 @@ interface TeamAffinityHeadLineMetadata { interface TeamAffinityHeadMetadataCacheEntry { mtimeMs: number; size: number; + ctimeMs?: number; inspectedLineCount: number; + headFingerprint: string; lines: TeamAffinityHeadLineMetadata[]; } interface TeamAffinityEvaluation { belongsToTeam: boolean; inspectedLineCount: number; + matchSource: TeamTranscriptAffinityMatchSource; +} + +interface TeamAffinityInspectionResult extends TeamAffinityEvaluation { + headWindowFull: boolean; + indexable: boolean; } export class TeamTranscriptProjectResolver { @@ -294,7 +321,8 @@ export class TeamTranscriptProjectResolver { >(); constructor( - private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader() + private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader(), + private readonly affinityIndexStore: TeamTranscriptAffinityIndexStore = new JsonTeamTranscriptAffinityIndexStore() ) {} private readConfigForObservation(teamName: string): Promise { @@ -388,6 +416,7 @@ export class TeamTranscriptProjectResolver { const sessionIds = await this.discoverSessionIds( teamName, resolution.projectDir, + resolution.projectId, resolvedConfig, options ); @@ -538,6 +567,7 @@ export class TeamTranscriptProjectResolver { } const teamRootSessionIds = await this.listTeamRootSessionIds( dirCandidate.projectDir, + dirCandidate.projectId, teamName ); if (teamRootSessionIds.length > 0) { @@ -848,6 +878,7 @@ export class TeamTranscriptProjectResolver { private async discoverSessionIds( teamName: string, projectDir: string, + projectId: string, config: TeamConfig, options?: TeamTranscriptProjectContextOptions ): Promise { @@ -858,7 +889,7 @@ export class TeamTranscriptProjectResolver { ? null : teamLifecycleMtimeCutoffMs(config); const [teamRootSessionIds, teamSubagentSessionIds] = await Promise.all([ - this.listTeamRootSessionIds(projectDir, teamName, rootMtimeSinceMs), + this.listTeamRootSessionIds(projectDir, projectId, teamName, rootMtimeSinceMs), includeTeamSubagentSessionDiscovery ? this.listTeamSubagentSessionIds(projectDir, teamName) : Promise.resolve([]), @@ -992,32 +1023,57 @@ export class TeamTranscriptProjectResolver { private async collectRootJsonlSessionIds( rootJsonlEntries: Dirent[], projectDir: string, + projectId: string, teamName: string, mtimeSinceMs?: number | null ): Promise { const discovered = new Set(); + const rootFileNames = new Set(rootJsonlEntries.map((entry) => entry.name)); + const indexEnabled = this.isPersistentAffinityIndexEnabled(); + const affinityIndex = indexEnabled + ? await this.loadTeamTranscriptAffinityIndex(teamName, projectId) + : null; + const shouldPruneAffinityIndex = Boolean( + affinityIndex && + Object.keys(affinityIndex.entries).some((fileName) => !rootFileNames.has(fileName)) + ); + const pendingIndexEntries: PersistedTeamTranscriptAffinityEntry[] = []; let nextIndex = 0; const scanNextRootEntry = async (): Promise => { while (nextIndex < rootJsonlEntries.length) { const entry = rootJsonlEntries[nextIndex++]; const filePath = path.join(projectDir, entry.name); - let precomputedStat: { mtimeMs: number; size: number; isFile: () => boolean } | undefined; - if (mtimeSinceMs != null) { - try { - const stat = await fs.stat(filePath); - if (!stat.isFile() || stat.mtimeMs < mtimeSinceMs) { - continue; - } - precomputedStat = stat; - } catch { - continue; - } - } - if (!(await this.fileBelongsToTeam(filePath, teamName, precomputedStat))) { + let fileStat: TeamTranscriptFileStat; + try { + fileStat = await fs.stat(filePath); + } catch { continue; } - discovered.add(entry.name.slice(0, -'.jsonl'.length)); + if (!fileStat.isFile() || (mtimeSinceMs != null && fileStat.mtimeMs < mtimeSinceMs)) { + continue; + } + + const indexedBelongsToTeam = indexEnabled + ? this.decideTeamAffinityFromIndex(affinityIndex?.entries[entry.name], fileStat) + : null; + if (indexedBelongsToTeam !== null) { + if (indexedBelongsToTeam) { + discovered.add(entry.name.slice(0, -'.jsonl'.length)); + } + continue; + } + + const inspection = await this.inspectFileTeamAffinity(filePath, teamName, fileStat); + if (inspection.belongsToTeam) { + discovered.add(entry.name.slice(0, -'.jsonl'.length)); + } + if (inspection.indexable) { + const indexEntry = this.buildTeamAffinityIndexEntry(entry.name, fileStat, inspection); + if (indexEntry) { + pendingIndexEntries.push(indexEntry); + } + } } }; @@ -1027,11 +1083,26 @@ export class TeamTranscriptProjectResolver { ) ); + if (indexEnabled && (pendingIndexEntries.length > 0 || shouldPruneAffinityIndex)) { + await this.affinityIndexStore + .upsertProjectEntries({ + teamName, + projectId, + projectDir, + rootFileNames, + entries: pendingIndexEntries, + }) + .catch((error) => { + logger.debug(`Failed to write transcript affinity index: ${String(error)}`); + }); + } + return [...discovered]; } private async listTeamRootSessionIds( projectDir: string, + projectId: string, teamName: string, mtimeSinceMs?: number | null ): Promise { @@ -1043,49 +1114,89 @@ export class TeamTranscriptProjectResolver { const rootJsonlEntries = dirEntries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); - return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName, mtimeSinceMs); + return this.collectRootJsonlSessionIds( + rootJsonlEntries, + projectDir, + projectId, + teamName, + mtimeSinceMs + ); } private async fileBelongsToTeam( filePath: string, teamName: string, - precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean } + precomputedStat?: TeamTranscriptFileStat ): Promise { + return (await this.inspectFileTeamAffinity(filePath, teamName, precomputedStat)).belongsToTeam; + } + + private async inspectFileTeamAffinity( + filePath: string, + teamName: string, + precomputedStat?: TeamTranscriptFileStat + ): Promise { + const emptyResult: TeamAffinityInspectionResult = { + belongsToTeam: false, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: false, + indexable: false, + }; const normalizedTeam = teamName.trim().toLowerCase(); if (!normalizedTeam) { - return false; + return emptyResult; } // Reuse the caller's stat when it already statted this exact file (the mtime-window // filter in collectRootJsonlSessionIds does). On the live resolution path this drops // a second fs.stat of the same file per entry, every poll — and using a single stat // snapshot is also more consistent than two reads that could straddle a write. - let fileStat: { mtimeMs: number; size: number; isFile: () => boolean }; + let fileStat: TeamTranscriptFileStat; if (precomputedStat) { fileStat = precomputedStat; } else { try { fileStat = await fs.stat(filePath); } catch { - return false; + return emptyResult; } } if (!fileStat.isFile()) { - return false; + return emptyResult; } const cacheKey = this.buildTeamAffinityFileCacheKey(filePath, normalizedTeam); const cached = this.teamAffinityFileCache.get(cacheKey); if (cached) { + if (this.teamTranscriptFileSignaturesMatch(cached, fileStat)) { + return { + belongsToTeam: cached.belongsToTeam, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: cached.headWindowFull, + indexable: false, + }; + } // A positive affinity is decided by early "head" lines that persist as an // append-only transcript grows, so a `true` result stays valid while the file // only grows (size >= cached). This avoids re-streaming the team's own // continuously-growing transcripts on every bootstrap poll. A `false` result // is still re-checked on any change, since a short file may later grow head // lines that mention the team; a shrink (rewrite/truncate) also forces a re-scan. - if (cached.belongsToTeam && fileStat.size >= cached.size) { - return true; + if ( + cached.belongsToTeam && + fileStat.size >= cached.size && + (await this.isCachedTeamAffinityHeadCurrent(filePath, cached)) + ) { + return { + belongsToTeam: true, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: cached.headWindowFull, + indexable: false, + }; } // A `false` decided from a FULL head window is durable while the file only // grows: the first TEAM_AFFINITY_SCAN_LINES lines of an append-only transcript @@ -1094,27 +1205,45 @@ export class TeamTranscriptProjectResolver { // re-scan below, identically to the positive path. This is the main launch win: // non-matching transcripts in the project dir are no longer re-streamed + // re-parsed on every bootstrap poll. - if (!cached.belongsToTeam && cached.headWindowFull && fileStat.size >= cached.size) { - return false; - } - if (cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) { - return cached.belongsToTeam; + if ( + !cached.belongsToTeam && + cached.headWindowFull && + fileStat.size >= cached.size && + (await this.isCachedTeamAffinityHeadCurrent(filePath, cached)) + ) { + return { + belongsToTeam: false, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: true, + indexable: false, + }; } } const headMetadata = await this.getTeamAffinityHeadMetadata(filePath, fileStat); if (!headMetadata) { - return false; + return emptyResult; } const evaluation = this.evaluateTeamAffinityHeadMetadata(headMetadata, normalizedTeam); + const headWindowFull = evaluation.inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES; this.setTeamAffinityFileCacheEntry(cacheKey, { mtimeMs: fileStat.mtimeMs, size: fileStat.size, + ...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) + ? { ctimeMs: fileStat.ctimeMs } + : {}), belongsToTeam: evaluation.belongsToTeam, - headWindowFull: evaluation.inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES, + inspectedLineCount: headMetadata.inspectedLineCount, + headFingerprint: headMetadata.headFingerprint, + headWindowFull, }); - return evaluation.belongsToTeam; + return { + ...evaluation, + headWindowFull, + indexable: true, + }; } private evaluateTeamAffinityHeadMetadata( @@ -1125,31 +1254,112 @@ export class TeamTranscriptProjectResolver { for (const line of metadata.lines) { inspectedLineCount += 1; if (line.nestedTeamNames.has(normalizedTeam)) { - return { belongsToTeam: true, inspectedLineCount }; + return { belongsToTeam: true, inspectedLineCount, matchSource: 'nested_team_name' }; } if ( line.normalizedTextContent && lineMentionsNormalizedTeam(line.normalizedTextContent, normalizedTeam) ) { - return { belongsToTeam: true, inspectedLineCount }; + return { belongsToTeam: true, inspectedLineCount, matchSource: 'text_team_mention' }; } } - return { belongsToTeam: false, inspectedLineCount: metadata.inspectedLineCount }; + return { + belongsToTeam: false, + inspectedLineCount: metadata.inspectedLineCount, + matchSource: 'none', + }; } - private async getTeamAffinityHeadMetadata( - filePath: string, - fileStat: { mtimeMs: number; size: number } - ): Promise { - const cached = this.teamAffinityHeadMetadataCache.get(filePath); - if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) { - return cached; + private isPersistentAffinityIndexEnabled(): boolean { + return process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX !== '0'; + } + + private async loadTeamTranscriptAffinityIndex( + teamName: string, + projectId: string + ): Promise { + try { + return await this.affinityIndexStore.loadProject(teamName, projectId); + } catch (error) { + logger.debug(`Failed to load transcript affinity index: ${String(error)}`); + return null; } - if (cached) { - this.teamAffinityHeadMetadataCache.delete(filePath); + } + + private decideTeamAffinityFromIndex( + entry: PersistedTeamTranscriptAffinityEntry | undefined, + fileStat: TeamTranscriptFileStat + ): boolean | null { + if (!entry) { + return null; + } + if (!this.teamTranscriptFileSignaturesMatch(entry.signature, fileStat)) { + return null; + } + return entry.verdict === 'belongs'; + } + + private teamTranscriptFileSignaturesMatch( + cached: { size: number; mtimeMs: number; ctimeMs?: number }, + fileStat: { size: number; mtimeMs: number; ctimeMs?: number } + ): boolean { + if (cached.size !== fileStat.size || cached.mtimeMs !== fileStat.mtimeMs) { + return false; + } + const cachedCtimeMs = + cached.ctimeMs != null && Number.isFinite(cached.ctimeMs) ? cached.ctimeMs : null; + const currentCtimeMs = + fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) ? fileStat.ctimeMs : null; + if (cachedCtimeMs !== null || currentCtimeMs !== null) { + return cachedCtimeMs !== null && currentCtimeMs !== null && cachedCtimeMs === currentCtimeMs; + } + return true; + } + + private buildTeamAffinityIndexEntry( + fileName: string, + fileStat: TeamTranscriptFileStat, + inspection: TeamAffinityInspectionResult + ): PersistedTeamTranscriptAffinityEntry | null { + if ( + fileName.length <= '.jsonl'.length || + !fileName.endsWith('.jsonl') || + fileName.includes('/') || + fileName.includes('\\') + ) { + return null; } - const lines: TeamAffinityHeadLineMetadata[] = []; + const sessionId = fileName.slice(0, -'.jsonl'.length); + const signature: TeamTranscriptAffinityFileSignature = { + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + ...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) + ? { ctimeMs: fileStat.ctimeMs } + : {}), + }; + + return { + fileName, + sessionId, + signature, + verdict: inspection.belongsToTeam ? 'belongs' : 'does_not_belong', + headWindowFull: inspection.headWindowFull, + inspectedLineCount: inspection.inspectedLineCount, + matchSource: inspection.matchSource, + writtenAt: new Date().toISOString(), + }; + } + + private async isCachedTeamAffinityHeadCurrent( + filePath: string, + cached: TeamAffinityFileCacheEntry + ): Promise { + if (cached.inspectedLineCount <= 0) { + return false; + } + + const fingerprint = createHash('sha256'); let inspectedLineCount = 0; const inspectHeadLine = (rawLine: string): boolean => { const trimmed = rawLine.trim(); @@ -1157,6 +1367,76 @@ export class TeamTranscriptProjectResolver { return false; } inspectedLineCount += 1; + fingerprint.update(trimmed); + fingerprint.update('\n'); + return inspectedLineCount >= cached.inspectedLineCount; + }; + + let handle: fs.FileHandle | null = null; + try { + handle = await fs.open(filePath, 'r'); + const decoder = new StringDecoder('utf8'); + const chunk = Buffer.allocUnsafe(TEAM_AFFINITY_READ_CHUNK_BYTES); + let pending = ''; + let position = 0; + let stop = false; + while (!stop) { + const { bytesRead } = await handle.read(chunk, 0, chunk.length, position); + if (bytesRead <= 0) { + pending += decoder.end(); + if (pending.length > 0) { + inspectHeadLine(pending); + } + break; + } + position += bytesRead; + pending += decoder.write(chunk.subarray(0, bytesRead)); + let newlineIndex = pending.indexOf('\n'); + while (newlineIndex !== -1) { + const line = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + if (inspectHeadLine(line)) { + stop = true; + break; + } + newlineIndex = pending.indexOf('\n'); + } + } + } catch { + return false; + } finally { + await handle?.close().catch(() => undefined); + } + + return ( + inspectedLineCount === cached.inspectedLineCount && + fingerprint.digest('hex') === cached.headFingerprint + ); + } + + private async getTeamAffinityHeadMetadata( + filePath: string, + fileStat: { mtimeMs: number; size: number; ctimeMs?: number } + ): Promise { + const cached = this.teamAffinityHeadMetadataCache.get(filePath); + if (cached && this.teamTranscriptFileSignaturesMatch(cached, fileStat)) { + return cached; + } + if (cached) { + this.teamAffinityHeadMetadataCache.delete(filePath); + } + + const lines: TeamAffinityHeadLineMetadata[] = []; + const fingerprint = createHash('sha256'); + let inspectedLineCount = 0; + const inspectHeadLine = (rawLine: string): boolean => { + const trimmed = rawLine.trim(); + if (!trimmed) { + return false; + } + inspectedLineCount += 1; + fingerprint.update(trimmed); + fingerprint.update('\n'); lines.push(parseTeamAffinityHeadLine(trimmed)); return inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES; }; @@ -1201,7 +1481,11 @@ export class TeamTranscriptProjectResolver { const entry = { mtimeMs: fileStat.mtimeMs, size: fileStat.size, + ...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) + ? { ctimeMs: fileStat.ctimeMs } + : {}), inspectedLineCount, + headFingerprint: fingerprint.digest('hex'), lines, }; this.setTeamAffinityHeadMetadataCacheEntry(filePath, entry); diff --git a/src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts b/src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts new file mode 100644 index 00000000..52ee6c34 --- /dev/null +++ b/src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts @@ -0,0 +1,170 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { + normalizeTeamTranscriptAffinityIndex, + toTeamTranscriptAffinityIndex, +} from './teamTranscriptAffinityIndexSchema'; +import { + type PersistedTeamTranscriptAffinityEntry, + type PersistedTeamTranscriptAffinityIndex, + TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT, + TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + type TeamTranscriptAffinityIndexStore, +} from './teamTranscriptAffinityIndexTypes'; + +const logger = createLogger('Service:JsonTeamTranscriptAffinityIndexStore'); + +const READ_TIMEOUT_MS = 5_000; + +function encodeFileSegment(value: string): string { + return encodeURIComponent(value); +} + +function sortEntriesByFreshness( + entries: PersistedTeamTranscriptAffinityEntry[] +): PersistedTeamTranscriptAffinityEntry[] { + return [...entries].sort((left, right) => { + const rightWrittenAt = Date.parse(right.writtenAt); + const leftWrittenAt = Date.parse(left.writtenAt); + return rightWrittenAt - leftWrittenAt || right.fileName.localeCompare(left.fileName); + }); +} + +export class JsonTeamTranscriptAffinityIndexStore implements TeamTranscriptAffinityIndexStore { + private readonly writeChains = new Map>(); + + constructor(private readonly options: { maxEntriesPerProject?: number } = {}) {} + + private get maxEntriesPerProject(): number { + return Math.max( + 1, + this.options.maxEntriesPerProject ?? TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT + ); + } + + private filePath(teamName: string, projectId: string): string { + return path.join( + getTeamsBasePath(), + teamName, + 'cache', + 'transcript-affinity', + `${encodeFileSegment(projectId)}.json` + ); + } + + private writeChainKey(teamName: string, projectId: string): string { + return `${teamName}\0${projectId}`; + } + + private async readIndex( + teamName: string, + projectId: string + ): Promise { + const filePath = this.filePath(teamName, projectId); + let content: string; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS); + try { + content = await fs.readFile(filePath, { + encoding: 'utf8', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.debug(`Failed to read transcript affinity index ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + logger.debug(`Corrupted transcript affinity index ${filePath}: ${String(error)}`); + await fs.unlink(filePath).catch(() => undefined); + return null; + } + + const normalized = normalizeTeamTranscriptAffinityIndex(parsed); + if (!normalized || normalized.teamName !== teamName || normalized.projectId !== projectId) { + await fs.unlink(filePath).catch(() => undefined); + return null; + } + + return normalized; + } + + async loadProject( + teamName: string, + projectId: string + ): Promise { + return this.readIndex(teamName, projectId); + } + + async upsertProjectEntries(input: { + teamName: string; + projectId: string; + projectDir: string; + rootFileNames: ReadonlySet; + entries: readonly PersistedTeamTranscriptAffinityEntry[]; + }): Promise { + const chainKey = this.writeChainKey(input.teamName, input.projectId); + const write = async (): Promise => { + const current = await this.readIndex(input.teamName, input.projectId); + const entries = new Map(); + + for (const [fileName, entry] of Object.entries(current?.entries ?? {})) { + if (input.rootFileNames.has(fileName)) { + entries.set(fileName, entry); + } + } + + for (const entry of input.entries) { + if (input.rootFileNames.has(entry.fileName)) { + entries.set(entry.fileName, entry); + } + } + + const cappedEntries = sortEntriesByFreshness([...entries.values()]).slice( + 0, + this.maxEntriesPerProject + ); + const next = toTeamTranscriptAffinityIndex({ + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + teamName: input.teamName, + projectId: input.projectId, + projectDir: input.projectDir, + writtenAt: new Date().toISOString(), + entries: Object.fromEntries(cappedEntries.map((entry) => [entry.fileName, entry])), + }); + + await atomicWriteAsync( + this.filePath(input.teamName, input.projectId), + `${JSON.stringify(next, null, 2)}\n` + ); + }; + + const previous = this.writeChains.get(chainKey) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(write) + .finally(() => { + if (this.writeChains.get(chainKey) === next) { + this.writeChains.delete(chainKey); + } + }); + + this.writeChains.set(chainKey, next); + await next; + } +} diff --git a/src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts b/src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts new file mode 100644 index 00000000..61f3f0c6 --- /dev/null +++ b/src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts @@ -0,0 +1,162 @@ +import { + type PersistedTeamTranscriptAffinityEntry, + type PersistedTeamTranscriptAffinityIndex, + TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + type TeamTranscriptAffinityFileSignature, + type TeamTranscriptAffinityMatchSource, + type TeamTranscriptAffinityVerdict, +} from './teamTranscriptAffinityIndexTypes'; + +function isIsoString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value)); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isValidFileName(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > '.jsonl'.length && + value.endsWith('.jsonl') && + !value.includes('/') && + !value.includes('\\') + ); +} + +function sessionIdFromFileName(fileName: string): string { + return fileName.slice(0, -'.jsonl'.length); +} + +function normalizeVerdict(value: unknown): TeamTranscriptAffinityVerdict | null { + return value === 'belongs' || value === 'does_not_belong' ? value : null; +} + +function normalizeMatchSource(value: unknown): TeamTranscriptAffinityMatchSource | null { + return value === 'nested_team_name' || value === 'text_team_mention' || value === 'none' + ? value + : null; +} + +function normalizeSignature(value: unknown): TeamTranscriptAffinityFileSignature | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + if (!isFiniteNonNegativeNumber(raw.size) || !isFiniteNonNegativeNumber(raw.mtimeMs)) { + return null; + } + if (raw.ctimeMs != null && !isFiniteNonNegativeNumber(raw.ctimeMs)) { + return null; + } + + return { + size: raw.size, + mtimeMs: raw.mtimeMs, + ...(raw.ctimeMs != null ? { ctimeMs: raw.ctimeMs } : {}), + }; +} + +export function normalizeTeamTranscriptAffinityEntry( + fileName: string, + value: unknown +): PersistedTeamTranscriptAffinityEntry | null { + if (!isValidFileName(fileName) || !value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + const verdict = normalizeVerdict(raw.verdict); + const signature = normalizeSignature(raw.signature); + const matchSource = normalizeMatchSource(raw.matchSource); + const expectedSessionId = sessionIdFromFileName(fileName); + + if ( + raw.fileName !== fileName || + raw.sessionId !== expectedSessionId || + !signature || + !verdict || + typeof raw.headWindowFull !== 'boolean' || + !Number.isInteger(raw.inspectedLineCount) || + !isFiniteNonNegativeNumber(raw.inspectedLineCount) || + !matchSource || + !isIsoString(raw.writtenAt) + ) { + return null; + } + + return { + fileName, + sessionId: expectedSessionId, + signature, + verdict, + headWindowFull: raw.headWindowFull, + inspectedLineCount: raw.inspectedLineCount, + matchSource, + writtenAt: raw.writtenAt, + }; +} + +export function normalizeTeamTranscriptAffinityIndex( + value: unknown +): PersistedTeamTranscriptAffinityIndex | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + if ( + raw.version !== TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION || + typeof raw.teamName !== 'string' || + raw.teamName.length === 0 || + typeof raw.projectId !== 'string' || + raw.projectId.length === 0 || + typeof raw.projectDir !== 'string' || + raw.projectDir.length === 0 || + !isIsoString(raw.writtenAt) || + !raw.entries || + typeof raw.entries !== 'object' + ) { + return null; + } + + const entries: Record = {}; + for (const [fileName, entry] of Object.entries(raw.entries as Record)) { + const normalized = normalizeTeamTranscriptAffinityEntry(fileName, entry); + if (normalized) { + entries[fileName] = normalized; + } + } + + return { + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + teamName: raw.teamName, + projectId: raw.projectId, + projectDir: raw.projectDir, + writtenAt: raw.writtenAt, + entries, + }; +} + +export function toTeamTranscriptAffinityIndex( + value: PersistedTeamTranscriptAffinityIndex +): PersistedTeamTranscriptAffinityIndex { + const entries: Record = {}; + for (const [fileName, entry] of Object.entries(value.entries)) { + const normalized = normalizeTeamTranscriptAffinityEntry(fileName, entry); + if (normalized) { + entries[fileName] = normalized; + } + } + + return { + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + teamName: value.teamName, + projectId: value.projectId, + projectDir: value.projectDir, + writtenAt: value.writtenAt, + entries, + }; +} diff --git a/src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts b/src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts new file mode 100644 index 00000000..2a80c505 --- /dev/null +++ b/src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts @@ -0,0 +1,47 @@ +export const TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION = 1; +export const TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT = 20_000; + +export type TeamTranscriptAffinityVerdict = 'belongs' | 'does_not_belong'; + +export type TeamTranscriptAffinityMatchSource = 'nested_team_name' | 'text_team_mention' | 'none'; + +export interface TeamTranscriptAffinityFileSignature { + size: number; + mtimeMs: number; + ctimeMs?: number; +} + +export interface PersistedTeamTranscriptAffinityEntry { + fileName: string; + sessionId: string; + signature: TeamTranscriptAffinityFileSignature; + verdict: TeamTranscriptAffinityVerdict; + headWindowFull: boolean; + inspectedLineCount: number; + matchSource: TeamTranscriptAffinityMatchSource; + writtenAt: string; +} + +export interface PersistedTeamTranscriptAffinityIndex { + version: typeof TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION; + teamName: string; + projectId: string; + projectDir: string; + writtenAt: string; + entries: Record; +} + +export interface TeamTranscriptAffinityIndexStore { + loadProject( + teamName: string, + projectId: string + ): Promise; + + upsertProjectEntries(input: { + teamName: string; + projectId: string; + projectDir: string; + rootFileNames: ReadonlySet; + entries: readonly PersistedTeamTranscriptAffinityEntry[]; + }): Promise; +} diff --git a/test/main/services/team/JsonTeamTranscriptAffinityIndexStore.test.ts b/test/main/services/team/JsonTeamTranscriptAffinityIndexStore.test.ts new file mode 100644 index 00000000..1fbdcf3d --- /dev/null +++ b/test/main/services/team/JsonTeamTranscriptAffinityIndexStore.test.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { JsonTeamTranscriptAffinityIndexStore } from '../../../../src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore'; +import { + type PersistedTeamTranscriptAffinityEntry, + TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, +} from '../../../../src/main/services/team/cache/teamTranscriptAffinityIndexTypes'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +describe('JsonTeamTranscriptAffinityIndexStore', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + setClaudeBasePathOverride(null); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + async function setupClaudeRoot(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-transcript-affinity-index-')); + setClaudeBasePathOverride(tmpDir); + await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true }); + return tmpDir; + } + + function indexPath(teamName: string, projectId: string): string { + return path.join( + tmpDir!, + 'teams', + teamName, + 'cache', + 'transcript-affinity', + `${encodeURIComponent(projectId)}.json` + ); + } + + function entry( + fileName: string, + overrides: Partial = {} + ): PersistedTeamTranscriptAffinityEntry { + return { + fileName, + sessionId: fileName.slice(0, -'.jsonl'.length), + signature: { size: 100, mtimeMs: 200, ctimeMs: 300 }, + verdict: 'belongs', + headWindowFull: false, + inspectedLineCount: 1, + matchSource: 'text_team_mention', + writtenAt: '2026-05-30T10:00:00.000Z', + ...overrides, + }; + } + + it('returns null for a missing index', async () => { + await setupClaudeRoot(); + const store = new JsonTeamTranscriptAffinityIndexStore(); + + await expect(store.loadProject('team-a', 'project-a')).resolves.toBeNull(); + }); + + it('upserts entries, prunes deleted root files, and caps by newest writtenAt', async () => { + await setupClaudeRoot(); + const store = new JsonTeamTranscriptAffinityIndexStore({ maxEntriesPerProject: 2 }); + + await store.upsertProjectEntries({ + teamName: 'team-a', + projectId: 'project-a', + projectDir: '/repo/a', + rootFileNames: new Set(['a.jsonl', 'b.jsonl']), + entries: [ + entry('a.jsonl', { writtenAt: '2026-05-30T10:00:00.000Z' }), + entry('b.jsonl', { writtenAt: '2026-05-30T10:01:00.000Z' }), + ], + }); + + await store.upsertProjectEntries({ + teamName: 'team-a', + projectId: 'project-a', + projectDir: '/repo/a', + rootFileNames: new Set(['b.jsonl', 'c.jsonl', 'd.jsonl']), + entries: [ + entry('c.jsonl', { writtenAt: '2026-05-30T10:02:00.000Z' }), + entry('d.jsonl', { writtenAt: '2026-05-30T10:03:00.000Z' }), + ], + }); + + const loaded = await store.loadProject('team-a', 'project-a'); + + expect(Object.keys(loaded?.entries ?? {}).sort()).toEqual(['c.jsonl', 'd.jsonl']); + expect(loaded?.entries['a.jsonl']).toBeUndefined(); + expect(loaded?.projectDir).toBe('/repo/a'); + }); + + it('deletes corrupt or wrong-schema index files without throwing', async () => { + await setupClaudeRoot(); + const store = new JsonTeamTranscriptAffinityIndexStore(); + const filePath = indexPath('team-a', 'project-a'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, '{not-json', 'utf8'); + + await expect(store.loadProject('team-a', 'project-a')).resolves.toBeNull(); + await expect(fs.access(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + JSON.stringify({ + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION + 1, + teamName: 'team-a', + projectId: 'project-a', + projectDir: '/repo/a', + writtenAt: '2026-05-30T10:00:00.000Z', + entries: {}, + }), + 'utf8' + ); + + await expect(store.loadProject('team-a', 'project-a')).resolves.toBeNull(); + await expect(fs.access(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('skips invalid entries while preserving valid entries in the same index', async () => { + await setupClaudeRoot(); + const store = new JsonTeamTranscriptAffinityIndexStore(); + const filePath = indexPath('team-a', 'project-a'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + JSON.stringify({ + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + teamName: 'team-a', + projectId: 'project-a', + projectDir: '/repo/a', + writtenAt: '2026-05-30T10:00:00.000Z', + entries: { + 'good.jsonl': entry('good.jsonl'), + '../bad.jsonl': entry('../bad.jsonl'), + 'wrong-session.jsonl': entry('wrong-session.jsonl', { sessionId: 'different' }), + }, + }), + 'utf8' + ); + + const loaded = await store.loadProject('team-a', 'project-a'); + + expect(Object.keys(loaded?.entries ?? {})).toEqual(['good.jsonl']); + }); +}); diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts index 53ac8655..4a35259e 100644 --- a/test/main/services/team/TeamTranscriptProjectResolver.test.ts +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -6,13 +6,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver'; import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; +import type { TeamTranscriptAffinityIndexStore } from '../../../../src/main/services/team/cache/teamTranscriptAffinityIndexTypes'; import type { TeamConfig } from '../../../../src/shared/types/team'; describe('TeamTranscriptProjectResolver', () => { let tmpDir: string | null = null; + const originalAffinityIndexEnv = process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX; afterEach(async () => { setClaudeBasePathOverride(null); + if (originalAffinityIndexEnv == null) { + delete process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX; + } else { + process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX = originalAffinityIndexEnv; + } if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); tmpDir = null; @@ -38,6 +45,62 @@ describe('TeamTranscriptProjectResolver', () => { return JSON.parse(raw) as TeamConfig; } + function affinityIndexPath(teamName: string, projectId: string): string { + return path.join( + tmpDir!, + 'teams', + teamName, + 'cache', + 'transcript-affinity', + `${encodeURIComponent(projectId)}.json` + ); + } + + async function readAffinityIndex(teamName: string, projectId: string): Promise<{ + entries: Record< + string, + { signature: { size: number; mtimeMs: number; ctimeMs?: number }; verdict: string } + >; + }> { + const raw = await fs.readFile(affinityIndexPath(teamName, projectId), 'utf8'); + return JSON.parse(raw) as { + entries: Record< + string, + { signature: { size: number; mtimeMs: number; ctimeMs?: number }; verdict: string } + >; + }; + } + + async function writeAffinityIndex( + teamName: string, + projectId: string, + value: unknown + ): Promise { + const filePath = affinityIndexPath(teamName, projectId); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8'); + } + + function sameByteLengthNoTeamTranscript(targetBytes: number): string { + for (let length = 0; length < targetBytes; length += 1) { + const candidate = `${JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: 'x'.repeat(length), + }, + })}\n`; + const candidateBytes = Buffer.byteLength(candidate, 'utf8'); + if (candidateBytes === targetBytes) { + return candidate; + } + if (candidateBytes > targetBytes) { + break; + } + } + throw new Error(`Could not create same-byte transcript for ${targetBytes} bytes`); + } + async function createSessionFile( projectPath: string, sessionId: string, @@ -624,6 +687,325 @@ describe('TeamTranscriptProjectResolver', () => { expect(fullContext?.sessionIds).toContain('old-member-session'); }); + it('uses a persistent exact affinity index without re-reading matching root transcript heads', async () => { + await setupClaudeRoot(); + + const teamName = 'persistent-index-team'; + const projectPath = '/Users/test/persistent-index'; + const sessionId = 'lead-indexed'; + await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text'); + await writeTeamConfig(teamName, { + name: 'Persistent Index Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const firstResolver = new TeamTranscriptProjectResolver(); + const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true }); + expect(firstContext?.sessionIds).toContain(sessionId); + + type ResolverScanProbe = { + getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise; + }; + const secondResolver = new TeamTranscriptProjectResolver(); + const scanSpy = vi.spyOn( + secondResolver as unknown as ResolverScanProbe, + 'getTeamAffinityHeadMetadata' + ); + scanSpy.mockRejectedValue(new Error('persistent index should bypass head scan')); + + const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true }); + + expect(secondContext?.sessionIds).toContain(sessionId); + expect(scanSpy).not.toHaveBeenCalled(); + scanSpy.mockRestore(); + }); + + it('falls back to a fresh head scan when a persistent index signature is stale', async () => { + await setupClaudeRoot(); + + const teamName = 'stale-persistent-index-team'; + const projectPath = '/Users/test/stale-persistent-index'; + const sessionId = 'stale-indexed'; + const created = await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text'); + await writeTeamConfig(teamName, { + name: 'Stale Persistent Index Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const firstResolver = new TeamTranscriptProjectResolver(); + const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true }); + expect(firstContext?.sessionIds).toContain(sessionId); + + await fs.writeFile( + created.jsonlPath, + `${JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: 'no team mention here' }, + })}\n`, + 'utf8' + ); + const updatedAt = new Date(Date.now() + 5_000); + await fs.utimes(created.jsonlPath, updatedAt, updatedAt); + + const secondResolver = new TeamTranscriptProjectResolver(); + const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true }); + + expect(secondContext?.sessionIds).not.toContain(sessionId); + }); + + it('treats ctime mismatch as stale even when persistent index size and mtime still match', async () => { + await setupClaudeRoot(); + + const teamName = 'ctime-persistent-index-team'; + const projectPath = '/Users/test/ctime-persistent-index'; + const projectId = encodePath(projectPath); + const sessionId = 'ctime-indexed'; + const created = await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text'); + const stableTime = new Date('2026-05-30T10:00:00.000Z'); + await fs.utimes(created.jsonlPath, stableTime, stableTime); + await writeTeamConfig(teamName, { + name: 'Ctime Persistent Index Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const firstResolver = new TeamTranscriptProjectResolver(); + const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true }); + expect(firstContext?.sessionIds).toContain(sessionId); + + const indexedBefore = await readAffinityIndex(teamName, projectId); + const originalSize = indexedBefore.entries[`${sessionId}.jsonl`].signature.size; + await fs.writeFile(created.jsonlPath, sameByteLengthNoTeamTranscript(originalSize), 'utf8'); + await fs.utimes(created.jsonlPath, stableTime, stableTime); + const currentStat = await fs.stat(created.jsonlPath); + + expect(currentStat.size).toBe(originalSize); + expect(currentStat.mtimeMs).toBe(indexedBefore.entries[`${sessionId}.jsonl`].signature.mtimeMs); + expect(currentStat.ctimeMs).not.toBe( + indexedBefore.entries[`${sessionId}.jsonl`].signature.ctimeMs + ); + + const secondResolver = new TeamTranscriptProjectResolver(); + const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true }); + + expect(secondContext?.sessionIds).not.toContain(sessionId); + }); + + it('treats a persistent index entry without ctime as stale when the file stat has ctime', async () => { + await setupClaudeRoot(); + + const teamName = 'missing-ctime-persistent-index-team'; + const projectPath = '/Users/test/missing-ctime-persistent-index'; + const projectId = encodePath(projectPath); + const sessionId = 'missing-ctime-indexed'; + await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text'); + await writeTeamConfig(teamName, { + name: 'Missing Ctime Persistent Index Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const firstResolver = new TeamTranscriptProjectResolver(); + const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true }); + expect(firstContext?.sessionIds).toContain(sessionId); + + const index = await readAffinityIndex(teamName, projectId); + delete index.entries[`${sessionId}.jsonl`].signature.ctimeMs; + await writeAffinityIndex(teamName, projectId, index); + + type ResolverScanProbe = { + getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise; + }; + const secondResolver = new TeamTranscriptProjectResolver(); + const scanSpy = vi.spyOn( + secondResolver as unknown as ResolverScanProbe, + 'getTeamAffinityHeadMetadata' + ); + + const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true }); + + expect(secondContext?.sessionIds).toContain(sessionId); + expect(scanSpy).toHaveBeenCalled(); + scanSpy.mockRestore(); + }); + + it('reuses exact persistent negatives but rescans after a short transcript grows', async () => { + await setupClaudeRoot(); + + const teamName = 'negative-persistent-index-team'; + const projectPath = '/Users/test/negative-persistent-index'; + const sessionId = 'short-negative'; + const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath)); + const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + jsonlPath, + `${[0, 1, 2] + .map((i) => + JSON.stringify({ type: 'user', message: { role: 'user', content: `noise ${i}` } }) + ) + .join('\n')}\n`, + 'utf8' + ); + await writeTeamConfig(teamName, { + name: 'Negative Persistent Index Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const firstResolver = new TeamTranscriptProjectResolver(); + const firstContext = await firstResolver.getContext(teamName, { forceRefresh: true }); + expect(firstContext?.sessionIds).not.toContain(sessionId); + + type ResolverScanProbe = { + getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise; + }; + const secondResolver = new TeamTranscriptProjectResolver(); + const scanSpy = vi.spyOn( + secondResolver as unknown as ResolverScanProbe, + 'getTeamAffinityHeadMetadata' + ); + scanSpy.mockRejectedValue(new Error('persistent negative should bypass head scan')); + + const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true }); + expect(secondContext?.sessionIds).not.toContain(sessionId); + expect(scanSpy).not.toHaveBeenCalled(); + scanSpy.mockRestore(); + + await fs.appendFile( + jsonlPath, + `${JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: `Current team context:\n- Team name: ${teamName}` }], + }, + })}\n`, + 'utf8' + ); + const updatedAt = new Date(Date.now() + 5_000); + await fs.utimes(jsonlPath, updatedAt, updatedAt); + + const thirdResolver = new TeamTranscriptProjectResolver(); + const thirdContext = await thirdResolver.getContext(teamName, { forceRefresh: true }); + + expect(thirdContext?.sessionIds).toContain(sessionId); + }); + + it('prunes persistent affinity entries for deleted root transcripts without requiring a new scan', async () => { + await setupClaudeRoot(); + + const teamName = 'prune-persistent-index-team'; + const projectPath = '/Users/test/prune-persistent-index'; + const projectId = encodePath(projectPath); + const keptSessionId = 'kept-session'; + const deletedSessionId = 'deleted-session'; + const kept = await createTeamAwareSessionFile(projectPath, keptSessionId, teamName, 'text'); + const deleted = await createTeamAwareSessionFile( + projectPath, + deletedSessionId, + teamName, + 'text' + ); + await writeTeamConfig(teamName, { + name: 'Prune Persistent Index Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const firstResolver = new TeamTranscriptProjectResolver(); + await firstResolver.getContext(teamName, { forceRefresh: true }); + const indexedBefore = await readAffinityIndex(teamName, projectId); + expect(Object.keys(indexedBefore.entries).sort()).toEqual([ + `${deletedSessionId}.jsonl`, + `${keptSessionId}.jsonl`, + ]); + + await fs.rm(deleted.jsonlPath); + + type ResolverScanProbe = { + getTeamAffinityHeadMetadata: (...args: unknown[]) => Promise; + }; + const secondResolver = new TeamTranscriptProjectResolver(); + const scanSpy = vi.spyOn( + secondResolver as unknown as ResolverScanProbe, + 'getTeamAffinityHeadMetadata' + ); + scanSpy.mockRejectedValue(new Error('remaining exact index hit should bypass head scan')); + + const secondContext = await secondResolver.getContext(teamName, { forceRefresh: true }); + const indexedAfter = await readAffinityIndex(teamName, projectId); + + expect(secondContext?.sessionIds).toContain(keptSessionId); + expect(secondContext?.sessionIds).not.toContain(deletedSessionId); + expect(scanSpy).not.toHaveBeenCalled(); + expect(Object.keys(indexedAfter.entries)).toEqual([`${keptSessionId}.jsonl`]); + await fs.access(kept.jsonlPath); + scanSpy.mockRestore(); + }); + + it('keeps discovering sessions when the persistent affinity store load or write fails', async () => { + await setupClaudeRoot(); + + const teamName = 'failing-store-index-team'; + const projectPath = '/Users/test/failing-store-index'; + const sessionId = 'failing-store-session'; + await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text'); + await writeTeamConfig(teamName, { + name: 'Failing Store Index Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const store: TeamTranscriptAffinityIndexStore = { + loadProject: vi.fn(async () => { + throw new Error('load failed'); + }), + upsertProjectEntries: vi.fn(async () => { + throw new Error('write failed'); + }), + }; + const resolver = new TeamTranscriptProjectResolver(undefined, store); + + const context = await resolver.getContext(teamName, { forceRefresh: true }); + + expect(context?.sessionIds).toContain(sessionId); + expect(store.loadProject).toHaveBeenCalled(); + expect(store.upsertProjectEntries).toHaveBeenCalled(); + }); + + it('does not read or write the persistent affinity index when the kill switch is disabled', async () => { + await setupClaudeRoot(); + process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX = '0'; + + const teamName = 'kill-switch-index-team'; + const projectPath = '/Users/test/kill-switch-index'; + const sessionId = 'kill-switch-session'; + await createTeamAwareSessionFile(projectPath, sessionId, teamName, 'text'); + await writeTeamConfig(teamName, { + name: 'Kill Switch Index Team', + projectPath, + leadSessionId: sessionId, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const store: TeamTranscriptAffinityIndexStore = { + loadProject: vi.fn(async () => { + throw new Error('disabled index should not load'); + }), + upsertProjectEntries: vi.fn(async () => undefined), + }; + const resolver = new TeamTranscriptProjectResolver(undefined, store); + + const context = await resolver.getContext(teamName, { forceRefresh: true }); + + expect(context?.sessionIds).toContain(sessionId); + expect(store.loadProject).not.toHaveBeenCalled(); + expect(store.upsertProjectEntries).not.toHaveBeenCalled(); + }); + // Regression for the launch hot path: non-matching transcripts must not be // re-streamed + re-parsed on every bootstrap poll. A negative verdict decided from // a FULL head window (>= 40 inspected lines) is durable while the file only grows, @@ -633,20 +1015,25 @@ describe('TeamTranscriptProjectResolver', () => { type AffinityCacheEntry = { mtimeMs: number; size: number; + ctimeMs?: number; belongsToTeam: boolean; + inspectedLineCount: number; + headFingerprint: string; headWindowFull: boolean; }; type HeadMetadataCacheEntry = { mtimeMs: number; size: number; + ctimeMs?: number; inspectedLineCount: number; + headFingerprint: string; lines: unknown[]; }; type ResolverProbe = { fileBelongsToTeam: ( filePath: string, teamName: string, - precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean } + precomputedStat?: { mtimeMs: number; size: number; ctimeMs?: number; isFile: () => boolean } ) => Promise; buildTeamAffinityFileCacheKey: (filePath: string, normalizedTeam: string) => string; teamAffinityFileCache: Map; @@ -728,6 +1115,89 @@ describe('TeamTranscriptProjectResolver', () => { expect(second!.size).toBeGreaterThan(sizeAfterFirst); // re-scanned + re-cached }); + it('does not reuse an in-memory positive growth shortcut after the cached head is rewritten', async () => { + await setupClaudeRoot(); + const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; + const team = 'rewrite-positive-team'; + const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/rewrite-positive')); + await fs.mkdir(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, 'rewrite-positive.jsonl'); + await fs.writeFile(jsonlPath, `${teamTextLine(team)}\n`, 'utf8'); + + expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true); + const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase()); + const first = resolver.teamAffinityFileCache.get(key); + expect(first?.belongsToTeam).toBe(true); + + const replacement = `${JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: 'x'.repeat(first!.size + 100) }, + })}\n`; + expect(Buffer.byteLength(replacement, 'utf8')).toBeGreaterThanOrEqual(first!.size); + await fs.writeFile(jsonlPath, replacement, 'utf8'); + + expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false); + expect(resolver.teamAffinityFileCache.get(key)?.belongsToTeam).toBe(false); + }); + + it('does not reuse an in-memory full-head negative shortcut after the cached head is rewritten', async () => { + await setupClaudeRoot(); + const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; + const team = 'rewrite-negative-team'; + const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/rewrite-negative')); + await fs.mkdir(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, 'rewrite-negative.jsonl'); + const originalLines = Array.from({ length: 45 }, (_, i) => noiseLine(i)); + await fs.writeFile(jsonlPath, `${originalLines.join('\n')}\n`, 'utf8'); + + expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false); + const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase()); + const first = resolver.teamAffinityFileCache.get(key); + expect(first?.headWindowFull).toBe(true); + + const rewrittenLines = [teamTextLine(team), ...originalLines.slice(1)]; + let replacement = `${rewrittenLines.join('\n')}\n`; + if (Buffer.byteLength(replacement, 'utf8') < first!.size) { + replacement += `${noiseLine(999)}\n`; + } + expect(Buffer.byteLength(replacement, 'utf8')).toBeGreaterThanOrEqual(first!.size); + await fs.writeFile(jsonlPath, replacement, 'utf8'); + + expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true); + expect(resolver.teamAffinityFileCache.get(key)?.belongsToTeam).toBe(true); + }); + + it('does not reuse in-memory exact caches after a same-size rewrite with restored mtime', async () => { + await setupClaudeRoot(); + const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; + const team = 'same-size-rewrite-team'; + const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/same-size-rewrite')); + await fs.mkdir(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, 'same-size-rewrite.jsonl'); + const stableTime = new Date('2026-05-30T10:00:00.000Z'); + await fs.writeFile(jsonlPath, `${teamTextLine(team)}\n`, 'utf8'); + await fs.utimes(jsonlPath, stableTime, stableTime); + + expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true); + const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase()); + const first = resolver.teamAffinityFileCache.get(key); + const firstHead = resolver.teamAffinityHeadMetadataCache.get(jsonlPath); + expect(first?.belongsToTeam).toBe(true); + expect(firstHead?.lines.length).toBeGreaterThan(0); + + await new Promise((resolve) => setTimeout(resolve, 5)); + await fs.writeFile(jsonlPath, sameByteLengthNoTeamTranscript(first!.size), 'utf8'); + await fs.utimes(jsonlPath, stableTime, stableTime); + const rewrittenStat = await fs.stat(jsonlPath); + + expect(rewrittenStat.size).toBe(first!.size); + expect(rewrittenStat.mtimeMs).toBe(first!.mtimeMs); + expect(rewrittenStat.ctimeMs).not.toBe(first!.ctimeMs); + + expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false); + expect(resolver.teamAffinityFileCache.get(key)?.belongsToTeam).toBe(false); + }); + // Regression: when the caller already statted the file (the mtime-window filter in // collectRootJsonlSessionIds), fileBelongsToTeam must reuse that stat rather than // issuing a second fs.stat of the same file. Proven without mocking fs: a precomputed