perf(team): cache transcript affinity verdicts
This commit is contained in:
parent
0a8fbc9801
commit
180bdb7575
6 changed files with 1332 additions and 46 deletions
|
|
@ -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<SessionProjectMatch, 'projectPath'> & {
|
||||
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<TeamConfig | null> {
|
||||
|
|
@ -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<string[]> {
|
||||
|
|
@ -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<string[]> {
|
||||
const discovered = new Set<string>();
|
||||
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<void> => {
|
||||
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<string[]> {
|
||||
|
|
@ -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<boolean> {
|
||||
return (await this.inspectFileTeamAffinity(filePath, teamName, precomputedStat)).belongsToTeam;
|
||||
}
|
||||
|
||||
private async inspectFileTeamAffinity(
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
precomputedStat?: TeamTranscriptFileStat
|
||||
): Promise<TeamAffinityInspectionResult> {
|
||||
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<TeamAffinityHeadMetadataCacheEntry | null> {
|
||||
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<PersistedTeamTranscriptAffinityIndex | null> {
|
||||
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<boolean> {
|
||||
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<TeamAffinityHeadMetadataCacheEntry | null> {
|
||||
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);
|
||||
|
|
|
|||
170
src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts
vendored
Normal file
170
src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts
vendored
Normal file
|
|
@ -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<string, Promise<void>>();
|
||||
|
||||
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<PersistedTeamTranscriptAffinityIndex | null> {
|
||||
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<PersistedTeamTranscriptAffinityIndex | null> {
|
||||
return this.readIndex(teamName, projectId);
|
||||
}
|
||||
|
||||
async upsertProjectEntries(input: {
|
||||
teamName: string;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
rootFileNames: ReadonlySet<string>;
|
||||
entries: readonly PersistedTeamTranscriptAffinityEntry[];
|
||||
}): Promise<void> {
|
||||
const chainKey = this.writeChainKey(input.teamName, input.projectId);
|
||||
const write = async (): Promise<void> => {
|
||||
const current = await this.readIndex(input.teamName, input.projectId);
|
||||
const entries = new Map<string, PersistedTeamTranscriptAffinityEntry>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
162
src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts
vendored
Normal file
162
src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts
vendored
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, PersistedTeamTranscriptAffinityEntry> = {};
|
||||
for (const [fileName, entry] of Object.entries(raw.entries as Record<string, unknown>)) {
|
||||
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<string, PersistedTeamTranscriptAffinityEntry> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
47
src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts
vendored
Normal file
47
src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts
vendored
Normal file
|
|
@ -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<string, PersistedTeamTranscriptAffinityEntry>;
|
||||
}
|
||||
|
||||
export interface TeamTranscriptAffinityIndexStore {
|
||||
loadProject(
|
||||
teamName: string,
|
||||
projectId: string
|
||||
): Promise<PersistedTeamTranscriptAffinityIndex | null>;
|
||||
|
||||
upsertProjectEntries(input: {
|
||||
teamName: string;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
rootFileNames: ReadonlySet<string>;
|
||||
entries: readonly PersistedTeamTranscriptAffinityEntry[];
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
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> = {}
|
||||
): 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
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<unknown>;
|
||||
};
|
||||
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<unknown>;
|
||||
};
|
||||
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<unknown>;
|
||||
};
|
||||
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<unknown>;
|
||||
};
|
||||
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<boolean>;
|
||||
buildTeamAffinityFileCacheKey: (filePath: string, normalizedTeam: string) => string;
|
||||
teamAffinityFileCache: Map<string, AffinityCacheEntry>;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue