/** * SessionMetadataIndex - persisted read-through cache for session listing metadata. * * The index is never a source of truth. Callers may use an entry only when the * current file signature (mtimeMs + size) matches the indexed signature. */ import * as crypto from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { createLogger } from '@shared/utils/logger'; import type { SessionFileMetadata } from '@main/utils/jsonl'; const logger = createLogger('Discovery:SessionMetadataIndex'); const SESSION_METADATA_INDEX_SCHEMA_VERSION = 1; const DEFAULT_PERSIST_DELAY_MS = 250; export interface SessionMetadataIndexOptions { rootDir: string; persistDelayMs?: number; } export interface SessionFileSignature { sessionId: string; filePath: string; mtimeMs: number; size: number; birthtimeMs?: number; } interface SessionMetadataIndexEntry extends SessionFileSignature { hasContent?: boolean; metadata?: SessionFileMetadata; updatedAt: number; } interface SessionMetadataIndexFile { schemaVersion: number; projectStorageDir: string; updatedAt: number; sessions: Record; } interface LoadedProjectIndex { file: SessionMetadataIndexFile; dirty: boolean; persistTimer: ReturnType | null; persistPromise: Promise | null; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function hasOwnProperty(value: Record, property: string): boolean { return Object.prototype.hasOwnProperty.call(value, property); } function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function isNonNegativeFiniteNumber(value: unknown): value is number { return isFiniteNumber(value) && value >= 0; } function isNonNegativeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } function sanitizePathSegment(value: string): string { const replaced = value.replace(/[^a-zA-Z0-9._-]/g, '-'); let start = 0; let end = replaced.length; while (start < end && replaced[start] === '-') { start += 1; } while (end > start && replaced[end - 1] === '-') { end -= 1; } const sanitized = replaced.slice(start, end); return sanitized.length > 0 ? sanitized.slice(0, 80) : 'project'; } function hashProjectStorageDir(projectStorageDir: string): string { return crypto.createHash('sha256').update(projectStorageDir).digest('hex').slice(0, 16); } function createEmptyIndex(projectStorageDir: string): SessionMetadataIndexFile { return { schemaVersion: SESSION_METADATA_INDEX_SCHEMA_VERSION, projectStorageDir, updatedAt: Date.now(), sessions: {}, }; } function normalizeFirstUserMessage( value: unknown ): SessionFileMetadata['firstUserMessage'] | undefined { if (value === null) { return null; } if (isRecord(value) && typeof value.text === 'string' && typeof value.timestamp === 'string') { return { text: value.text, timestamp: value.timestamp, }; } return undefined; } function normalizePhaseBreakdown( value: unknown ): SessionFileMetadata['phaseBreakdown'] | undefined { if (!Array.isArray(value)) { return undefined; } const phases: NonNullable = []; for (const phase of value) { if ( !isRecord(phase) || !isNonNegativeInteger(phase.phaseNumber) || !isNonNegativeInteger(phase.contribution) || !isNonNegativeInteger(phase.peakTokens) || (hasOwnProperty(phase, 'postCompaction') && !isNonNegativeInteger(phase.postCompaction)) ) { return undefined; } phases.push({ phaseNumber: phase.phaseNumber, contribution: phase.contribution, peakTokens: phase.peakTokens, ...(isNonNegativeInteger(phase.postCompaction) ? { postCompaction: phase.postCompaction } : {}), }); } return phases; } function normalizeMetadata(value: unknown): SessionFileMetadata | undefined { if (!isRecord(value)) { return undefined; } const firstUserMessage = normalizeFirstUserMessage(value.firstUserMessage); if ( firstUserMessage === undefined || !isNonNegativeInteger(value.messageCount) || typeof value.isOngoing !== 'boolean' || !(value.gitBranch === null || typeof value.gitBranch === 'string') ) { return undefined; } const metadata: SessionFileMetadata = { firstUserMessage, messageCount: value.messageCount, isOngoing: value.isOngoing, gitBranch: value.gitBranch, }; if (hasOwnProperty(value, 'model')) { if (!(value.model === null || typeof value.model === 'string')) { return undefined; } metadata.model = value.model; } if (hasOwnProperty(value, 'contextConsumption')) { if (!isNonNegativeInteger(value.contextConsumption)) { return undefined; } metadata.contextConsumption = value.contextConsumption; } if (hasOwnProperty(value, 'compactionCount')) { if (!isNonNegativeInteger(value.compactionCount)) { return undefined; } metadata.compactionCount = value.compactionCount; } if (hasOwnProperty(value, 'phaseBreakdown')) { const phaseBreakdown = normalizePhaseBreakdown(value.phaseBreakdown); if (!phaseBreakdown) { return undefined; } metadata.phaseBreakdown = phaseBreakdown; } return metadata; } function normalizeEntry(value: unknown): SessionMetadataIndexEntry | null { if (!isRecord(value)) { return null; } if ( typeof value.sessionId !== 'string' || typeof value.filePath !== 'string' || !isNonNegativeFiniteNumber(value.mtimeMs) || !isNonNegativeInteger(value.size) || !isNonNegativeFiniteNumber(value.updatedAt) ) { return null; } const entry: SessionMetadataIndexEntry = { sessionId: value.sessionId, filePath: value.filePath, mtimeMs: value.mtimeMs, size: value.size, updatedAt: value.updatedAt, }; if (hasOwnProperty(value, 'birthtimeMs')) { if (!isNonNegativeFiniteNumber(value.birthtimeMs)) { return null; } entry.birthtimeMs = value.birthtimeMs; } if (hasOwnProperty(value, 'hasContent')) { if (typeof value.hasContent !== 'boolean') { return null; } entry.hasContent = value.hasContent; } const metadata = normalizeMetadata(value.metadata); if (metadata) { entry.metadata = metadata; } else if (hasOwnProperty(value, 'metadata')) { return null; } return entry; } function normalizeIndexFile( value: unknown, projectStorageDir: string ): SessionMetadataIndexFile | null { if (!isRecord(value)) { return null; } if ( value.schemaVersion !== SESSION_METADATA_INDEX_SCHEMA_VERSION || value.projectStorageDir !== projectStorageDir || !isRecord(value.sessions) ) { return null; } const sessions: Record = {}; for (const [key, rawEntry] of Object.entries(value.sessions)) { const entry = normalizeEntry(rawEntry); if (key === entry?.filePath) { sessions[key] = entry; } } return { schemaVersion: SESSION_METADATA_INDEX_SCHEMA_VERSION, projectStorageDir, updatedAt: isNonNegativeFiniteNumber(value.updatedAt) ? value.updatedAt : Date.now(), sessions, }; } function isFreshEntry( entry: SessionMetadataIndexEntry | undefined, signature: Pick ): entry is SessionMetadataIndexEntry { return ( Boolean(entry) && entry!.sessionId === signature.sessionId && entry!.filePath === signature.filePath && entry!.mtimeMs === signature.mtimeMs && entry!.size === signature.size ); } export class SessionMetadataIndex { private readonly rootDir: string; private readonly persistDelayMs: number; private readonly indexes = new Map(); private readonly loads = new Map>(); constructor(options: SessionMetadataIndexOptions) { this.rootDir = options.rootDir; this.persistDelayMs = options.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS; } static getIndexPath(rootDir: string, projectStorageDir: string): string { const basename = sanitizePathSegment(path.basename(projectStorageDir)); const hash = hashProjectStorageDir(projectStorageDir); return path.join(rootDir, `${basename}-${hash}.json`); } async getContentPresence(signature: SessionFileSignature): Promise { const index = await this.loadProjectIndex(path.dirname(signature.filePath)); const entry = index.file.sessions[signature.filePath]; if (!isFreshEntry(entry, signature)) { return undefined; } return typeof entry.hasContent === 'boolean' ? entry.hasContent : undefined; } async setContentPresence(signature: SessionFileSignature, hasContent: boolean): Promise { const projectStorageDir = path.dirname(signature.filePath); const index = await this.loadProjectIndex(projectStorageDir); const entry = this.getOrCreateEntry(index.file, signature); entry.hasContent = hasContent; entry.updatedAt = Date.now(); this.markDirty(projectStorageDir, index); } async getMetadata(signature: SessionFileSignature): Promise { const index = await this.loadProjectIndex(path.dirname(signature.filePath)); const entry = index.file.sessions[signature.filePath]; if (!isFreshEntry(entry, signature)) { return undefined; } return entry.metadata; } async setMetadata(signature: SessionFileSignature, metadata: SessionFileMetadata): Promise { const projectStorageDir = path.dirname(signature.filePath); const index = await this.loadProjectIndex(projectStorageDir); const entry = this.getOrCreateEntry(index.file, signature); entry.metadata = metadata; entry.updatedAt = Date.now(); this.markDirty(projectStorageDir, index); } async pruneMissing(projectStorageDir: string, existingFilePaths: Set): Promise { const index = await this.loadProjectIndex(projectStorageDir); let changed = false; for (const filePath of Object.keys(index.file.sessions)) { if (!existingFilePaths.has(filePath)) { delete index.file.sessions[filePath]; changed = true; } } if (changed) { this.markDirty(projectStorageDir, index); } } async flushForTesting(): Promise { for (const [projectStorageDir, index] of this.indexes.entries()) { if (index.persistTimer) { clearTimeout(index.persistTimer); index.persistTimer = null; } while (index.dirty || index.persistPromise) { if (index.dirty) { this.startPersist(projectStorageDir, index); } await (index.persistPromise ?? Promise.resolve()); } } } private getOrCreateEntry( index: SessionMetadataIndexFile, signature: SessionFileSignature ): SessionMetadataIndexEntry { const existing = index.sessions[signature.filePath]; if (isFreshEntry(existing, signature)) { if (isNonNegativeFiniteNumber(signature.birthtimeMs)) { existing.birthtimeMs = signature.birthtimeMs; } return existing; } const entry: SessionMetadataIndexEntry = { sessionId: signature.sessionId, filePath: signature.filePath, mtimeMs: signature.mtimeMs, size: signature.size, updatedAt: Date.now(), }; if (isNonNegativeFiniteNumber(signature.birthtimeMs)) { entry.birthtimeMs = signature.birthtimeMs; } index.sessions[signature.filePath] = entry; return entry; } private async loadProjectIndex(projectStorageDir: string): Promise { const cached = this.indexes.get(projectStorageDir); if (cached) { return cached; } const existingLoad = this.loads.get(projectStorageDir); if (existingLoad) { return existingLoad; } const load = this.readProjectIndex(projectStorageDir).finally(() => { this.loads.delete(projectStorageDir); }); this.loads.set(projectStorageDir, load); return load; } private async readProjectIndex(projectStorageDir: string): Promise { const indexPath = SessionMetadataIndex.getIndexPath(this.rootDir, projectStorageDir); let file = createEmptyIndex(projectStorageDir); try { const raw = await fs.readFile(indexPath, 'utf8'); const parsed = normalizeIndexFile(JSON.parse(raw), projectStorageDir); if (parsed) { file = parsed; } } catch (error) { const code = isRecord(error) ? error.code : undefined; if (code !== 'ENOENT') { logger.debug( `Ignoring unreadable session metadata index ${indexPath}: ${ error instanceof Error ? error.message : String(error) }` ); } } const loaded: LoadedProjectIndex = { file, dirty: false, persistTimer: null, persistPromise: null, }; this.indexes.set(projectStorageDir, loaded); return loaded; } private markDirty(projectStorageDir: string, index: LoadedProjectIndex): void { index.file.updatedAt = Date.now(); index.dirty = true; if (this.persistDelayMs <= 0) { this.startPersist(projectStorageDir, index); return; } if (index.persistTimer) { return; } index.persistTimer = setTimeout(() => { index.persistTimer = null; this.startPersist(projectStorageDir, index); }, this.persistDelayMs); index.persistTimer.unref?.(); } private startPersist(projectStorageDir: string, index: LoadedProjectIndex): void { if (index.persistPromise) { return; } const promise = this.persistProjectIndex(projectStorageDir, index).finally(() => { if (index.persistPromise === promise) { index.persistPromise = null; } if (index.dirty) { this.markDirty(projectStorageDir, index); } }); index.persistPromise = promise; } private async persistProjectIndex( projectStorageDir: string, index: LoadedProjectIndex ): Promise { if (!index.dirty) { return; } const indexPath = SessionMetadataIndex.getIndexPath(this.rootDir, projectStorageDir); const tmpPath = `${indexPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`; const serialized = `${JSON.stringify(index.file)}\n`; index.dirty = false; try { await fs.mkdir(this.rootDir, { recursive: true, mode: 0o700 }); await fs.writeFile(tmpPath, serialized, { encoding: 'utf8', mode: 0o600 }); await fs.rename(tmpPath, indexPath); } catch (error) { try { await fs.unlink(tmpPath); } catch { // ignore } logger.debug( `Failed to persist session metadata index ${indexPath}: ${ error instanceof Error ? error.message : String(error) }` ); } } }