Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion src/main/ai-vault/cached-session-list.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult } from '../../shared/ai-vault-types'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'

const {
filterPathsToRunningWslDistrosAsync,
Expand Down Expand Up @@ -125,3 +128,68 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled()
})
})

describe('listAiVaultSessions symlinked scope', () => {
let root: string
beforeEach(async () => {
resetAiVaultSessionListCacheForTests()
scanAiVaultSessionsInService.mockReset()
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-cached-scope-')))
})
afterEach(async () => {
resetAiVaultSessionListCacheForTests()
await rm(root, { recursive: true, force: true })
})

it('scans the real path too and keeps real-path sessions in a symlinked workspace', async () => {
const realRepo = join(root, 'data', 'api')
const linkedRepo = join(root, 'work-api')
await mkdir(realRepo, { recursive: true })
await symlink(realRepo, linkedRepo, 'junction')
const recorded = {
id: 'codex:s1',
executionHostId: 'local',
agent: 'codex',
sessionId: 's1',
title: 's1',
cwd: realRepo,
branch: null,
model: null,
filePath: join(root, 's1.jsonl'),
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-10-20T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: 's1',
subagent: null
} satisfies AiVaultSession
const outOfScope = (id: string): AiVaultSession => ({
...recorded,
id: `codex:${id}`,
sessionId: id,
cwd: join(root, 'elsewhere'),
modifiedAt: '2026-10-21T00:00:00.000Z'
})
scanAiVaultSessionsInService.mockResolvedValueOnce({
...scanResult('scan-A'),
sessions: [outOfScope('newer-1'), outOfScope('newer-2'), recorded]
})

const listed = await listAiVaultSessions({ limit: 1, scopePaths: [linkedRepo] })

expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({
scopePaths: [linkedRepo, realRepo]
})
expect(listed.sessions.find((entry) => entry.id === 'codex:s1')?.cwdAliases).toEqual([
linkedRepo
])
// A cache hit at depth 1 keeps one out-of-scope row; the aliased row must count as in scope.
const cached = await listAiVaultSessions({ limit: 1, scopePaths: [linkedRepo] })
expect(cached.sessions.map((entry) => entry.id)).toEqual(['codex:newer-1', 'codex:s1'])
})
})
9 changes: 7 additions & 2 deletions src/main/ai-vault/cached-session-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import type { AiVaultScanOptions } from './session-scanner-types'
import { prepareOpenCodeWslReaders } from './opencode-wsl-runtime-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
import {
attachAiVaultScopeCwdAliases,
resolveAiVaultScopeRealPaths
} from './session-scope-real-paths'
import {
startLocalAiVaultScanProgress,
type LocalAiVaultScanProgressHandle
Expand Down Expand Up @@ -143,18 +147,19 @@ export async function listAiVaultSessions(
if (!scanProgress) {
throw new Error('Local scan progress was not created.')
}
const scope = await resolveAiVaultScopeRealPaths(args?.scopePaths ?? [])
const scanned = await scanAiVaultSessionsInBackground(
{
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths: args?.scopePaths,
scopePaths: args?.scopePaths ? scope.scanScopePaths : undefined,
...(await localAiVaultScanRoots())
},
scanSignal,
scanProgress
)
const result = { ...scanned, scanId }
const result = { ...attachAiVaultScopeCwdAliases(scanned, scope), scanId }
// A delete (or other invalidation) landed while this scan was running:
// its result predates the delete, so caching it would resurrect the
// deleted session for the TTL. Return it to this caller but don't cache.
Expand Down
16 changes: 16 additions & 0 deletions src/main/ai-vault/session-list-result-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import { describe, expect, it } from 'vitest'
import { parseAiVaultListResult } from './session-list-result-validation'

describe('parseAiVaultListResult', () => {
it('keeps host cwd aliases, and drops only malformed ones', () => {
const parsed = parseAiVaultListResult({
sessions: [
{ ...validSession('aliased'), cwdAliases: ['/home/me/work/api'] },
{ ...validSession('malformed'), cwdAliases: [42] }
],
issues: [],
scannedAt: '2026-07-27T00:00:00.000Z'
})

expect(parsed.sessions.map((entry) => [entry.sessionId, entry.cwdAliases])).toEqual([
['aliased', ['/home/me/work/api']],
['malformed', undefined]
])
})

it('keeps valid sessions when another wire entry is malformed', () => {
const parsed = parseAiVaultListResult({
sessions: [validSession(), { id: 42 }],
Expand Down
7 changes: 7 additions & 0 deletions src/main/ai-vault/session-list-result-validation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod'
import {
AI_VAULT_AGENTS,
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
type AiVaultAgent,
type AiVaultListResult,
type AiVaultScanIssue,
Expand Down Expand Up @@ -51,6 +52,12 @@ const aiVaultSessionSchema = z.object({
sessionId: z.string(),
title: z.string(),
cwd: z.string().nullable(),
// Why catch: a malformed alias list must cost only the aliases, never the whole row.
cwdAliases: z
.array(z.string().min(1).max(4096))
.max(AI_VAULT_SCOPE_PATHS_MAX_COUNT)
.optional()
.catch(undefined),
branch: z.string().nullable(),
model: z.string().nullable(),
filePath: z.string(),
Expand Down
121 changes: 121 additions & 0 deletions src/main/ai-vault/session-scope-real-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import {
attachAiVaultScopeCwdAliases,
resolveAiVaultScopeRealPaths
} from './session-scope-real-paths'

let root: string
let realRepo: string
let linkedRepo: string

// Junctions need no Windows symlink privilege; elsewhere the type is ignored.
const linkDir = (target: string, path: string): Promise<void> => symlink(target, path, 'junction')

beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-scope-real-paths-')))
realRepo = join(root, 'data', 'api')
linkedRepo = join(root, 'work-api')
await mkdir(join(realRepo, 'pkg'), { recursive: true })
await linkDir(realRepo, linkedRepo)
})

afterEach(async () => {
await rm(root, { recursive: true, force: true })
})

function session(id: string, cwd: string | null, filePath = `/sessions/${id}.jsonl`) {
return {
id,
executionHostId: 'local',
agent: 'codex',
sessionId: id,
title: id,
cwd,
branch: null,
model: null,
filePath,
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-10-20T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: id,
subagent: null
} satisfies AiVaultSession
}

function result(sessions: AiVaultSession[]): AiVaultListResult {
return { sessions, issues: [], scannedAt: '2026-10-20T00:00:00.000Z' }
}

describe('resolveAiVaultScopeRealPaths', () => {
it('adds the real path of a symlinked scope so the scan admits sessions recorded there', async () => {
const scope = await resolveAiVaultScopeRealPaths([linkedRepo, realRepo, join(root, 'gone')])

expect(scope.scanScopePaths).toEqual([linkedRepo, realRepo, join(root, 'gone')])
expect(scope.links).toEqual([{ scopePath: linkedRepo, realPath: realRepo }])
})

it('skips paths this host does not have and paths that are not links', async () => {
const scope = await resolveAiVaultScopeRealPaths([realRepo, join(root, 'other-host-path')])

expect(scope.links).toEqual([])
expect(scope.scanScopePaths).toEqual([realRepo, join(root, 'other-host-path')])
})
})

describe('attachAiVaultScopeCwdAliases', () => {
it('aliases cwds under the real path to the registered spelling and keeps cwd for resume', async () => {
const scope = await resolveAiVaultScopeRealPaths([linkedRepo])
const listed = attachAiVaultScopeCwdAliases(
result([session('real', realRepo), session('nested', join(realRepo, 'pkg'))]),
scope
)

expect(listed.sessions.map((entry) => [entry.cwd, entry.cwdAliases])).toEqual([
[realRepo, [linkedRepo]],
[join(realRepo, 'pkg'), [join(linkedRepo, 'pkg')]]
])
})

it('never resolves a recorded cwd, so a cwd spelled through another link stays unmatched', async () => {
const otherLink = join(root, 'other-link')
await linkDir(realRepo, otherLink)
const rows = [session('other-link', join(otherLink, 'pkg'))]

const listed = attachAiVaultScopeCwdAliases(
result(rows),
await resolveAiVaultScopeRealPaths([linkedRepo])
)

expect(listed.sessions).toEqual(rows)
})

it('leaves unrelated, cwd-less and WSL rows untouched', async () => {
const scope = await resolveAiVaultScopeRealPaths([linkedRepo])
const rows = [
session('unrelated', join(root, 'elsewhere')),
session('no-cwd', null),
session('wsl', '/data/api', '\\\\wsl.localhost\\Ubuntu\\home\\me\\.codex\\s.jsonl')
]
const listed = attachAiVaultScopeCwdAliases(result(rows), scope)

expect(listed.sessions).toEqual(rows)
})

it('returns the scan result unchanged when no scope path is a link', async () => {
const scanned = result([session('real', realRepo)])

expect(
attachAiVaultScopeCwdAliases(scanned, await resolveAiVaultScopeRealPaths([realRepo]))
).toBe(scanned)
})
})
100 changes: 100 additions & 0 deletions src/main/ai-vault/session-scope-real-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { realpath } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import {
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
import { sessionFileExecutionNamespace } from '../../shared/ai-vault-scope-cwd-match'
import { parseWslUncPath } from '../../shared/wsl-paths'

/** A scope path registered through a symlink, and where it really points on this host. */
type AiVaultScopeLink = { scopePath: string; realPath: string }

export type AiVaultScopeRealPaths = {
/** The registered paths plus their real paths, so the scan admits sessions recorded under either. */
scanScopePaths: readonly string[]
links: readonly AiVaultScopeLink[]
}

/**
* Why here: this runs on the host that owns the files (the desktop for local
* scans, orcad for SSH and server hosts), so a symlink resolves against the
* disk the agents actually wrote their cwd on.
*/
export async function resolveAiVaultScopeRealPaths(
scopePaths: readonly string[]
): Promise<AiVaultScopeRealPaths> {
const links: AiVaultScopeLink[] = []
for (const scopePath of scopePaths) {
// Why skip WSL UNC paths: Windows cannot resolve a Linux symlink through the share.
if (!isAbsolute(scopePath) || parseWslUncPath(scopePath)) {
continue
}
// A path missing on this host belongs to another host's workspace; it stays unmatched.
const realPath = await realpath(scopePath).catch(() => null)
if (realPath && !sameComparisonPath(realPath, scopePath)) {
links.push({ scopePath, realPath })
}
}
const scanScopePaths = [...scopePaths]
for (const { realPath } of links) {
if (!scanScopePaths.some((path) => sameComparisonPath(path, realPath))) {
scanScopePaths.push(realPath)
}
}
return { scanScopePaths, links }
}

/**
* Gives each session recorded under a linked scope's real path the registered
* spelling as a matching-only alias. Resume keeps reading the recorded `cwd`.
* Why no realpath per cwd: a listing must not touch arbitrary cwds, which can sit
* on hung network mounts or behind macOS privacy prompts.
*/
export function attachAiVaultScopeCwdAliases(
result: AiVaultListResult,
scope: AiVaultScopeRealPaths
): AiVaultListResult {
if (scope.links.length === 0) {
return result
}
const aliasesByCwd = new Map<string, string[]>()
let changed = false
const sessions: AiVaultSession[] = []
for (const session of result.sessions) {
const cwd = session.cwd
// Why native only: WSL rows carry Linux cwds this Windows host cannot resolve.
if (!cwd || !isAbsolute(cwd) || sessionFileExecutionNamespace(session.filePath) !== 'native') {
sessions.push(session)
continue
}
let aliases = aliasesByCwd.get(cwd)
if (!aliases) {
aliases = scopeAliasesFor(cwd, scope.links)
aliasesByCwd.set(cwd, aliases)
}
if (aliases.length === 0) {
sessions.push(session)
continue
}
changed = true
sessions.push({ ...session, cwdAliases: aliases })
}
return changed ? { ...result, sessions } : result
}

function scopeAliasesFor(cwd: string, links: readonly AiVaultScopeLink[]): string[] {
const aliases: string[] = []
for (const { scopePath, realPath } of links) {
const suffix = relativePathInsideRoot(realPath, cwd)
if (suffix !== null) {
aliases.push(suffix ? join(scopePath, suffix) : scopePath)
}
}
return aliases
}

function sameComparisonPath(left: string, right: string): boolean {
return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)
}
Loading
Loading