import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; import * as fs from 'fs'; import * as path from 'path'; import { readBootstrapLaunchSnapshot } from './TeamBootstrapStateReader'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import { choosePreferredLaunchStateSummary, type LaunchStateSummary, normalizePersistedLaunchSummaryProjection, shouldSuppressLegacyLaunchArtifactHeuristic, TEAM_LAUNCH_SUMMARY_FILE, } from './TeamLaunchSummaryProjection'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import type { TeamConfig, TeamMember, TeamProviderId, TeamSummary, TeamSummaryMember, } from '@shared/types'; const logger = createLogger('Service:TeamConfigReader'); const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12; const LARGE_CONFIG_BYTES = 512 * 1024; const CONFIG_HEAD_BYTES = 64 * 1024; const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads const PER_TEAM_READ_TIMEOUT_MS = 5_000; const GET_CONFIG_SLOW_READ_WARN_MS = 500; const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000; const COARSE_FS_FULL_VERIFY_MS = 1_500; const LIST_TEAMS_CACHE_TTL_MS = 5_000; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; export interface TeamConfigFingerprint { size: string; mode: string; dev?: string; ino?: string; mtimeNs?: string; ctimeNs?: string; birthtimeNs?: string; mtimeMs: number; ctimeMs: number; birthtimeMs: number; } interface InternalTeamConfigFingerprint extends TeamConfigFingerprint { isFile: boolean; highResolution: boolean; numericSize: number; } interface CachedTeamConfig { value: TeamConfig; fingerprint: InternalTeamConfigFingerprint | null; verifiedAt: number; fullVerifiedAt: number; } type TeamConfigReadMode = 'verified' | 'snapshot'; interface ConfigReadTiming { teamName: string; mode: TeamConfigReadMode; configPath: string; size: number | null; statMs: number | null; readMs: number | null; parseMs: number | null; totalMs: number; likelyCause: string; fingerprintHighResolution: boolean | null; cacheGeneration: number | null; currentGeneration: number; caller: string | null; } interface CachedTeamList { value: TeamSummary[]; expiresAt: number; } interface InFlightTeamList { promise: Promise; generationAtStart: number; } function normalizeProjectPathCandidate(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function resolveProjectPathFromConfig( config: Pick ): string | undefined { const direct = normalizeProjectPathCandidate(config.projectPath); if (direct) { return direct; } const leadMemberCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; const leadResolved = normalizeProjectPathCandidate(leadMemberCwd); if (leadResolved) { return leadResolved; } const distinctMemberCwds = Array.from( new Set( (config.members ?? []) .map((member) => normalizeProjectPathCandidate(member.cwd)) .filter((cwd): cwd is string => Boolean(cwd)) ) ); if (distinctMemberCwds.length === 1) { return distinctMemberCwds[0]; } if (Array.isArray(config.projectPathHistory)) { for (let i = config.projectPathHistory.length - 1; i >= 0; i--) { const historyValue = normalizeProjectPathCandidate(config.projectPathHistory[i]); if (historyValue) { return historyValue; } } } return undefined; } async function readLaunchStateSummary(teamDir: string): Promise { const bootstrapSnapshot = await readBootstrapLaunchSnapshot(path.basename(teamDir)); const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE); const launchSummaryPath = path.join(teamDir, TEAM_LAUNCH_SUMMARY_FILE); const [launchSnapshot, launchSummaryProjection] = await Promise.all([ (async () => { try { const stat = await fs.promises.stat(launchStatePath); if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { return null; } const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS); return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw)); } catch { return null; } })(), (async () => { try { const stat = await fs.promises.stat(launchSummaryPath); if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { return null; } const raw = await readFileUtf8WithTimeout(launchSummaryPath, PER_TEAM_READ_TIMEOUT_MS); return normalizePersistedLaunchSummaryProjection(path.basename(teamDir), JSON.parse(raw)); } catch { return null; } })(), ]); return choosePreferredLaunchStateSummary({ bootstrapSnapshot, launchSnapshot, launchSummaryProjection, }); } async function mapLimit( items: readonly T[], limit: number, fn: (item: T) => Promise ): Promise { const results = new Array(items.length); let index = 0; const workerCount = Math.max(1, Math.min(limit, items.length)); const workers = new Array(workerCount).fill(0).map(async () => { while (true) { const i = index++; if (i >= items.length) return; results[i] = await fn(items[i]); } }); await Promise.all(workers); return results; } function withReadTimeout(promise: Promise, ms: number): Promise { let timer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('Team config read timeout')), ms); }); return Promise.race([promise, timeout]).finally(() => { if (timer) clearTimeout(timer); }); } function cloneConfig(config: TeamConfig): TeamConfig { return structuredClone(config); } function cloneTeamSummaries(teams: readonly TeamSummary[]): TeamSummary[] { return structuredClone([...teams]); } function classifyConfigReadTiming(timing: { statMs: number | null; readMs: number | null; parseMs: number | null; }): string { const statMs = timing.statMs ?? 0; const readMs = timing.readMs ?? 0; const parseMs = timing.parseMs ?? 0; if (readMs >= 1_000 && readMs >= statMs * 2 && readMs >= parseMs * 2) { return 'io_read_slow'; } if (statMs >= 1_000 && statMs >= readMs * 2 && statMs >= parseMs * 2) { return 'io_stat_slow'; } if (parseMs >= 500 && parseMs >= readMs && parseMs >= statMs) { return 'json_parse_slow'; } if (statMs + readMs >= 1_000) { return 'filesystem_pressure'; } return 'mixed_or_unknown'; } function captureConfigReadCaller(): string | null { const stack = new Error().stack?.split('\n').slice(2) ?? []; const frame = stack.find((line) => { const normalized = line.trim(); return ( normalized.length > 0 && !normalized.includes('TeamConfigReader.') && !normalized.includes('TeamConfigReader.ts') && !normalized.includes('captureConfigReadCaller') && !normalized.includes('node:internal') ); }); return frame?.trim().slice(0, 240) ?? null; } export class TeamConfigReader { private static readonly configCacheByPath = new Map(); private static readonly configReadInFlightByPath = new Map>(); private static readonly configStatInFlightByPath = new Map< string, Promise >(); private static readonly configGenerationByPath = new Map(); private static readonly listTeamsCacheByBasePath = new Map(); private static readonly listTeamsInFlightByBasePath = new Map(); private static listTeamsGeneration = 0; static clearCacheForTests(): void { TeamConfigReader.configCacheByPath.clear(); TeamConfigReader.configReadInFlightByPath.clear(); TeamConfigReader.configStatInFlightByPath.clear(); TeamConfigReader.configGenerationByPath.clear(); TeamConfigReader.listTeamsCacheByBasePath.clear(); TeamConfigReader.listTeamsInFlightByBasePath.clear(); TeamConfigReader.listTeamsGeneration = 0; } static invalidateTeam(teamName: string): void { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); TeamConfigReader.invalidatePath(configPath); } static invalidatePath(configPath: string): void { TeamConfigReader.configCacheByPath.delete(configPath); TeamConfigReader.configReadInFlightByPath.delete(configPath); TeamConfigReader.configStatInFlightByPath.delete(configPath); TeamConfigReader.bumpConfigGeneration(configPath); TeamConfigReader.invalidateListTeamsCache(); } static invalidateListTeamsCache(): void { TeamConfigReader.listTeamsCacheByBasePath.clear(); // Do not clear in-flight scans here. Config writes can arrive while a global // team scan is already running; dropping the in-flight entry starts a second // full scan over all teams and amplifies launch-time filesystem pressure. // The generation check below prevents the stale in-flight result from being // cached after invalidation. TeamConfigReader.listTeamsGeneration += 1; } private static invalidatePathForGeneration( configPath: string, expectedGeneration?: number ): void { if ( typeof expectedGeneration === 'number' && TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration ) { return; } TeamConfigReader.invalidatePath(configPath); } static async primeConfig( teamName: string, config: TeamConfig, fingerprint?: TeamConfigFingerprint | null ): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const generation = TeamConfigReader.bumpConfigGeneration(configPath); TeamConfigReader.configReadInFlightByPath.delete(configPath); TeamConfigReader.configStatInFlightByPath.delete(configPath); let internalFingerprint: InternalTeamConfigFingerprint | null = null; if (fingerprint) { internalFingerprint = { ...fingerprint, isFile: true, highResolution: Boolean(fingerprint.mtimeNs || fingerprint.ctimeNs), numericSize: Number(fingerprint.size), }; } else { internalFingerprint = await TeamConfigReader.readConfigFingerprint(configPath).catch( () => null ); } TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation); TeamConfigReader.invalidateListTeamsCache(); } constructor( private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() ) {} async listTeams(): Promise { const teamsBasePath = getTeamsBasePath(); const cached = TeamConfigReader.listTeamsCacheByBasePath.get(teamsBasePath); if (cached && cached.expiresAt > Date.now()) { return cloneTeamSummaries(cached.value); } const existingRequest = TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath); if (existingRequest?.generationAtStart === TeamConfigReader.listTeamsGeneration) { return cloneTeamSummaries(await existingRequest.promise); } const request = this.listTeamsUncached(teamsBasePath); const generationAtStart = TeamConfigReader.listTeamsGeneration; TeamConfigReader.listTeamsInFlightByBasePath.set(teamsBasePath, { promise: request, generationAtStart, }); try { const teams = await request; if (TeamConfigReader.listTeamsGeneration === generationAtStart) { TeamConfigReader.listTeamsCacheByBasePath.set(teamsBasePath, { value: cloneTeamSummaries(teams), expiresAt: Date.now() + LIST_TEAMS_CACHE_TTL_MS, }); } return cloneTeamSummaries(teams); } finally { if (TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath)?.promise === request) { TeamConfigReader.listTeamsInFlightByBasePath.delete(teamsBasePath); } } } private async listTeamsUncached(teamsBasePath: string): Promise { const worker = getTeamFsWorkerClient(); if (worker.isAvailable()) { const startedAt = Date.now(); try { const { teams, diag } = await worker.listTeams({ largeConfigBytes: LARGE_CONFIG_BYTES, configHeadBytes: CONFIG_HEAD_BYTES, maxConfigBytes: MAX_CONFIG_READ_BYTES, maxMembersMetaBytes: 256 * 1024, maxSessionHistoryInSummary: MAX_SESSION_HISTORY_IN_SUMMARY, maxProjectPathHistoryInSummary: MAX_PROJECT_PATH_HISTORY_IN_SUMMARY, concurrency: TEAM_LIST_CONCURRENCY, maxConfigReadMs: PER_TEAM_READ_TIMEOUT_MS, }); const ms = Date.now() - startedAt; const skipReasons = diag && typeof diag === 'object' ? (diag as Record).skipReasons : null; if (skipReasons && typeof skipReasons === 'object') { const bad = Number((skipReasons as Record).config_parse_failed ?? 0) + Number((skipReasons as Record).config_read_timeout ?? 0); if (bad > 0) { logger.warn(`[listTeams] worker skipped broken team configs count=${bad}`); } } if (ms >= 1500) { logger.warn(`[listTeams] worker slow ms=${ms} diag=${JSON.stringify(diag)}`); } return teams; } catch (error) { logger.warn( `[listTeams] worker failed: ${error instanceof Error ? error.message : String(error)}` ); // Fall through to in-process implementation. } } const teamsDir = teamsBasePath; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(teamsDir, { withFileTypes: true }); } catch { return []; } const teamDirs = entries.filter((e) => e.isDirectory()); const perTeam: (TeamSummary | null)[] = await mapLimit( teamDirs, TEAM_LIST_CONCURRENCY, async (entry): Promise => { const teamName = entry.name; try { return await withReadTimeout( this.readTeamSummary(teamsDir, teamName), PER_TEAM_READ_TIMEOUT_MS ); } catch (err) { const reason = err instanceof Error ? err.message : 'unknown'; logger.warn(`Skipping team dir (${reason}): ${teamName}`); return null; } } ); return perTeam.filter((t): t is TeamSummary => t !== null); } private async readTeamSummary(teamsDir: string, teamName: string): Promise { const configPath = path.join(teamsDir, teamName, 'config.json'); const teamDir = path.join(teamsDir, teamName); try { let config: TeamConfig | null = null; let leadProviderId: TeamProviderId | undefined; let displayName: string | null = null; let description = ''; let color: string | undefined; let projectPath: string | undefined; let leadSessionId: string | undefined; let deletedAt: string | undefined; let projectPathHistory: TeamConfig['projectPathHistory'] | undefined; let sessionHistory: TeamConfig['sessionHistory'] | undefined; let stat: fs.Stats | null = null; try { stat = await fs.promises.stat(configPath); } catch { stat = null; } // Skip non-regular files (pipes, sockets, etc.) — readFile could hang on them if (!stat?.isFile()) { // Fallback: check for draft team (team.meta.json without config.json) return this.readDraftTeamSummary(teamsDir, teamName); } // Safety: refuse to touch extremely large configs. Even "head" parsing can be misleading, // and full reads/parses can stall the main process. if (stat.size > MAX_CONFIG_READ_BYTES) { logger.warn( `Skipping team dir with oversized config.json (${stat.size} bytes): ${teamName}` ); return null; } if (stat.size > LARGE_CONFIG_BYTES) { // Defensive: avoid any reads from very large configs during listing. // If the team is real, it can still be opened later via getConfig(). displayName = teamName; } else { const raw = await readFileUtf8WithTimeout(configPath, PER_TEAM_READ_TIMEOUT_MS); config = JSON.parse(raw) as TeamConfig; displayName = typeof config.name === 'string' ? config.name : null; description = typeof config.description === 'string' ? config.description : ''; color = typeof config.color === 'string' && config.color.trim().length > 0 ? config.color : undefined; projectPath = resolveProjectPathFromConfig(config); leadSessionId = typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 ? config.leadSessionId : undefined; projectPathHistory = Array.isArray(config.projectPathHistory) ? config.projectPathHistory.slice(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY) : undefined; sessionHistory = Array.isArray(config.sessionHistory) ? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY) : undefined; deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; } if (typeof displayName !== 'string' || displayName.trim() === '') { logger.debug(`Skipping team dir with invalid config name: ${teamName}`); return null; } // Case-insensitive dedup: key is lowercase name, value keeps the original casing const memberMap = new Map(); const removedKeys = new Set(); const expectedTeammateNames = new Set(); const confirmedArtifactNames = new Set(); let metaMembers: TeamMember[] = []; let leadName: string | undefined; let leadColor: string | undefined; const captureLeadMember = (m: TeamMember, overwrite = false): void => { if (m.removedAt) return; if (!isLeadMember(m)) return; const name = m.name?.trim(); if (name && (overwrite || !leadName)) { leadName = name; } const colorValue = m.color?.trim(); if (colorValue && (overwrite || !leadColor)) { leadColor = colorValue; } }; const mergeMember = (m: TeamMember): void => { const name = m.name?.trim(); if (!name) return; // Summary/memberCount should represent teammates (exclude the lead process). if (name === 'user' || isLeadMember(m)) return; const key = name.toLowerCase(); // If meta marks this name removed, do not surface it in summaries if (removedKeys.has(key)) return; const existing = memberMap.get(key); memberMap.set(key, { name: existing?.name ?? name, role: m.role?.trim() || existing?.role, color: m.color?.trim() || existing?.color, mcpPolicy: normalizeTeamMemberMcpPolicy(m.mcpPolicy) ?? existing?.mcpPolicy, }); }; // Also read members.meta.json — UI-created teams store members there, // and CLI-created teams may have additional members added via the UI. try { metaMembers = await this.membersMetaStore.getMembers(teamName); for (const member of metaMembers) { const name = member.name?.trim(); if (!name) continue; captureLeadMember(member); // Summary/memberCount should represent teammates (exclude the lead process). if (name === 'user' || isLeadMember(member)) continue; const key = name.toLowerCase(); if (member.removedAt) { removedKeys.add(key); continue; } expectedTeammateNames.add(name); mergeMember(member); } } catch { // best-effort — don't fail listing if meta file is broken } try { leadProviderId = (await this.teamMetaStore.getMeta(teamName))?.providerId; } catch { leadProviderId = undefined; } // Merge config members AFTER meta so removedAt can suppress stale config entries. if (config && Array.isArray(config.members)) { for (const member of config.members) { if (member && typeof member.name === 'string') { const name = member.name.trim(); captureLeadMember(member, true); if (name && name !== 'user' && !isLeadMember(member)) { confirmedArtifactNames.add(name); } mergeMember(member); } } } try { const inboxDir = path.join(teamDir, 'inboxes'); const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true }); for (const entry of inboxEntries) { if (!entry.isFile() || !entry.name.endsWith('.json')) continue; const inboxName = entry.name.slice(0, -'.json'.length).trim(); if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue; confirmedArtifactNames.add(inboxName); } } catch { // best-effort } // Defense: drop CLI auto-suffixed duplicates (alice-2) only when the // base name is still active. Removed base members must not hide active // suffixed teammates in summary/list paths. const activeNamesForAutoSuffix = Array.from(memberMap.values()) .map((member) => member.name) .filter((name) => !removedKeys.has(name.trim().toLowerCase())); const keepName = createCliAutoSuffixNameGuard(activeNamesForAutoSuffix); // Defense: drop CLI provisioner artifacts (alice-provisioner) when base name exists. const keepProvisioner = createCliProvisionerNameGuard(activeNamesForAutoSuffix); for (const [key, member] of Array.from(memberMap.entries())) { if (!keepName(member.name) || !keepProvisioner(member.name)) { memberMap.delete(key); } } const members = Array.from(memberMap.values()); const suppressLegacyLaunchArtifactHeuristic = shouldSuppressLegacyLaunchArtifactHeuristic({ leadProviderId, members: metaMembers, }); const launchStateSummary = (await readLaunchStateSummary(teamDir)) ?? (() => { if (suppressLegacyLaunchArtifactHeuristic) { return null; } if ( !leadSessionId || expectedTeammateNames.size === 0 || confirmedArtifactNames.size === 0 ) { return null; } const missingMembers = Array.from(expectedTeammateNames).filter( (name) => !confirmedArtifactNames.has(name) ); if (missingMembers.length === 0) { return null; } return { partialLaunchFailure: true as const, expectedMemberCount: expectedTeammateNames.size, confirmedMemberCount: confirmedArtifactNames.size, missingMembers, }; })(); const summary: TeamSummary = { teamName, displayName, description, memberCount: memberMap.size, taskCount: 0, lastActivity: null, ...(members.length > 0 ? { members } : {}), ...(leadName ? { leadName } : {}), ...(leadColor ? { leadColor } : {}), ...(color ? { color } : {}), ...(projectPath ? { projectPath } : {}), ...(leadSessionId ? { leadSessionId } : {}), ...(projectPathHistory ? { projectPathHistory } : {}), ...(sessionHistory ? { sessionHistory } : {}), ...(deletedAt ? { deletedAt } : {}), ...(launchStateSummary ?? {}), }; return summary; } catch { logger.debug(`Skipping team dir without valid config: ${teamName}`); return null; } } /** * Checks for a draft team (team.meta.json exists without config.json). * This happens when provisioning failed before CLI's TeamCreate could run. */ private async readDraftTeamSummary( teamsDir: string, teamName: string ): Promise { const metaPath = path.join(teamsDir, teamName, 'team.meta.json'); try { const metaStat = await fs.promises.stat(metaPath); if (!metaStat.isFile() || metaStat.size > 256 * 1024) { return null; } const metaRaw = await readFileUtf8WithTimeout(metaPath, PER_TEAM_READ_TIMEOUT_MS); const meta = JSON.parse(metaRaw) as Record; if (meta?.version !== 1 || typeof meta?.cwd !== 'string') { return null; } const displayName = typeof meta.displayName === 'string' && meta.displayName.trim() ? meta.displayName.trim() : teamName; let memberCount = 0; let leadName: string | undefined; let leadColor: string | undefined; try { const members = await this.membersMetaStore.getMembers(teamName); memberCount = members.filter((member) => { const name = member.name?.trim() ?? ''; if (!member.removedAt && isLeadMember(member)) { if (name) { leadName = name; } const color = member.color?.trim(); if (color) { leadColor = color; } } if (!name || name === 'user' || isLeadMember(member)) { return false; } return !member.removedAt; }).length; } catch { // best-effort } return { teamName, displayName, description: typeof meta.description === 'string' ? meta.description : '', memberCount, taskCount: 0, lastActivity: typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null, color: typeof meta.color === 'string' ? meta.color : undefined, ...(leadName ? { leadName } : {}), ...(leadColor ? { leadColor } : {}), projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined, pendingCreate: true, }; } catch { return null; } } async getConfig(teamName: string): Promise { return this.getConfigVerified(teamName); } async getConfigVerified(teamName: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath); if (existingRead) { return this.resolveConfigRead(teamName, configPath, existingRead); } const generation = TeamConfigReader.getConfigGeneration(configPath); const readPromise = this.readConfigFromDisk( teamName, configPath, null, true, generation, 'verified' ); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); try { return await this.resolveConfigRead(teamName, configPath, readPromise); } finally { if (TeamConfigReader.configReadInFlightByPath.get(configPath) === readPromise) { TeamConfigReader.configReadInFlightByPath.delete(configPath); } } } async getConfigSnapshot(teamName: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); for (let attempt = 0; attempt < 3; attempt++) { const generationAtStart = TeamConfigReader.getConfigGeneration(configPath); let fingerprint: InternalTeamConfigFingerprint | null; try { fingerprint = await TeamConfigReader.getConfigFingerprint(configPath); } catch (error) { if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) { continue; } const cached = TeamConfigReader.configCacheByPath.get(configPath); if ( cached && Date.now() - cached.verifiedAt <= CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS ) { logger.warn( `[getConfigSnapshot] config_snapshot_stat_failed_using_recent_cache team=${teamName} error=${ error instanceof Error ? error.message : String(error) }` ); return cloneConfig(cached.value); } return null; } if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) { continue; } if (!fingerprint?.isFile || fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { TeamConfigReader.invalidatePathForGeneration(configPath, generationAtStart); if (fingerprint && fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { logger.warn( `Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}` ); } return null; } const cached = TeamConfigReader.configCacheByPath.get(configPath); if ( cached?.fingerprint && TeamConfigReader.fingerprintsEqual(cached.fingerprint, fingerprint) ) { const now = Date.now(); const mustRevalidateCoarseFingerprint = !fingerprint.highResolution && now - cached.fullVerifiedAt >= COARSE_FS_FULL_VERIFY_MS; if (!mustRevalidateCoarseFingerprint) { cached.verifiedAt = now; return cloneConfig(cached.value); } } const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath); if (existingRead) { return this.resolveConfigRead(teamName, configPath, existingRead); } const generation = TeamConfigReader.getConfigGeneration(configPath); const readPromise = this.readConfigFromDisk( teamName, configPath, fingerprint, true, generation, 'snapshot' ); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); try { return await this.resolveConfigRead(teamName, configPath, readPromise); } finally { if (TeamConfigReader.configReadInFlightByPath.get(configPath) === readPromise) { TeamConfigReader.configReadInFlightByPath.delete(configPath); } } } return null; } private async resolveConfigRead( teamName: string, configPath: string, readPromise: Promise ): Promise { try { const config = await readPromise; return config ? cloneConfig(config) : null; } catch { return null; } } private static async getConfigFingerprint( configPath: string ): Promise { const existing = TeamConfigReader.configStatInFlightByPath.get(configPath); if (existing) return existing; const statPromise = TeamConfigReader.readConfigFingerprint(configPath).finally(() => { if (TeamConfigReader.configStatInFlightByPath.get(configPath) === statPromise) { TeamConfigReader.configStatInFlightByPath.delete(configPath); } }); TeamConfigReader.configStatInFlightByPath.set(configPath, statPromise); return statPromise; } private static async readConfigFingerprint( configPath: string ): Promise { let stat: fs.BigIntStats; try { stat = await withReadTimeout( fs.promises.stat(configPath, { bigint: true }), PER_TEAM_READ_TIMEOUT_MS ); } catch (error) { const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null; if (code === 'ENOENT') { return null; } throw error; } const highResStat = stat as fs.BigIntStats & { mtimeNs?: bigint; ctimeNs?: bigint; birthtimeNs?: bigint; }; const mtimeNs = highResStat.mtimeNs; const ctimeNs = highResStat.ctimeNs; const birthtimeNs = highResStat.birthtimeNs; return { size: stat.size.toString(), mode: stat.mode.toString(), dev: stat.dev.toString(), ino: stat.ino.toString(), mtimeNs: typeof mtimeNs === 'bigint' ? mtimeNs.toString() : undefined, ctimeNs: typeof ctimeNs === 'bigint' ? ctimeNs.toString() : undefined, birthtimeNs: typeof birthtimeNs === 'bigint' ? birthtimeNs.toString() : undefined, mtimeMs: Number(stat.mtimeMs), ctimeMs: Number(stat.ctimeMs), birthtimeMs: Number(stat.birthtimeMs), isFile: stat.isFile(), highResolution: typeof mtimeNs === 'bigint' || typeof ctimeNs === 'bigint', numericSize: Number(stat.size), }; } private static fingerprintsEqual( a: InternalTeamConfigFingerprint, b: InternalTeamConfigFingerprint ): boolean { return ( a.size === b.size && a.mode === b.mode && a.dev === b.dev && a.ino === b.ino && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.birthtimeNs === b.birthtimeNs && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs && a.birthtimeMs === b.birthtimeMs ); } private static storeConfigCache( configPath: string, config: TeamConfig, fingerprint: InternalTeamConfigFingerprint | null, fullVerified: boolean, expectedGeneration?: number ): void { if ( typeof expectedGeneration === 'number' && TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration ) { return; } const now = Date.now(); const previous = TeamConfigReader.configCacheByPath.get(configPath); TeamConfigReader.configCacheByPath.set(configPath, { value: cloneConfig(config), fingerprint, verifiedAt: now, fullVerifiedAt: fullVerified ? now : (previous?.fullVerifiedAt ?? now), }); } private static getConfigGeneration(configPath: string): number { return TeamConfigReader.configGenerationByPath.get(configPath) ?? 0; } private static bumpConfigGeneration(configPath: string): number { const next = TeamConfigReader.getConfigGeneration(configPath) + 1; TeamConfigReader.configGenerationByPath.set(configPath, next); return next; } private async readConfigFromDisk( teamName: string, configPath: string, knownFingerprint: InternalTeamConfigFingerprint | null = null, updateCache = false, cacheGeneration?: number, mode: TeamConfigReadMode = 'verified' ): Promise { const startedAt = performance.now(); const caller = captureConfigReadCaller(); let size: number | null = null; let statMs: number | null = null; let readMs: number | null = null; let parseMs: number | null = null; let fingerprintHighResolution: boolean | null = knownFingerprint?.highResolution ?? null; const buildTiming = (): ConfigReadTiming => ({ teamName, mode, configPath, size, statMs, readMs, parseMs, totalMs: Math.round(performance.now() - startedAt), likelyCause: classifyConfigReadTiming({ statMs, readMs, parseMs }), fingerprintHighResolution, cacheGeneration: cacheGeneration ?? null, currentGeneration: TeamConfigReader.getConfigGeneration(configPath), caller, }); try { const statStartedAt = performance.now(); const fingerprint = knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath)); statMs = Math.round(performance.now() - statStartedAt); size = fingerprint?.numericSize ?? null; fingerprintHighResolution = fingerprint?.highResolution ?? null; // Safety: refuse special files and huge/binary configs if (!fingerprint?.isFile) { TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); return null; } if (fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); logger.warn( `Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}` ); return null; } const readStartedAt = performance.now(); const raw = await readFileUtf8WithTimeout(configPath, PER_TEAM_READ_TIMEOUT_MS); readMs = Math.round(performance.now() - readStartedAt); const parseStartedAt = performance.now(); const config = JSON.parse(raw) as TeamConfig; parseMs = Math.round(performance.now() - parseStartedAt); if (typeof config.name !== 'string' || config.name.trim() === '') { TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); return null; } const resolvedProjectPath = resolveProjectPathFromConfig(config); const resolvedConfig = resolvedProjectPath ? { ...config, projectPath: resolvedProjectPath } : config; const totalMs = performance.now() - startedAt; if (totalMs >= GET_CONFIG_SLOW_READ_WARN_MS) { logger.warn(`[getConfig] slow read diag=${JSON.stringify(buildTiming())}`); } if (updateCache) { TeamConfigReader.storeConfigCache( configPath, resolvedConfig, fingerprint, true, cacheGeneration ); } return resolvedConfig; } catch (error) { TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); if (error instanceof FileReadTimeoutError) { logger.warn(`[getConfig] ${error.message} diag=${JSON.stringify(buildTiming())}`); } else if (error instanceof Error && error.message === 'Team config read timeout') { logger.warn( `[getConfig] Timed out after ${PER_TEAM_READ_TIMEOUT_MS}ms reading ${configPath} diag=${JSON.stringify(buildTiming())}` ); } throw error; } } async updateConfig( teamName: string, updates: { name?: string; description?: string; color?: string; language?: string } ): Promise { const config = await this.getConfig(teamName); if (!config) { return null; } if (updates.name !== undefined && updates.name.trim() !== '') { config.name = updates.name.trim(); } if (updates.description !== undefined) { config.description = updates.description.trim() || undefined; } if (updates.color !== undefined) { config.color = updates.color.trim() || undefined; } if (updates.language !== undefined) { config.language = updates.language.trim() || undefined; } const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); await TeamConfigReader.primeConfig(teamName, config); return config; } }