From da58ee0875689381cf29aa7830a6b3dced7096d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:25:58 -0700 Subject: [PATCH] Session History: the Workspace tab stops re-reading every transcript to find its sessions (#30469) * perf(session-history): remember each transcripts folder so Workspace scans stop re-reading files * refactor(session-history): keep the scope cwd table in its own module * test(session-history): type the hoisted read log without an assertion * test(session-history): type the collected session folders * chore(wsl-move): list the parse cache scope_cwds table * Keep scoped session selection current with cache authority * Consolidate scope cache imports in regression test * Run scope cache SQLite regression tests under Node --------- Co-authored-by: OrcaWin Co-authored-by: Orca Worker --- config/scripts/vitest-node-runtime-files.mjs | 2 + .../ai-vault/session-parse-cache-database.ts | 74 ++++--- .../session-parse-cache-persistence.ts | 57 ++++- .../ai-vault/session-parse-cache-store.ts | 110 +++++++++- .../ai-vault/session-scanner-cline-parser.ts | 12 +- .../session-scanner-cursor-chat-meta.test.ts | 40 ++++ .../session-scanner-cursor-chat-meta.ts | 9 + .../ai-vault/session-scanner-graph-parsers.ts | 25 ++- .../ai-vault/session-scanner-grok-parser.ts | 7 +- .../ai-vault/session-scanner-kiro-parser.ts | 9 +- .../session-scanner-opencode-parser.ts | 7 +- .../session-scanner-scope-cwd-facts.test.ts | 200 ++++++++++++++++++ .../session-scanner-scope-walk.test.ts | 97 ++++++++- .../ai-vault/session-scanner-scope-walk.ts | 100 ++++++--- src/main/ai-vault/session-scope-cwd.ts | 191 ++++++++++------- src/main/ai-vault/session-scope-cwds-table.ts | 126 +++++++++++ src/main/ai-vault/session-sidecar-stat.ts | 20 ++ .../wsl-move/wsl-move-storage-dispositions.ts | 3 +- 18 files changed, 940 insertions(+), 149 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts create mode 100644 src/main/ai-vault/session-scope-cwds-table.ts diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 31aea1f69ba..0293f9a4f4b 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -19,6 +19,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/ai-vault/session-parse-cache-large-scan.test.ts', 'src/main/ai-vault/session-parse-cache-metadata-checkpoint.test.ts', 'src/main/ai-vault/session-parse-cache-persistence.test.ts', + 'src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts', + 'src/main/ai-vault/session-scanner-scope-walk.test.ts', // Electron main bridges streams with Node's implementation. 'src/main/host/electron-http-client.test.ts', 'src/main/daemon/daemon-init-provider-installation.test.ts', diff --git a/src/main/ai-vault/session-parse-cache-database.ts b/src/main/ai-vault/session-parse-cache-database.ts index 3848ff31d39..e5e96ea496f 100644 --- a/src/main/ai-vault/session-parse-cache-database.ts +++ b/src/main/ai-vault/session-parse-cache-database.ts @@ -9,8 +9,17 @@ import { removeVersionedCacheDatabase } from '../sqlite/versioned-cache-database' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { PersistedSessionParseCacheEntry } from './session-parse-cache-store' -import type { SessionSidecarObservation } from './session-sidecar-stat' +import type { + PersistedSessionParseCacheEntry, + SessionScopeCwdLookup, + SessionScopeCwdStamp +} from './session-parse-cache-store' +import { + ensureScopeCwdsTable, + readScopeCwdRows, + writeScopeCwdRows +} from './session-scope-cwds-table' +import { parsePersistedSidecar } from './session-sidecar-stat' // Bump when the row layout or cached session semantics change; a mismatched // database is dropped whole. @@ -68,6 +77,21 @@ export class SessionParseCacheDatabase { }, undefined) } + /** Each path's parse-row cwd and stored scope cwd, without decoding whole sessions. */ + readScopeCwds(paths: readonly string[]) { + const parsed = this.run((db) => readScopeCwdRows(db, paths, 'parsed'), undefined) + const read = this.run((db) => readScopeCwdRows(db, paths, 'read'), undefined) + const found = parsed ?? new Map() + for (const [path, row] of read ?? []) { + found.set(path, { ...found.get(path), ...row }) + } + return { found, parsed: parsed !== undefined, read: read !== undefined } + } + + writeScopeCwds(rows: Iterable<[string, SessionScopeCwdStamp]>, seenAt: number): void { + this.run((db) => writeScopeCwdRows(db, rows, seenAt), undefined) + } + /** * One transaction; a caller chunks large writes so the event loop gets turns * between them. `keepExisting` leaves rows already present untouched. @@ -105,7 +129,10 @@ export class SessionParseCacheDatabase { } delete(path: string): void { - this.run((db) => db.prepare('DELETE FROM rows WHERE path = ?').run(path), undefined) + this.run((db) => { + db.prepare('DELETE FROM rows WHERE path = ?').run(path) + db.prepare('DELETE FROM scope_cwds WHERE path = ?').run(path) + }, undefined) } /** Oldest first, so repeated calls work through every stale row outside `excludeGlob`. */ @@ -114,7 +141,8 @@ export class SessionParseCacheDatabase { (db) => db .prepare( - 'SELECT path FROM rows WHERE seen_at < ? AND path NOT GLOB ? ORDER BY seen_at LIMIT ?' + `SELECT path FROM (SELECT path, seen_at FROM rows UNION ALL SELECT path, seen_at FROM scope_cwds) + WHERE seen_at < ? AND path NOT GLOB ? GROUP BY path ORDER BY MIN(seen_at) LIMIT ?` ) .all(cutoff, excludeGlob, limit) .flatMap((row) => (typeof row.path === 'string' ? [row.path] : [])), @@ -126,18 +154,23 @@ export class SessionParseCacheDatabase { deleteSeenBefore(cutoff: number, glob: string): boolean { return this.run( (db) => - Number( - db.prepare('DELETE FROM rows WHERE seen_at < ? AND path GLOB ?').run(cutoff, glob).changes - ) > 0, + ['rows', 'scope_cwds'] + .map((table) => + Number( + db.prepare(`DELETE FROM ${table} WHERE seen_at < ? AND path GLOB ?`).run(cutoff, glob) + .changes + ) + ) + .some((changes) => changes > 0), false ) } markSeen(path: string, seenAt: number): void { - this.run( - (db) => db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path), - undefined - ) + this.run((db) => { + db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path) + db.prepare('UPDATE scope_cwds SET seen_at = ? WHERE path = ?').run(seenAt, path) + }, undefined) } /** Hands freed pages back to the file system after deletes. */ @@ -189,7 +222,8 @@ function openDatabase(path: string): SyncDatabase { version: SESSION_PARSE_CACHE_SCHEMA_VERSION, sql: SCHEMA_SQL, directoryMode: PRIVATE_DIRECTORY_MODE, - pageSize: PAGE_SIZE + pageSize: PAGE_SIZE, + afterSchema: ensureScopeCwdsTable }) hardenSqliteDatabaseFiles(path) return db @@ -233,19 +267,3 @@ export function parsePersistedSessionParseCacheRow( function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } - -// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has -// no such field. Absent (or unreadable) means unknown, which costs one re-parse -// of the rows that have a sibling and nothing at all for the rest. -function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined { - if (value === 'none' || value === 'unknown') { - return value - } - if (!isRecord(value)) { - return undefined - } - const { path, mtimeMs, sizeBytes } = value - return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number' - ? { path, mtimeMs, sizeBytes } - : undefined -} diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 4d89875de8b..65524d580d2 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -11,8 +11,11 @@ import { splitOpenCodeSqliteCandidate } from '../../shared/opencode-sqlite-sessi import { SessionParseCacheDatabase } from './session-parse-cache-database' import { importLegacySessionParseCacheJson } from './session-parse-cache-legacy-json' import { + scopeCwdStampOf, setSessionParseCacheColdTier, - type PersistedSessionParseCacheEntry + type PersistedSessionParseCacheEntry, + type SessionScopeCwdRead, + type SessionScopeCwdStamp } from './session-parse-cache-store' const WRITE_DEBOUNCE_MS = 1_500 @@ -41,6 +44,7 @@ let owner = false // undefined: not opened yet; null: unavailable for this process's life. let database: SessionParseCacheDatabase | null | undefined const pendingWrites = new Map() +const pendingScopeCwds = new Map() let writeTimer: NodeJS.Timeout | null = null let pendingSince: number | null = null @@ -55,7 +59,13 @@ export function configureSessionParseCachePersistence( export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void { configureSessionParseCachePersistence(next) owner = true - setSessionParseCacheColdTier({ read: readRow, stored: queueWrite, deleted: deleteRow }) + setSessionParseCacheColdTier({ + read: readRow, + stored: queueWrite, + deleted: deleteRow, + readScopeCwds, + storedScopeCwd: queueScopeCwdWrite + }) } export function getSessionParseCachePersistenceOptions(): SessionParseCachePersistenceOptions | null { @@ -65,6 +75,7 @@ export function getSessionParseCachePersistenceOptions(): SessionParseCachePersi export function resetSessionParseCachePersistenceForTests(): void { clearWriteTimer() pendingWrites.clear() + pendingScopeCwds.clear() database?.close() database = undefined options = null @@ -89,6 +100,16 @@ export async function flushSessionParseCachePersist(): Promise { await yieldToEventLoop() } } + while (pendingScopeCwds.size > 0) { + const chunk = [...pendingScopeCwds].slice(0, WRITE_CHUNK_ROWS) + for (const [path] of chunk) { + pendingScopeCwds.delete(path) + } + openedDatabase()?.writeScopeCwds(chunk, Date.now()) + if (pendingScopeCwds.size > 0) { + await yieldToEventLoop() + } + } } // Opened on first need, so Electron main (which never looks rows up) never opens it. @@ -151,9 +172,40 @@ function readRow(path: string): PersistedSessionParseCacheEntry | undefined { return pendingWrites.get(path) ?? openedDatabase()?.read(path) } +function readScopeCwds(paths: readonly string[]): SessionScopeCwdRead { + const result: SessionScopeCwdRead = openedDatabase()?.readScopeCwds(paths) ?? { + found: new Map(), + parsed: false, + read: false + } + const found = result.found + for (const path of paths) { + const row = pendingWrites.get(path) + const read = pendingScopeCwds.get(path) + if (row !== undefined || read !== undefined) { + found.set(path, { + ...found.get(path), + ...(row === undefined ? {} : { parsed: scopeCwdStampOf(row) }), + ...(read === undefined ? {} : { read }) + }) + } + } + return result +} + +function queueScopeCwdWrite(path: string, stamp: SessionScopeCwdStamp): void { + pendingScopeCwds.delete(path) + pendingScopeCwds.set(path, stamp) + scheduleFlush() +} + function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void { pendingWrites.delete(path) pendingWrites.set(path, entry) + scheduleFlush() +} + +function scheduleFlush(): void { const now = Date.now() pendingSince ??= now if (writeTimer) { @@ -169,6 +221,7 @@ function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void // Immediate, so a queued write or a later lookup can't bring the row back. function deleteRow(path: string): void { pendingWrites.delete(path) + pendingScopeCwds.delete(path) openedDatabase()?.delete(path) } diff --git a/src/main/ai-vault/session-parse-cache-store.ts b/src/main/ai-vault/session-parse-cache-store.ts index dc6f985511f..43cd2871f41 100644 --- a/src/main/ai-vault/session-parse-cache-store.ts +++ b/src/main/ai-vault/session-parse-cache-store.ts @@ -54,12 +54,29 @@ export type PersistedSessionParseCacheEntry = Omit< 'resume' | 'foldSession' | 'skippedRecords' > +// A transcript version's cwd for scope membership: null means the version is +// known to have none, so no scope holds it. +export type SessionScopeCwdStamp = Pick< + PersistedSessionParseCacheEntry, + 'mtimeMs' | 'sizeBytes' | 'platform' | 'sidecar' +> & { cwd: string | null } + +// `parsed` (from a parse row) outranks `read` (learned without a parse). +export type SessionScopeCwdLookup = { parsed?: SessionScopeCwdStamp; read?: SessionScopeCwdStamp } +export type SessionScopeCwdRead = { + found: Map + parsed: boolean + read: boolean +} + // The database behind this map (session-parse-cache-persistence.ts). A row the // map's bounds drop is read back from it rather than re-parsed. export type SessionParseCacheColdTier = { read(path: string): PersistedSessionParseCacheEntry | undefined stored(path: string, entry: PersistedSessionParseCacheEntry): void deleted(path: string): void + readScopeCwds?(paths: readonly string[]): SessionScopeCwdRead + storedScopeCwd?(path: string, stamp: SessionScopeCwdStamp): void } const cache = new Map() @@ -70,10 +87,19 @@ const listedPaths = new Set() // count-bounded sets. const unlistedPaths = new Set() const resumePaths = new Set() +// Scope cwds learned without a parse, for the paths the latest scope walk found. +const readScopeCwds = new Map() +const scopeKnowledge = new Map() let coldTier: SessionParseCacheColdTier | null = null export function setSessionParseCacheColdTier(next: SessionParseCacheColdTier | null): void { coldTier = next + clearSessionScopeCwdKnowledge() + readScopeCwds.clear() +} + +export function clearSessionScopeCwdKnowledge(): void { + scopeKnowledge.clear() } export function resetSessionParseCacheForTests(): void { @@ -81,6 +107,8 @@ export function resetSessionParseCacheForTests(): void { listedPaths.clear() unlistedPaths.clear() resumePaths.clear() + readScopeCwds.clear() + clearSessionScopeCwdKnowledge() coldTier = null } @@ -90,6 +118,8 @@ export function invalidateSessionParseCacheEntry(path: string): void { unlistedPaths.delete(path) resumePaths.delete(path) cache.delete(path) + readScopeCwds.delete(path) + scopeKnowledge.delete(path) coldTier?.deleted(path) } @@ -100,7 +130,7 @@ export function noteSessionParseCacheListed(path: string): void { export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined { const entry = cache.get(path) - if (entry !== undefined || coldTier === null) { + if (entry !== undefined || coldTier === null || scopeKnowledge.get(path) === 'unresolved') { return entry } const persisted = coldTier.read(path) @@ -117,10 +147,86 @@ export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry export function peekSessionParseCacheEntry( path: string ): PersistedSessionParseCacheEntry | undefined { - return cache.get(path) ?? coldTier?.read(path) + return ( + cache.get(path) ?? + (scopeKnowledge.get(path) === 'unresolved' ? undefined : coldTier?.read(path)) + ) +} + +/** Scope cwds for many paths at once: memory first, then one batched database read. */ +export function lookupSessionScopeCwds(paths: readonly string[]): void { + const cold = paths.filter((path) => !cache.has(path)) + const stored = + coldTier === null || cold.length === 0 + ? { found: new Map(), parsed: true, read: true } + : coldTier.readScopeCwds?.(cold) + for (const path of paths) { + const row = stored?.found.get(path) + const parsed = row?.parsed ?? (stored?.parsed ? 'absent' : 'unresolved') + scopeKnowledge.set(path, cache.has(path) ? 'changed' : parsed) + const read = row?.read + if (read && !readScopeCwds.has(path)) { + readScopeCwds.set(path, read) + } + } +} + +export function resolveSessionScopeCwd( + path: string, + matches: (stamp: SessionScopeCwdStamp) => boolean +): { cwd: string | null } | 'read' | 'parse' { + const hot = cache.get(path) + let parsed = hot ? scopeCwdStampOf(hot) : scopeKnowledge.get(path) + if (parsed === 'changed') { + const stored = coldTier?.readScopeCwds?.([path]).found.get(path) + parsed = stored?.parsed ?? 'unresolved' + scopeKnowledge.set(path, parsed) + if (stored?.read && !readScopeCwds.has(path)) { + readScopeCwds.set(path, stored.read) + } + } + if (parsed === undefined || parsed === 'unresolved') { + scopeKnowledge.set(path, 'unresolved') + return 'parse' + } + for (const stamp of [typeof parsed === 'object' ? parsed : undefined, readScopeCwds.get(path)]) { + if (stamp && matches(stamp)) { + return { cwd: stamp.cwd } + } + } + return 'read' +} + +export function storeSessionScopeCwd(path: string, stamp: SessionScopeCwdStamp): void { + readScopeCwds.set(path, stamp) + coldTier?.storedScopeCwd?.(path, stamp) +} + +/** Keeps in memory only the read cwds of transcripts the latest scope walk found. */ +export function pruneSessionScopeCwds(discoveredPaths: ReadonlySet): void { + for (const map of [readScopeCwds, scopeKnowledge]) { + for (const path of map.keys()) { + if (!discoveredPaths.has(path)) { + map.delete(path) + } + } + } +} + +export function scopeCwdStampOf(entry: PersistedSessionParseCacheEntry): SessionScopeCwdStamp { + return { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + cwd: entry.session?.cwd ?? null, + ...(entry.sidecar === undefined ? {} : { sidecar: entry.sidecar }) + } } export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void { + if (scopeKnowledge.has(path)) { + scopeKnowledge.set(path, 'changed') + } const previous = cache.get(path) const changed = previous === undefined || diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts index 5ef97f09cb0..b36c2be5dcb 100644 --- a/src/main/ai-vault/session-scanner-cline-parser.ts +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -68,6 +68,16 @@ function isMissingSessionPathError(error: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR' } +/** The session's cwd from its manifest alone; null when the manifest lists no session. */ +export function clineManifestCwd(metadataContent: string): string | null { + const metadata = parseJsonRecord(metadataContent) + return metadata ? clineMetadataCwd(metadata) : null +} + +function clineMetadataCwd(metadata: Record): string | null { + return extractString(metadata.cwd) ?? extractString(metadata.workspace_root) +} + export function parseClineSessionContent( file: FileWithMtime, metadataContent: string, @@ -88,7 +98,7 @@ export function parseClineSessionContent( sessionId, messages: messageSink }) - accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) + accumulator.cwd = clineMetadataCwd(metadata) accumulator.model = extractString(metadata.model) updateTimeline(accumulator, metadata.started_at) diff --git a/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts index de20f1eebc0..4d1d2503dc4 100644 --- a/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts +++ b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts @@ -82,6 +82,9 @@ import { } from './session-scanner-parse-cache' import { getSessionParseCacheEntry, + lookupSessionScopeCwds, + resolveSessionScopeCwd, + scopeCwdStampOf, peekSessionParseCacheEntry, type PersistedSessionParseCacheEntry, setSessionParseCacheColdTier @@ -615,6 +618,16 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript const read = vi.fn((key: string) => rows.get(key)) const tier = { read, + readScopeCwds: (paths: readonly string[]) => ({ + found: new Map( + paths.flatMap((key) => { + const row = rows.get(key) + return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const] + }) + ), + parsed: true, + read: true + }), stored: (key: string, row: PersistedSessionParseCacheEntry) => rows.set(key, row), deleted: (key: string) => rows.delete(key) } @@ -645,3 +658,30 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript expect(found.map((row) => row.cwd)).toEqual(['/repo']) expect(await stat(path)).toMatchObject({ mtimeMs: file.mtimeMs, size: file.sizeBytes }) }) + +it('lists a Cursor session once meta.json names the scope after a scan stored it as cwd-less', async () => { + const cursorHome = await createCursorHome() + const path = await writeTranscript(cursorHome, 'slug', 'chat-1', [ + JSON.stringify({ role: 'user', message: { content: 'Find the bug' } }) + ]) + await utimes(path, 1, 1) + for (const chatId of ['busy-1', 'busy-2']) { + await writeTranscript(cursorHome, 'slug', chatId, [ + JSON.stringify({ role: 'user', message: { content: chatId } }) + ]) + } + const scan = () => + scanAiVaultSessions({ + ...isolatedScanRoots(join(cursorHome, '..')), + cursorProjectsDir: join(cursorHome, 'projects'), + platform: 'darwin', + limit: 1, + scopePaths: ['/repo'] + }) + expect((await scan()).sessions.some((row) => row.filePath === path)).toBe(false) + lookupSessionScopeCwds([path]) + expect(resolveSessionScopeCwd(path, () => true)).toEqual({ cwd: null }) + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-1', { cwd: '/repo' }) + const found = (await scan()).sessions.filter((row) => row.filePath === path) + expect(found.map((row) => row.cwd)).toEqual(['/repo']) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-chat-meta.ts b/src/main/ai-vault/session-scanner-cursor-chat-meta.ts index e1c9b4afdfa..f010e6cafda 100644 --- a/src/main/ai-vault/session-scanner-cursor-chat-meta.ts +++ b/src/main/ai-vault/session-scanner-cursor-chat-meta.ts @@ -68,6 +68,15 @@ export function wasCursorChatMetaRefused(transcriptPath: string): boolean { return scanScopedIndex.getStore()?.refusedTranscripts.has(transcriptPath) ?? false } +/** Whether this scan could not look for the transcript's meta.json, so its absence proves nothing. */ +export function cursorChatMetaUnavailable(transcriptPath: string): boolean { + const chatsRoot = cursorChatsRootFromTranscriptPath(transcriptPath) + return ( + wasCursorChatMetaRefused(transcriptPath) || + (chatsRoot !== null && scanScopedIndex.getStore()?.refusals.has(chatsRoot) === true) + ) +} + /** Chats roots the current scan was refused, for the caller to report as scan issues. */ export function cursorChatMetaRefusals(): { chatsRoot: string; message: string }[] { const scan = scanScopedIndex.getStore() diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 353decfcd46..39cc6c53373 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -42,6 +42,20 @@ type ParserSessionOptions = { executionHostPlatform?: NodeJS.Platform | null } +/** The session's cwd; a scope walk reads it from metadata.json without the context file. */ +export function rovoSessionCwd(metadata: Record): string | null { + return firstString(metadata, [ + 'workspace_path', + 'workspacePath', + 'workspace', + 'cwd', + 'working_directory', + 'workingDirectory', + 'project_path', + 'projectPath' + ]) +} + export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform, @@ -60,16 +74,7 @@ export async function parseRovoSessionFile( messages }) accumulator.title = firstString(metadata, ['title', 'name', 'summary']) - accumulator.cwd = firstString(metadata, [ - 'workspace_path', - 'workspacePath', - 'workspace', - 'cwd', - 'working_directory', - 'workingDirectory', - 'project_path', - 'projectPath' - ]) + accumulator.cwd = rovoSessionCwd(metadata) updateTimeline( accumulator, extractString(metadata.created_at) ?? extractString(metadata.createdAt) diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index d9e88077c39..4c931b8b4c7 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -45,7 +45,7 @@ export async function parseGrokSessionFile( const info = asRecord(record.info) const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path)) const accumulator = createAccumulator({ agent: 'grok', file, sessionId, messages }) - accumulator.cwd = extractString(info?.cwd) + accumulator.cwd = grokSessionCwd(record) accumulator.title = normalizeTitleText(extractString(record.generated_title) ?? '') ?? normalizeTitleText(extractString(record.session_summary) ?? '') @@ -60,6 +60,11 @@ export async function parseGrokSessionFile( return finalizeSession(accumulator, platform) } +/** The session's cwd; a scope walk reads it from summary.json without the history. */ +export function grokSessionCwd(summary: Record): string | null { + return extractString(asRecord(summary.info)?.cwd) +} + async function consumeGrokChatHistory( accumulator: SessionAccumulator, sessionDir: string diff --git a/src/main/ai-vault/session-scanner-kiro-parser.ts b/src/main/ai-vault/session-scanner-kiro-parser.ts index 8eef66422ea..56384965b49 100644 --- a/src/main/ai-vault/session-scanner-kiro-parser.ts +++ b/src/main/ai-vault/session-scanner-kiro-parser.ts @@ -99,13 +99,18 @@ function consumeKiroTranscriptLine(accumulator: SessionAccumulator, line: string } /** null for a subagent's child session: it is reached from its parent, not listed. */ +export function listedKiroMetadata(metadataContent: string): Record | null { + const metadata = parseJsonObject(metadataContent) + return metadata && !extractString(metadata.parent_session_id) ? metadata : null +} + function createKiroAccumulator( file: FileWithMtime, metadataContent: string, messages?: TranscriptMessageSink ): SessionAccumulator | null { - const metadata = parseJsonObject(metadataContent) - if (!metadata || extractString(metadata.parent_session_id)) { + const metadata = listedKiroMetadata(metadataContent) + if (!metadata) { return null } const accumulator = createAccumulator({ diff --git a/src/main/ai-vault/session-scanner-opencode-parser.ts b/src/main/ai-vault/session-scanner-opencode-parser.ts index 2bc0245bcb0..ee152cfe9b1 100644 --- a/src/main/ai-vault/session-scanner-opencode-parser.ts +++ b/src/main/ai-vault/session-scanner-opencode-parser.ts @@ -24,6 +24,11 @@ import { tokenTotal } from './session-scanner-values' +/** The session's cwd; a scope walk reads it from the session file without its messages. */ +export function openCodeSessionCwd(record: Record): string | null { + return extractString(record.directory) +} + export async function parseOpenCodeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform, @@ -36,7 +41,7 @@ export async function parseOpenCodeSessionFile( const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) const accumulator = createAccumulator({ agent: 'opencode', file, sessionId, messages }) accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.directory) + accumulator.cwd = openCodeSessionCwd(record) updateTimeline(accumulator, timeObjectValue(record.time, 'created')) updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId) diff --git a/src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts b/src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts new file mode 100644 index 00000000000..49184403b6b --- /dev/null +++ b/src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts @@ -0,0 +1,200 @@ +import { appendFile, mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access' +import { grokEncodedCwdDirName } from '../../shared/grok-session-paths' +import { + flushSessionParseCachePersist, + initSessionParseCachePersistence, + resetSessionParseCachePersistenceForTests +} from './session-parse-cache-persistence' +import { resetSessionParseCacheForTests } from './session-parse-cache-store' +import { scanAiVaultSessions } from './session-scanner' +import SyncDatabase from '../sqlite/sync-database' +import { SessionParseCacheDatabase } from './session-parse-cache-database' +import { parseAgentSessionFileCached } from './session-scanner-parse-cache' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const reads = vi.hoisted((): string[] => []) +vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + wslGatedReadFile: (...args: Parameters) => { + reads.push(String(args[0])) + return actual.wslGatedReadFile(...args) + }, + readTranscriptSlice: (...args: Parameters) => { + reads.push(String(args[0])) + return actual.readTranscriptSlice(...args) + }, + openTranscriptReadStream: (...args: Parameters) => { + reads.push(String(args[0])) + return actual.openTranscriptReadStream(...args) + } + } +}) + +let tempRoots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] + reads.length = 0 +}) + +it.each([false, true])('keeps cold authority with read failure=%s', async (failReads) => { + const root = await mkdtemp(join(tmpdir(), 'orca-scope-invalid-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const persistence = { filePath: join(root, 'parse-cache.json'), appVersion: 'test' } + initSessionParseCachePersistence(persistence) + const paths: string[] = [] + for (let index = 0; index < 8; index++) { + const path = await writeRollout( + roots.codexSessionsDir, + `old-${index}`, + index === 0 ? '/elsewhere' : '/repo', + index + 1 + ) + if (index === 0) { + const context = jsonLines([{ type: 'turn_context', payload: { cwd: '/repo' } }]) + await appendFile(path, `\n${context}\n`) + } + const { mtimeMs, size: sizeBytes, mtime } = await stat(path) + const parsed = await parseAgentSessionFileCached( + { + agent: 'codex', + file: { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() }, + codexHome: null + }, + 'linux' + ) + expect(parsed?.cwd).toBe('/repo') + paths.push(path) + } + await flushSessionParseCachePersist() + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite')) + for (const [index, session] of ['{', '1', '[]', 'true', '"scalar"', 'null', 'null'].entries()) { + db.prepare('UPDATE rows SET session = ?, sidecar = ? WHERE path = ?').run( + session, + index === 5 ? '{' : null, + paths[index + 1] + ) + } + db.prepare( + 'INSERT INTO scope_cwds SELECT path, mtime_ms, size_bytes, platform, ?, ?, seen_at FROM rows WHERE path = ?' + ).run('{', '/elsewhere', paths[0]) + db.close() + for (let index = 0; index < 9; index++) { + await writeRollout(roots.codexSessionsDir, `busy-${index}`, '/other', 20) + } + initSessionParseCachePersistence(persistence) + const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds') + const ordinary = vi.spyOn(SessionParseCacheDatabase.prototype, 'read') + const prepare = SyncDatabase.prototype.prepare + const queries = vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql + ) { + if (failReads && sql.startsWith('SELECT') && sql.includes('FROM rows WHERE path')) { + throw new Error('unavailable parsed channel') + } + return prepare.call(this, sql) + }) + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 8, + scopePaths: ['/repo'] + }) + expect( + result.sessions + .filter((row) => row.cwd === '/repo') + .map((row) => row.filePath) + .sort() + ).toEqual(paths.slice(0, failReads ? 8 : 7).sort()) + expect( + metadata.mock.calls.filter(([batch]) => batch.some((path) => paths.includes(path))) + ).toHaveLength(1) + expect(queries.mock.calls.filter(([sql]) => sql.includes('WHERE path IN'))).toHaveLength(2) + if (failReads) { + expect(ordinary.mock.calls.filter(([path]) => paths.includes(path))).toEqual([]) + } +}) + +async function writeGrokSession(sessionsDir: string, id: string, cwd: string, day: number) { + const dir = join(sessionsDir, grokEncodedCwdDirName(cwd) ?? 'unknown', id) + await mkdir(dir, { recursive: true }) + const timestamp = new Date(Date.UTC(2026, 9, day, 10)) + const summary = join(dir, 'summary.json') + await writeFile( + summary, + JSON.stringify({ + info: { id, cwd }, + created_at: timestamp.toISOString(), + updated_at: timestamp.toISOString() + }) + ) + await writeFile(join(dir, 'chat_history.jsonl'), jsonLines([{ type: 'user', content: id }])) + await utimes(summary, timestamp, timestamp) + return dir +} + +async function writeRollout(sessionsDir: string, id: string, cwd: string, day: number) { + const timestamp = new Date(Date.UTC(2026, 9, day, 10)) + const dir = join(sessionsDir, '2026', '10', String(day).padStart(2, '0')) + const path = join(dir, `rollout-2026-10-${String(day).padStart(2, '0')}T10-00-00-${id}.jsonl`) + await mkdir(dir, { recursive: true }) + await writeFile( + path, + jsonLines([ + { timestamp, type: 'session_meta', payload: { id, cwd } }, + { + timestamp, + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'text', text: id }] } + } + ]) + ) + await utimes(path, timestamp, timestamp) + return path +} + +it('settles out-of-scope transcripts once and reads none of them after a restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-scope-cwd-facts-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const persistence = { filePath: join(root, 'user-data', 'parse-cache.json'), appVersion: 'test' } + initSessionParseCachePersistence(persistence) + const others: string[] = [] + for (const day of [15, 14, 13]) { + others.push(await writeGrokSession(roots.grokSessionsDir, `other-${day}`, '/other', day)) + } + const quiet = await writeGrokSession(roots.grokSessionsDir, 'repo-1', '/repo', 1) + const elsewhere = await writeRollout(roots.codexSessionsDir, 'other-2', '/other', 2) + const scan = () => + scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1, scopePaths: ['/repo'] }) + + const listed = (await scan()).sessions.map((session) => session.cwd) + expect(listed.filter((cwd) => cwd === '/repo')).toHaveLength(1) + // The walked Grok session was ruled out from summary.json, never its history. + expect(reads).not.toContain(join(others[2], 'chat_history.jsonl')) + + await flushSessionParseCachePersist() + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + initSessionParseCachePersistence(persistence) + reads.length = 0 + + const relisted = (await scan()).sessions.filter((session) => session.cwd === '/repo') + expect(relisted.map((session) => session.filePath)).toEqual([join(quiet, 'summary.json')]) + expect(reads.filter((path) => path.startsWith(root))).toEqual([]) + expect(reads).not.toContain(elsewhere) +}) diff --git a/src/main/ai-vault/session-scanner-scope-walk.test.ts b/src/main/ai-vault/session-scanner-scope-walk.test.ts index 81e3f730a97..0532d2faa3f 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.test.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.test.ts @@ -6,15 +6,25 @@ import { truncateAiVaultListResult } from '../../shared/ai-vault-session-depth' import { parseSessionCandidates } from './session-scanner-candidate-parse' import { MAX_UNLISTED_ENTRIES, + scopeCwdStampOf, peekSessionParseCacheEntry, setSessionParseCacheColdTier, type PersistedSessionParseCacheEntry, storeSessionParseCacheEntry, + storeSessionScopeCwd, resetSessionParseCacheForTests } from './session-parse-cache-store' import { createSessionParseStats, parseAgentSessionFileCached } from './session-scanner-parse-cache' import { scanAiVaultSessions } from './session-scanner' -import { resetTranscriptHeadCwdCacheForTests } from './session-scope-cwd' +import * as eventLoop from '../../shared/event-loop-yield' +import { AI_VAULT_SCOPE_CWD_SOURCES } from './session-scope-cwd' +import { SessionParseCacheDatabase } from './session-parse-cache-database' +import SyncDatabase from '../sqlite/sync-database' +import { + initSessionParseCachePersistence, + flushSessionParseCachePersist, + resetSessionParseCachePersistenceForTests +} from './session-parse-cache-persistence' import { isolatedScanRoots, jsonLines, @@ -26,12 +36,85 @@ import { let tempRoots: string[] = [] afterEach(async () => { + vi.restoreAllMocks() + resetSessionParseCachePersistenceForTests() await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) tempRoots = [] - resetTranscriptHeadCwdCacheForTests() resetSessionParseCacheForTests() }) +it.each(['prefetch', 'read'] as const)('keeps an evicted parse from %s', async (boundary) => { + const root = await mkdtemp(join(tmpdir(), 'orca-scope-publication-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const path = join(roots.codexSessionsDir, 'rollout-published.jsonl') + await writeJsonlFile(path, [ + { type: 'session_meta', payload: { id: 'published', cwd: '/elsewhere' } }, + { type: 'turn_context', payload: { cwd: '/repo' } }, + { type: 'event_msg', payload: { type: 'user_message', message: 'Find me' } } + ]) + await utimes(path, 1, 1) + await writeRollout(roots.codexSessionsDir, 'newer', '/other', '15') + const { mtimeMs, size: sizeBytes, mtime } = await stat(path) + const file = { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() } + initSessionParseCachePersistence({ filePath: join(root, 'cache.json'), appVersion: 'test' }) + const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds') + const publish = async () => { + const parsed = await parseAgentSessionFileCached( + { agent: 'codex', file, codexHome: null }, + 'linux' + ) + expect(parsed?.cwd).toBe('/repo') + for (let index = 0; index <= MAX_UNLISTED_ENTRIES; index++) { + storeSessionParseCacheEntry(`index-${index}`, { + mtimeMs: mtimeMs + 1, + sizeBytes: null, + platform: 'linux', + session: null, + resume: null + }) + } + if (boundary === 'read') { + await flushSessionParseCachePersist() + } + const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite')) + expect(db.prepare('SELECT path FROM rows WHERE path = ?').get(path) !== undefined).toBe( + boundary === 'read' + ) + db.close() + metadata.mockClear() + } + if (boundary === 'prefetch') { + storeSessionScopeCwd(path, { mtimeMs, sizeBytes, platform: 'linux', cwd: '/elsewhere' }) + vi.spyOn(eventLoop, 'yieldToEventLoop').mockImplementationOnce(publish) + } else { + const source = AI_VAULT_SCOPE_CWD_SOURCES.codex + if (source.kind !== 'read') { + throw new Error('Codex must have a cheap reader') + } + const original = source.readCwd + vi.spyOn(source, 'readCwd').mockImplementation(async (candidate, signal) => { + const read = await original(candidate, signal) + if (candidate.file.path === path) { + await publish() + } + return read + }) + } + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 1, + scopePaths: ['/repo'] + }) + const found = result.sessions.filter((row) => row.filePath === path) + expect(found.map((row) => row.cwd)).toEqual(['/repo']) + expect( + metadata.mock.calls.filter(([paths]) => paths.length === 1 && paths[0] === path) + ).toHaveLength(1) + expect(await stat(path)).toMatchObject({ mtimeMs, size: sizeBytes }) +}) + async function writeRollout(sessionsDir: string, id: string, cwd: string, day: string) { const dir = join(sessionsDir, '2026', '10', day) await mkdir(dir, { recursive: true }) @@ -214,6 +297,16 @@ it('uses a cold parsed latest Codex cwd after its unlisted row is evicted', asyn const read = vi.fn((key: string) => rows.get(key)) setSessionParseCacheColdTier({ read, + readScopeCwds: (paths) => ({ + found: new Map( + paths.flatMap((key) => { + const row = rows.get(key) + return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const] + }) + ), + parsed: true, + read: true + }), stored: (key, row) => rows.set(key, row), deleted: (key) => rows.delete(key) }) diff --git a/src/main/ai-vault/session-scanner-scope-walk.ts b/src/main/ai-vault/session-scanner-scope-walk.ts index 93afeaf7459..6d6248e679e 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.ts @@ -2,7 +2,16 @@ import { createAiVaultScopeCwdMatcher } from '../../shared/ai-vault-scope-cwd-ma import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { noteAiVaultScanReading } from './ai-vault-scan-progress' -import { peekSessionParseCacheEntry } from './session-parse-cache-store' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { + lookupSessionScopeCwds, + clearSessionScopeCwdKnowledge, + pruneSessionScopeCwds, + resolveSessionScopeCwd, + storeSessionScopeCwd, + type SessionScopeCwdStamp +} from './session-parse-cache-store' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { parseSessionCandidates } from './session-scanner-candidate-parse' import { sessionSortTime } from './session-scanner-accumulator' @@ -15,12 +24,7 @@ import type { SessionFileCandidate, SessionFileDiscovery } from './session-scanner-types' -import { - AI_VAULT_SCOPE_CWD_SOURCES, - pruneTranscriptHeadCwdCache, - readTranscriptHeadCwd, - scopeWalksAgentFiles -} from './session-scope-cwd' +import { AI_VAULT_SCOPE_CWD_SOURCES, scopeWalksAgentFiles } from './session-scope-cwd' import { sidecarUnchanged } from './session-sidecar-stat' import { prioritizeAntigravityTranscriptCandidates } from './antigravity-transcript-candidates' @@ -63,7 +67,7 @@ export async function scanInScopeSessions(args: ScopeScanArgs): Promise ({ agent: layout.agent, file, codexHome: null }))) } - const walked = await walkInScopeSessions(args, scopePaths) + const walked = await walkInScopeSessions(args, scopePaths).finally(clearSessionScopeCwdKnowledge) if (candidates.length === 0) { return walked } @@ -93,6 +97,7 @@ async function walkInScopeSessions( const signal = args.options.signal const inScope = createAiVaultScopeCwdMatcher(scopePaths) const candidates = walkCandidates(args, scopePaths) + await lookupScopeCwds(candidates, signal) const sessions = await parseSessionCandidates({ candidates, limit: args.limit, @@ -130,7 +135,7 @@ function walkCandidates( } all.push(...discoveryFileCandidates(discovery, files, args.options)) } - pruneTranscriptHeadCwdCache(seenPaths) + pruneSessionScopeCwds(seenPaths) // Aliases collapse with the listed files still present, so a listed rollout's // hardlink in another Codex home is not parsed again. return prioritizeAntigravityTranscriptCandidates( @@ -141,7 +146,29 @@ function walkCandidates( ) } -/** False only when a current parse or the transcript's opening records put it out of scope. */ +// Per lookup batch: one database round trip, then a turn for the event loop. +const SCOPE_LOOKUP_BATCH = 500 + +async function lookupScopeCwds( + candidates: readonly SessionFileCandidate[], + signal: AbortSignal | undefined +): Promise { + // The Antigravity fold has no cwd until the per-scan history join runs. + const paths = candidates + .filter((candidate) => !candidate.antigravityHistoryPath) + .map((candidate) => candidate.file.path) + for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_BATCH) { + throwIfAiVaultScanCancelled(signal) + lookupSessionScopeCwds(paths.slice(start, start + SCOPE_LOOKUP_BATCH)) + await yieldToEventLoop() + } +} + +/** + * False only when this transcript version's cwd is known to be out of scope: a + * parse row's cwd first, then one read without a parse. A read the agent can + * settle is stored, so the next scan of an unchanged file reads nothing. + */ async function mayBeInScope( candidate: SessionFileCandidate, inScope: ReturnType, @@ -149,24 +176,41 @@ async function mayBeInScope( signal: AbortSignal | undefined ): Promise { const { file } = candidate - const entry = peekSessionParseCacheEntry(file.path) - if ( - // The Antigravity fold has no cwd until the per-scan history join runs. - !candidate.antigravityHistoryPath && - entry !== undefined && - entry.platform === platform && - entry.mtimeMs === file.mtimeMs && - (entry.sizeBytes === null || - file.sizeBytes === undefined || - entry.sizeBytes === file.sizeBytes) && - sidecarUnchanged(entry.sidecar, file.sidecar) - ) { - return entry.session !== null && inScope(entry.session.cwd, file.path) - } - const source = AI_VAULT_SCOPE_CWD_SOURCES[candidate.agent] - if (source.kind !== 'head') { + if (candidate.antigravityHistoryPath) { return true } - const cwd = await readTranscriptHeadCwd(file, source, signal) - return cwd === null || inScope(cwd, file.path) + const resolve = () => + resolveSessionScopeCwd(file.path, (stamp) => stampMatchesFile(stamp, file, platform)) + let known = resolve() + const source = AI_VAULT_SCOPE_CWD_SOURCES[candidate.agent] + if (known === 'read' && source.kind === 'read') { + const read = await source.readCwd(candidate, signal) + throwIfAiVaultScanCancelled(signal) + if (read !== null) { + storeSessionScopeCwd(file.path, { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + platform, + cwd: read.cwd, + ...(file.sidecar === undefined ? {} : { sidecar: file.sidecar }) + }) + } + known = resolve() + } + return typeof known === 'string' || (known.cwd !== null && inScope(known.cwd, file.path)) +} + +function stampMatchesFile( + stamp: SessionScopeCwdStamp, + file: FileWithMtime, + platform: NodeJS.Platform +): boolean { + return ( + stamp.platform === platform && + stamp.mtimeMs === file.mtimeMs && + (stamp.sizeBytes === null || + file.sizeBytes === undefined || + stamp.sizeBytes === file.sizeBytes) && + sidecarUnchanged(stamp.sidecar, file.sidecar) + ) } diff --git a/src/main/ai-vault/session-scope-cwd.ts b/src/main/ai-vault/session-scope-cwd.ts index fad3287dc14..8d0196d6b54 100644 --- a/src/main/ai-vault/session-scope-cwd.ts +++ b/src/main/ai-vault/session-scope-cwd.ts @@ -1,36 +1,101 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' -import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access' +import { readTranscriptSlice, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { CLAUDE_CWD_BUCKET_LAYOUT, CODEBUDDY_CWD_BUCKET_LAYOUT, PI_CWD_BUCKET_LAYOUT, type CwdBucketLayout } from './session-cwd-bucket-layouts' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' import { asRecord } from './session-scanner-record-value' import { extractString, parseJsonObject } from './session-scanner-values' +import { clineManifestCwd } from './session-scanner-cline-parser' +import { cursorChatMetaUnavailable, readCursorChatMeta } from './session-scanner-cursor-chat-meta' +import { rovoSessionCwd } from './session-scanner-graph-parsers' +import { grokSessionCwd } from './session-scanner-grok-parser' +import { listedKiroMetadata } from './session-scanner-kiro-parser' +import { openCodeSessionCwd } from './session-scanner-opencode-parser' type HeadRecord = Record +/** A definitive cwd (null: the session has none), or null when the read could not settle it. */ +export type ScopeCwdRead = { cwd: string | null } | null + /** * How Workspace and Project views find a transcript's cwd without a full parse, * so an agent's older in-scope sessions are selected before the recency cap. * - bucket: the agent keeps one directory per cwd. - * - head: the cwd sits in the transcript's opening records. + * - read: a small read settles it. A settled cwd is stored per transcript + * version, so later scans read nothing; a parse row always outranks it. * - parse: only the (cached) full parse knows it. * - none: the agent never records a cwd, so no session can belong to a scope. */ export type SessionScopeCwdSource = | { kind: 'bucket'; layout: CwdBucketLayout } - | { kind: 'head'; cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number } + | { + kind: 'read' + readCwd: (candidate: SessionFileCandidate, signal?: AbortSignal) => Promise + } | { kind: 'parse' } | { kind: 'none' } const PARSE: SessionScopeCwdSource = { kind: 'parse' } + +/** The cwd in the transcript's opening records; a later record can still move it, which a parse row then corrects. */ +function head( + cwdOf: (record: HeadRecord) => unknown, + firstReadBytes?: number +): SessionScopeCwdSource { + return { + kind: 'read', + readCwd: async ({ file }, signal) => { + const cwd = await readTranscriptHeadCwd(file, { cwdOf, firstReadBytes }, signal) + return cwd === null ? null : { cwd } + } + } +} + +function document(cwdOf: (content: string) => string | null): SessionScopeCwdSource { + return { kind: 'read', readCwd: ({ file }, signal) => readDocumentCwd(file, cwdOf, signal) } +} + +/** The parser's own cwd from the small file it reads first; `cwdOf` throwing is the parser's failure too. */ +async function readDocumentCwd( + file: FileWithMtime, + cwdOf: (content: string) => string | null, + signal?: AbortSignal +): Promise { + const content = await settledRead( + () => wslGatedReadFile(file.path, 'utf-8', 'scan', signal), + signal + ) + if (content === null) { + return null + } + try { + return { cwd: cwdOf(content) } + } catch { + return null + } +} + +function jsonRecord(content: string): Record | null { + return asRecord(JSON.parse(content) as unknown) +} + // Pi-family header: `{"type":"session","cwd":...}`. -const SESSION_HEADER: SessionScopeCwdSource = { - kind: 'head', - cwdOf: (record) => (record.type === 'session' ? record.cwd : undefined) +const SESSION_HEADER = head((record) => (record.type === 'session' ? record.cwd : undefined)) + +// Cursor transcripts never record a cwd; the session's comes from its meta.json. +const CURSOR: SessionScopeCwdSource = { + kind: 'read', + readCwd: async ({ file }, signal) => { + const meta = await settledRead(() => readCursorChatMeta(file.path, signal), signal) + if (meta) { + return { cwd: meta.cwd } + } + return file.sidecar === 'none' && !cursorChatMetaUnavailable(file.path) ? { cwd: null } : null + } } // Keyed by every agent, so a new agent cannot list without saying how its scope is found. @@ -38,38 +103,58 @@ export const AI_VAULT_SCOPE_CWD_SOURCES: Record (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined), - // Its first record carries the base instructions (about 20 KB): one read, not two. - firstReadBytes: 64 * 1024 - }, - qoder: { kind: 'head', cwdOf: (record) => record.cwd }, + // Its first record carries the base instructions (about 20 KB): one read, not two. + codex: head( + (record) => (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined), + 64 * 1024 + ), + qoder: head((record) => record.cwd), omp: SESSION_HEADER, 'prime-agent': SESSION_HEADER, openclaw: SESSION_HEADER, - droid: { - kind: 'head', - cwdOf: (record) => (record.type === 'session_start' ? record.cwd : undefined) - }, + droid: head((record) => (record.type === 'session_start' ? record.cwd : undefined)), // Gemini transcripts carry no cwd at all. gemini: { kind: 'none' }, - // Small per-session documents, a cwd recorded mid-file, a sidecar, or a database row. + // Their parsers take the cwd from the small file discovery found, before any history. + grok: document((content) => { + const summary = jsonRecord(content) + return summary ? grokSessionCwd(summary) : null + }), + rovo: document((content) => { + const metadata = jsonRecord(content) + return metadata ? rovoSessionCwd(metadata) : null + }), + cline: document(clineManifestCwd), + kiro: document((content) => { + const metadata = listedKiroMetadata(content) + return metadata ? extractString(metadata.cwd) : null + }), + cursor: CURSOR, + // Legacy session files; database rows (`#`) are already filtered by directory in SQL. + opencode: { + kind: 'read', + readCwd: async ({ file }, signal) => + file.path.includes('#') + ? null + : readDocumentCwd( + file, + (content) => { + const record = jsonRecord(content) + return record ? openCodeSessionCwd(record) : null + }, + signal + ) + }, + // A cwd recorded mid-file, a sidecar join, a shared index, or a database row. hermes: PARSE, - cursor: PARSE, antigravity: PARSE, - rovo: PARSE, copilot: PARSE, - opencode: PARSE, opencode2: PARSE, zcode: PARSE, - grok: PARSE, devin: PARSE, - cline: PARSE, kimi: PARSE, muse: PARSE, - jcode: PARSE, - kiro: PARSE + jcode: PARSE } /** Whether a scan with these scope paths walks this agent's files past the recency cap. */ @@ -78,7 +163,7 @@ export function scopeWalksAgentFiles( scopePaths: readonly string[] | undefined ): boolean { const kind = AI_VAULT_SCOPE_CWD_SOURCES[agent].kind - return (scopePaths?.length ?? 0) > 0 && (kind === 'head' || kind === 'parse') + return (scopePaths?.length ?? 0) > 0 && (kind === 'read' || kind === 'parse') } const HEAD_READ_BYTES = 8 * 1024 @@ -94,10 +179,6 @@ export async function readTranscriptHeadCwd( source: { cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number }, signal?: AbortSignal ): Promise { - const cached = headCwdCache.get(file.path) - if (cached && cached.mtimeMs === file.mtimeMs && cached.sizeBytes === file.sizeBytes) { - return cached.cwd - } const { cwdOf } = source let cwd: string | null = null const first = source.firstReadBytes ?? HEAD_READ_BYTES @@ -121,13 +202,6 @@ export async function readTranscriptHeadCwd( break } } - if (cwd !== null) { - headCwdCache.set(file.path, { - mtimeMs: file.mtimeMs, - sizeBytes: file.sizeBytes, - cwd: internCwd(cwd) - }) - } return cwd } @@ -145,39 +219,14 @@ function firstHeadCwd( return null } -// In memory only: a restart re-reads heads, and parsed files answer from the parse cache. -const headCwdCache = new Map< - string, - { mtimeMs: number; sizeBytes: number | undefined; cwd: string } ->() -// Thousands of transcripts share a handful of cwds. -const cwdStrings = new Map() - -function internCwd(cwd: string): string { - const existing = cwdStrings.get(cwd) - if (existing !== undefined) { - return existing - } - cwdStrings.set(cwd, cwd) - return cwd -} - -/** Drops cached heads for transcripts the latest scoped scan no longer discovered. */ -export function pruneTranscriptHeadCwdCache(discoveredPaths: ReadonlySet): void { - for (const path of headCwdCache.keys()) { - if (!discoveredPaths.has(path)) { - headCwdCache.delete(path) - } - } - const liveCwds = new Set([...headCwdCache.values()].map((entry) => entry.cwd)) - for (const cwd of cwdStrings.keys()) { - if (!liveCwds.has(cwd)) { - cwdStrings.delete(cwd) +/** A read's value, or null when it failed; an abort still propagates. */ +async function settledRead(read: () => Promise, signal?: AbortSignal): Promise { + try { + return await read() + } catch (error) { + if (signal?.aborted) { + throw error } + return null } } - -export function resetTranscriptHeadCwdCacheForTests(): void { - headCwdCache.clear() - cwdStrings.clear() -} diff --git a/src/main/ai-vault/session-scope-cwds-table.ts b/src/main/ai-vault/session-scope-cwds-table.ts new file mode 100644 index 00000000000..b292e8be7ca --- /dev/null +++ b/src/main/ai-vault/session-scope-cwds-table.ts @@ -0,0 +1,126 @@ +// The scope_cwds table of the session parse cache database: a transcript +// version's cwd learned without a parse, read beside each parse row's own cwd. +import type SyncDatabase from '../sqlite/sync-database' +import { parsePersistedSidecar } from './session-sidecar-stat' +import type { SessionScopeCwdLookup, SessionScopeCwdStamp } from './session-parse-cache-store' + +// A transcript's cwd learned without a parse (its opening records, or a small +// file its parser reads it from). Versioned apart from rows, so a reader fix +// resets only these. +const SCOPE_CWDS_VERSION = '1' +const SCOPE_CWDS_SQL = ` +CREATE TABLE IF NOT EXISTS scope_cwds( + path TEXT PRIMARY KEY, + mtime_ms REAL NOT NULL, + size_bytes INTEGER, + platform TEXT NOT NULL, + sidecar TEXT, + cwd TEXT, + seen_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS scope_cwds_seen_at ON scope_cwds(seen_at); +` +// Keeps each IN list well under SQLite's bound-parameter limit. +const SCOPE_LOOKUP_CHUNK = 500 +const VALID_SESSION = + "CASE WHEN json_valid(session) THEN json_type(session) IN ('object','null') ELSE 0 END" + +export function readScopeCwdRows( + db: SyncDatabase, + paths: readonly string[], + channel: 'parsed' | 'read' +): Map { + const found = new Map() + for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_CHUNK) { + const chunk = paths.slice(start, start + SCOPE_LOOKUP_CHUNK) + const marks = chunk.map(() => '?').join(',') + for (const [table, key, cwdColumn] of [ + ['rows', 'parsed', `CASE WHEN ${VALID_SESSION} THEN json_extract(session, '$.cwd') END`], + ['scope_cwds', 'read', 'cwd'] + ] as const) { + if (key !== channel) { + continue + } + const rows = db + .prepare( + `SELECT path, mtime_ms, size_bytes, platform, sidecar, ${cwdColumn} AS cwd FROM ${table} WHERE path IN (${marks}) ${key === 'parsed' ? `AND (${VALID_SESSION})` : ''}` + ) + .all(...chunk) + for (const row of rows) { + const stamp = parseScopeCwdStamp(row) + if (stamp && typeof row.path === 'string') { + found.set(row.path, { ...found.get(row.path), [key]: stamp }) + } + } + } + } + return found +} + +export function writeScopeCwdRows( + db: SyncDatabase, + rows: Iterable<[string, SessionScopeCwdStamp]>, + seenAt: number +): void { + const insert = db.prepare( + 'INSERT OR REPLACE INTO scope_cwds(path, mtime_ms, size_bytes, platform, sidecar, cwd, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ) + db.exec('BEGIN IMMEDIATE') + try { + for (const [path, stamp] of rows) { + insert.run( + path, + stamp.mtimeMs, + stamp.sizeBytes, + stamp.platform, + stamp.sidecar === undefined ? null : JSON.stringify(stamp.sidecar), + stamp.cwd, + seenAt + ) + } + db.exec('COMMIT') + } catch (error) { + if (db.isTransaction) { + db.exec('ROLLBACK') + } + throw error + } +} + +export function ensureScopeCwdsTable(db: SyncDatabase): void { + const current = db.prepare("SELECT value FROM meta WHERE key = 'scope_cwds_version'").get() + if (current?.value !== SCOPE_CWDS_VERSION) { + db.exec('DROP TABLE IF EXISTS scope_cwds') + } + db.exec(SCOPE_CWDS_SQL) + db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('scope_cwds_version', ?)").run( + SCOPE_CWDS_VERSION + ) +} + +function parseScopeCwdStamp(row: Record): SessionScopeCwdStamp | null { + const { mtime_ms: mtimeMs, size_bytes: sizeBytes, platform, cwd } = row + if ( + typeof mtimeMs !== 'number' || + (sizeBytes !== null && typeof sizeBytes !== 'number') || + typeof platform !== 'string' || + (cwd !== null && typeof cwd !== 'string') + ) { + return null + } + let sidecar: ReturnType + try { + sidecar = + typeof row.sidecar === 'string' ? parsePersistedSidecar(JSON.parse(row.sidecar)) : undefined + } catch { + return null + } + return { + mtimeMs, + sizeBytes, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only ever compared for equality with process.platform, so an unknown value is a miss, not a misuse. + platform: platform as NodeJS.Platform, + cwd, + ...(sidecar === undefined ? {} : { sidecar }) + } +} diff --git a/src/main/ai-vault/session-sidecar-stat.ts b/src/main/ai-vault/session-sidecar-stat.ts index e176da342e2..f29ce43f695 100644 --- a/src/main/ai-vault/session-sidecar-stat.ts +++ b/src/main/ai-vault/session-sidecar-stat.ts @@ -47,3 +47,23 @@ export function sidecarUnchanged( entry.sizeBytes === observed.sizeBytes ) } + +// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has +// no such field. Absent (or unreadable) means unknown, which costs one re-parse +// of the rows that have a sibling and nothing at all for the rest. +export function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined { + if (value === 'none' || value === 'unknown') { + return value + } + if (!isRecord(value)) { + return undefined + } + const { path, mtimeMs, sizeBytes } = value + return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number' + ? { path, mtimeMs, sizeBytes } + : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/shared/wsl-move/wsl-move-storage-dispositions.ts b/src/shared/wsl-move/wsl-move-storage-dispositions.ts index 8f21b027969..1311884c0b2 100644 --- a/src/shared/wsl-move/wsl-move-storage-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-storage-dispositions.ts @@ -98,6 +98,7 @@ export const SQLITE_SCHEMA_FILE_DATABASES: Record = { 'src/main/ai-vault-search/session-search-query-schema.ts': 'ai-vault-search', 'src/main/ai-vault-search/session-search-schema.ts': 'ai-vault-search', 'src/main/ai-vault/session-parse-cache-database.ts': 'ai-vault-parse-cache', + 'src/main/ai-vault/session-scope-cwds-table.ts': 'ai-vault-parse-cache', 'src/main/browser/browser-cookie-staged-import.ts': 'browser-cookie-import', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts': 'agent-session-journal', @@ -219,7 +220,7 @@ export const SQLITE_TABLE_DISPOSITIONS: Record ), ...tables( 'ai-vault-parse-cache', - ['meta', 'rows'], + ['meta', 'rows', 'scope_cwds'], notApplicableDisposition(57, 'Session History parse cache; rebuilt from transcripts.') ), ...tables(