Skip to content
Open
59 changes: 58 additions & 1 deletion src/server/codexAppServerBridge.archive.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { callRpcWithArchiveRecovery } from './codexAppServerBridge'
import {
callRpcWithArchiveRecovery,
canonicalizeThreadListResponseForRead,
canonicalizeWorkspaceRootsStateForRead,
} from './codexAppServerBridge'

describe('callRpcWithArchiveRecovery', () => {
it('sets a fallback name and retries archive when Codex has not materialized a rollout', async () => {
Expand Down Expand Up @@ -75,3 +79,56 @@ describe('callRpcWithArchiveRecovery', () => {
await expect(callRpcWithArchiveRecovery(appServer, 'thread/read', { threadId: 'test-thread' })).rejects.toThrow('network failed')
})
})

describe('canonicalizeWorkspaceRootsStateForRead', () => {
it('realpaths existing local roots so symlink cwd sessions remain visible', async () => {
const state = await canonicalizeWorkspaceRootsStateForRead({
order: ['/workspace-link/projects/demo', 'remote-project-id'],
labels: {
'/workspace-link/projects/demo': 'Demo',
},
active: ['/workspace-link/projects/demo'],
projectOrder: ['remote-project-id', '/workspace-link/projects/demo'],
remoteProjects: [{
id: 'remote-project-id',
hostId: 'remote-ssh-discovered:host',
remotePath: '/remote/projects/demo',
label: 'remote-demo',
}],
}, async (value) => value.replace('/workspace-link/', '/storage/'))

expect(state.order).toEqual([
'/storage/projects/demo',
'remote-project-id',
])
expect(state.active).toEqual(['/storage/projects/demo'])
expect(state.projectOrder).toEqual([
'remote-project-id',
'/storage/projects/demo',
])
expect(state.labels['/storage/projects/demo']).toBe('Demo')
expect(state.remoteProjects[0]?.id).toBe('remote-project-id')
})
})

describe('canonicalizeThreadListResponseForRead', () => {
it('realpaths thread cwd values to match canonicalized workspace roots', async () => {
const payload = await canonicalizeThreadListResponseForRead({
data: [
{ id: 'symlink-cwd-thread', cwd: '/workspace-link/projects/demo' },
{ id: 'canonical-cwd-thread', cwd: '/storage/projects/demo' },
{ id: 'remote-thread', cwd: 'remote-project-id' },
],
nextCursor: null,
}, async (value) => value.replace('/workspace-link/', '/storage/'))

expect(payload).toEqual({
data: [
{ id: 'symlink-cwd-thread', cwd: '/storage/projects/demo' },
{ id: 'canonical-cwd-thread', cwd: '/storage/projects/demo' },
{ id: 'remote-thread', cwd: 'remote-project-id' },
],
nextCursor: null,
})
})
})
81 changes: 76 additions & 5 deletions src/server/codexAppServerBridge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { mkdtemp, readFile, readdir, rename, rm, mkdir, stat, cp, lstat, readlink, symlink } from 'node:fs/promises'
import { mkdtemp, readFile, readdir, rename, rm, mkdir, stat, cp, lstat, readlink, symlink, realpath } from 'node:fs/promises'
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { request as httpRequest } from 'node:http'
Expand Down Expand Up @@ -76,7 +76,7 @@ type ServerRequestReply = {
}
}

type WorkspaceRootsState = {
export type WorkspaceRootsState = {
order: string[]
labels: Record<string, string>
active: string[]
Expand Down Expand Up @@ -1228,7 +1228,10 @@ export async function callRpcWithArchiveRecovery(
params: unknown,
): Promise<unknown> {
try {
return await appServer.rpc(method, params ?? null)
const result = await appServer.rpc(method, params ?? null)
return method === 'thread/list'
? await canonicalizeThreadListResponseForRead(result)
: result
} catch (error) {
if (method !== 'thread/archive') {
throw error
Expand Down Expand Up @@ -3869,6 +3872,74 @@ async function readMergedThreadTitleCache(): Promise<ThreadTitleCache> {
return mergeThreadTitleCaches(persistedCache, sessionIndexCache)
}

type PathRealpathResolver = (path: string) => Promise<string>

async function canonicalizeWorkspaceRootPath(
value: string,
pathRealpath: PathRealpathResolver,
): Promise<string> {
if (!isAbsolute(value)) return value
try {
return await pathRealpath(value)
} catch {
return value
}
}

async function canonicalizeWorkspaceRootPathList(
values: string[],
pathRealpath: PathRealpathResolver,
): Promise<string[]> {
return normalizeStringArray(await Promise.all(values.map((value) => canonicalizeWorkspaceRootPath(value, pathRealpath))))
}

export async function canonicalizeWorkspaceRootsStateForRead(
state: WorkspaceRootsState,
pathRealpath: PathRealpathResolver = realpath,
): Promise<WorkspaceRootsState> {
const [order, active, projectOrder] = await Promise.all([
canonicalizeWorkspaceRootPathList(state.order, pathRealpath),
canonicalizeWorkspaceRootPathList(state.active, pathRealpath),
canonicalizeWorkspaceRootPathList(state.projectOrder, pathRealpath),
])
const labels: Record<string, string> = { ...state.labels }
await Promise.all(Object.entries(state.labels).map(async ([key, label]) => {
const canonicalKey = await canonicalizeWorkspaceRootPath(key, pathRealpath)
labels[canonicalKey] = label
}))
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated

return {
order,
labels,
active,
projectOrder,
remoteProjects: state.remoteProjects.map((project) => ({ ...project })),
}
}

async function canonicalizeThreadCwdRecord(
value: unknown,
pathRealpath: PathRealpathResolver = realpath,
): Promise<unknown> {
const record = asRecord(value)
const cwd = typeof record?.cwd === 'string' ? record.cwd : ''
if (!record || !cwd) return value
const canonicalCwd = await canonicalizeWorkspaceRootPath(cwd, pathRealpath)
return canonicalCwd === cwd ? value : { ...record, cwd: canonicalCwd }
}

export async function canonicalizeThreadListResponseForRead(
payload: unknown,
pathRealpath: PathRealpathResolver = realpath,
): Promise<unknown> {
const record = asRecord(payload)
if (!record || !Array.isArray(record.data)) return payload
return {
...record,
data: await Promise.all(record.data.map((item) => canonicalizeThreadCwdRecord(item, pathRealpath))),
}
}

async function readWorkspaceRootsState(): Promise<WorkspaceRootsState> {
const statePath = getCodexGlobalStatePath()
let payload: Record<string, unknown> = {}
Expand All @@ -3881,13 +3952,13 @@ async function readWorkspaceRootsState(): Promise<WorkspaceRootsState> {
payload = {}
}

return {
return await canonicalizeWorkspaceRootsStateForRead({
order: normalizeStringArray(payload['electron-saved-workspace-roots']),
labels: normalizeStringRecord(payload['electron-workspace-root-labels']),
active: normalizeStringArray(payload['active-workspace-roots']),
projectOrder: normalizeStringArray(payload['project-order']),
remoteProjects: normalizeRemoteProjects(payload['remote-projects']),
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise<void> {
Expand Down
30 changes: 30 additions & 0 deletions tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,36 @@ This file tracks manual regression and feature verification steps.

---

### Sidebar sessions survive symlinked workspace roots

#### Feature/Change Name
Workspace roots and thread-list cwd values are canonicalized through local `realpath` before the sidebar filters thread projects, so sessions remain visible whether they were recorded through a symlink path or its target.

#### Prerequisites/Setup
1. Dev server running (`pnpm run dev`)
2. A workspace root registered through a symlink path, for example `/workspace-link/projects/demo`
3. At least one session recorded with the canonical cwd, for example `/storage/projects/demo`
4. Light theme and dark theme both available from the appearance switcher

#### Steps
1. In light theme, open the app and wait for the sidebar thread list to load.
2. Confirm a session recorded under the canonical cwd appears in the sidebar.
3. Confirm a session recorded under the symlink cwd also appears in the sidebar.
4. Search for both known session titles and confirm both rows remain findable.
5. Fetch `/codex-api/workspace-roots-state` and confirm local symlink roots are returned as their canonical real paths.
6. Switch to dark theme and repeat steps 1-4.

#### Expected Results
- A registered symlink root and a session cwd pointing at the symlink target are treated as the same project.
- Sessions recorded through either path form are not filtered out as unregistered workspace roots.
- Search and sidebar browsing both expose the session.
- Rows remain readable in light and dark themes.

#### Rollback/Cleanup
- None.

---

### Pinned threads remain visible during background pagination

#### Feature/Change Name
Expand Down