diff --git a/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts b/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts new file mode 100644 index 00000000000..fec516d4e3c --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../shared/execution-host' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { + evictRemoteSessionParseCache, + readCachedRemoteSessionParse, + REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST, + resetRemoteSessionParseCacheForTests, + storeRemoteSessionParse +} from './remote-session-scanner-parse-cache' +import { + codexTranscript, + jsonLines, + MemoryRemoteProvider +} from './remote-session-scanner-test-provider' +import type { FileWithMtime } from './session-scanner-types' + +const CLAUDE_ONE = '/home/ada/.claude/projects/repo/one.jsonl' +const CLAUDE_TWO = '/home/ada/.claude/projects/repo/two.jsonl' +const CODEX_A = '/home/ada/.codex/sessions/codex-a.jsonl' + +function scan(provider: MemoryRemoteProvider, executionHostId: ExecutionHostId = 'ssh:dev-box') { + return scanRemoteAiVaultSessions({ + provider, + executionHostId, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) +} + +function claudeTranscript(args: { sessionId: string; title: string; messages?: number }): string { + const records: unknown[] = [ + { + sessionId: args.sessionId, + timestamp: '2026-07-04T04:00:00.000Z', + type: 'user', + cwd: '/home/ada/repo', + message: { content: [{ type: 'text', text: args.title }] } + } + ] + for (let index = 1; index < (args.messages ?? 2); index++) { + records.push({ + sessionId: args.sessionId, + timestamp: `2026-07-04T04:00:0${index}.000Z`, + type: 'assistant', + message: { model: 'claude-opus-4', content: `Answer ${index}` } + }) + } + return jsonLines(records) +} + +function fileAt(path: string, mtimeMs = 1): FileWithMtime { + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString(), sizeBytes: 1 } +} + +describe('remote session parse cache', () => { + beforeEach(() => { + resetRemoteSessionParseCacheForTests() + }) + + it('reads every body once, then rescans unchanged files with zero reads', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + provider.addFile(CLAUDE_TWO, claudeTranscript({ sessionId: 'two', title: 'Second' }), 20) + provider.addFile( + CODEX_A, + codexTranscript({ + sessionId: 'codex-a', + title: 'Codex session', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T05:00:00.000Z' + }), + 30 + ) + + const first = await scan(provider) + expect(first.issues).toEqual([]) + expect(first.sessions).toHaveLength(3) + const firstReads = new Set(provider.readFileCalls) + expect(firstReads).toContain(CLAUDE_ONE) + expect(firstReads).toContain(CLAUDE_TWO) + expect(firstReads).toContain(CODEX_A) + + provider.clearReadFileCalls() + const second = await scan(provider) + expect(provider.readFileCalls).toEqual([]) + expect(second.sessions).toEqual(first.sessions) + expect(second.issues).toEqual([]) + }) + + it('re-reads exactly the file whose mtime changed', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + provider.addFile(CLAUDE_TWO, claudeTranscript({ sessionId: 'two', title: 'Second' }), 20) + await scan(provider) + + provider.addFile( + CLAUDE_TWO, + claudeTranscript({ sessionId: 'two', title: 'Second', messages: 3 }), + 25 + ) + provider.clearReadFileCalls() + const result = await scan(provider) + + expect(provider.readFileCalls).toEqual([CLAUDE_TWO]) + expect(result.sessions.find((session) => session.sessionId === 'two')?.messageCount).toBe(3) + }) + + it('re-reads a file whose size changed even when the mtime did not', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + await scan(provider) + + provider.addFile( + CLAUDE_ONE, + claudeTranscript({ sessionId: 'one', title: 'First', messages: 4 }), + 10 + ) + provider.clearReadFileCalls() + const result = await scan(provider) + + expect(provider.readFileCalls).toEqual([CLAUDE_ONE]) + expect(result.sessions[0]?.messageCount).toBe(4) + }) + + it('never serves entries cached for one host to another host', async () => { + const content = claudeTranscript({ sessionId: 'shared', title: 'Same path everywhere' }) + const providerA = new MemoryRemoteProvider() + providerA.addFile(CLAUDE_ONE, content, 10) + await scan(providerA, 'ssh:host-a') + + const providerB = new MemoryRemoteProvider() + providerB.addFile(CLAUDE_ONE, content, 10) + const result = await scan(providerB, 'ssh:host-b') + + expect(providerB.readFileCalls).toContain(CLAUDE_ONE) + expect(result.sessions[0]?.executionHostId).toBe('ssh:host-b') + }) + + it('re-reads bodies after the host cache is evicted', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + await scan(provider) + + evictRemoteSessionParseCache('ssh:dev-box') + provider.clearReadFileCalls() + await scan(provider) + + expect(provider.readFileCalls).toEqual([CLAUDE_ONE]) + }) + + it('bounds entries per host, evicting least recently used first', () => { + storeRemoteSessionParse('ssh:host-b', fileAt('/other-host'), null) + for (let index = 0; index < REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST; index++) { + storeRemoteSessionParse('ssh:host-a', fileAt(`/f${index}`), null) + } + + // Reading /f0 refreshes its recency, so the overflow evicts /f1 instead. + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f0'))).not.toBeNull() + storeRemoteSessionParse('ssh:host-a', fileAt('/overflow'), null) + + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f1'))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f0'))).not.toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/overflow'))).not.toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-b', fileAt('/other-host'))).not.toBeNull() + }) + + it('misses when the stat changed and when the host was never scanned', () => { + storeRemoteSessionParse('ssh:host-a', fileAt('/f', 1), null) + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f', 2))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-c', fileAt('/f', 1))).toBeNull() + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-parse-cache.ts b/src/main/ai-vault/remote-session-scanner-parse-cache.ts new file mode 100644 index 00000000000..e8e400fdd72 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-parse-cache.ts @@ -0,0 +1,80 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileWithMtime } from './session-scanner-types' + +// Sized past the remote recency cap (1000) plus the in-scope parse cap (2000) +// so a full steady-state result set stays resident between rescans; matches +// the local session-scanner-parse-cache bound. +export const REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST = 4096 + +type RemoteSessionParseCacheEntry = { + mtimeMs: number + sizeBytes: number | null + session: AiVaultSession | null +} + +export type RemoteSessionParseCacheHit = { + session: AiVaultSession | null +} + +// Keyed by execution host so identical remote paths on different hosts can +// never serve each other's sessions. Reconnects keep entries (paths and +// mtimes stay valid); target removal evicts via evictRemoteSessionParseCache. +const cacheByHost = new Map>() + +/** + * Return the parse result cached for this remote file when its (mtime, size) + * stat is unchanged, refreshing its LRU recency. A hit means the rescan skips + * the SSH body transfer and re-parse entirely; `null` means read + parse. + */ +export function readCachedRemoteSessionParse( + executionHostId: ExecutionHostId, + file: FileWithMtime +): RemoteSessionParseCacheHit | null { + const hostCache = cacheByHost.get(executionHostId) + const entry = hostCache?.get(file.path) + if (!hostCache || !entry) { + return null + } + const unchanged = + entry.mtimeMs === file.mtimeMs && + (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) + if (!unchanged) { + return null + } + hostCache.delete(file.path) + hostCache.set(file.path, entry) + return { session: entry.session } +} + +export function storeRemoteSessionParse( + executionHostId: ExecutionHostId, + file: FileWithMtime, + session: AiVaultSession | null +): void { + let hostCache = cacheByHost.get(executionHostId) + if (!hostCache) { + hostCache = new Map() + cacheByHost.set(executionHostId, hostCache) + } + hostCache.delete(file.path) + hostCache.set(file.path, { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + session + }) + if (hostCache.size > REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST) { + const oldest = hostCache.keys().next() + if (!oldest.done) { + hostCache.delete(oldest.value) + } + } +} + +export function evictRemoteSessionParseCache(executionHostId: ExecutionHostId): void { + cacheByHost.delete(executionHostId) +} + +export function resetRemoteSessionParseCacheForTests(): void { + cacheByHost.clear() +} diff --git a/src/main/ai-vault/remote-session-scanner-test-provider.ts b/src/main/ai-vault/remote-session-scanner-test-provider.ts new file mode 100644 index 00000000000..08adb5c5e5b --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-test-provider.ts @@ -0,0 +1,119 @@ +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' + +/** + * In-memory remote filesystem for remote-session-scanner tests. Records every + * readFile call (each one models a full SSH body transfer) so parse-cache + * tests can assert exactly which bodies a rescan re-reads. + */ +export class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + readonly readFileCalls: string[] = [] + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + clearReadFileCalls(): void { + this.readFileCalls.length = 0 + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const normalized = normalize(filePath) + this.readFileCalls.push(normalized) + const file = this.files.get(normalized) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +export function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string + threadSource?: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { + id: args.sessionId, + cwd: args.cwd, + ...(args.threadSource ? { thread_source: args.threadSource } : {}) + } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 16173f7b736..ccfe61046df 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -1,86 +1,18 @@ -import { describe, expect, it } from 'vitest' -import type { DirEntry } from '../../shared/types' -import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' +import { beforeEach, describe, expect, it } from 'vitest' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' - -class MemoryRemoteProvider implements IFilesystemProvider { - private readonly files = new Map() - - addFile(path: string, content: string, mtimeMs: number): void { - this.files.set(normalize(path), { content, mtimeMs }) - } - - async readDir(dirPath: string): Promise { - const dir = normalize(dirPath) - const prefix = dir.endsWith('/') ? dir : `${dir}/` - const entries = new Map() - for (const path of this.files.keys()) { - if (!path.startsWith(prefix)) { - continue - } - const relative = path.slice(prefix.length) - if (!relative) { - continue - } - const [name, ...rest] = relative.split('/') - if (!name) { - continue - } - entries.set(name, { - name, - isDirectory: rest.length > 0, - isSymlink: false - }) - } - return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) - } - - async readFile(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { content: file.content, isBinary: false } - } - - async stat(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } - } - - writeFile = unsupported - writeFileBase64 = unsupported - writeFileBase64Chunk = unsupported - deletePath = unsupported - createFile = unsupported - createDir = unsupported - createDirNoClobber = unsupported - rename = unsupported - renameNoClobber = unsupported - copy = unsupported - realpath = async (path: string): Promise => path - search = unsupported - listFiles = unsupported - watch = unsupported -} - -async function unsupported(): Promise { - throw new Error('unsupported') -} - -function normalize(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} +import { resetRemoteSessionParseCacheForTests } from './remote-session-scanner-parse-cache' +import { + codexTranscript, + jsonLines, + MemoryRemoteProvider +} from './remote-session-scanner-test-provider' describe('scanRemoteAiVaultSessions', () => { + beforeEach(() => { + resetRemoteSessionParseCacheForTests() + }) + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -313,32 +245,3 @@ describe('scanRemoteAiVaultSessions', () => { ]) }) }) - -function codexTranscript(args: { - sessionId: string - title: string - cwd: string - timestamp: string - threadSource?: string -}): string { - return jsonLines([ - { - timestamp: args.timestamp, - type: 'session_meta', - payload: { - id: args.sessionId, - cwd: args.cwd, - ...(args.threadSource ? { thread_source: args.threadSource } : {}) - } - }, - { - timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: args.title }] - } - } - ]) -} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 1b7d66cb7b0..da63a62c810 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -13,6 +13,10 @@ import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { + readCachedRemoteSessionParse, + storeRemoteSessionParse +} from './remote-session-scanner-parse-cache' import { remoteSessionSources } from './remote-session-scanner-sources' import type { RemoteScannerContext, @@ -194,13 +198,25 @@ async function parseRemoteSessionCandidate( context: RemoteScannerContext, issues: AiVaultScanIssue[] ): Promise { + // A (mtime,size)-unchanged file skips the SSH body transfer and re-parse + // entirely; rescans previously re-read every candidate in full. Like the + // local cache, an unchanged Codex transcript keeps its cached index title. + const cached = readCachedRemoteSessionParse(context.executionHostId, candidate.file) + if (cached) { + return cached.session + } try { const read = await context.provider.readFile(candidate.file.path) if (read.isBinary) { + storeRemoteSessionParse(context.executionHostId, candidate.file, null) return null } - return candidate.source.parse(candidate.file, read.content, context) + const session = await candidate.source.parse(candidate.file, read.content, context) + storeRemoteSessionParse(context.executionHostId, candidate.file, session) + return session } catch (err) { + // Failures stay uncached so the issue resurfaces on every scan and a + // transient read error retries next time. issues.push({ executionHostId: context.executionHostId, agent: candidate.source.agent, @@ -247,7 +263,12 @@ async function statRemoteFile( try { const stat = await provider.stat(path) const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + return { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: Number.isFinite(stat.size) ? stat.size : undefined + } } catch (err) { issues.push({ executionHostId, agent, path, message: errorMessage(err) }) return null diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ff676112218..eb20c19f27b 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -16,7 +16,8 @@ import type { SshConnectionState } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' +import { isRuntimeOwnedSshTargetId, toSshExecutionHostId } from '../../shared/execution-host' +import { evictRemoteSessionParseCache } from '../ai-vault/remote-session-scanner-parse-cache' import { isAuthError } from '../ssh/ssh-connection-utils' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider' @@ -115,6 +116,9 @@ export async function removeRegisteredSshTarget(targetId: string): Promise ) } persistedStore?.removeSshRemotePtyLeases(targetId) + // Why: reconnects keep cached session parses (paths/mtimes stay valid), but + // a removed target must not pin its parsed transcripts in main-process memory. + evictRemoteSessionParseCache(toSshExecutionHostId(targetId)) sshStore.removeTarget(targetId) }