diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 3e931a0f8..84b2c45a9 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { callRpcWithArchiveRecovery, + canonicalizeThreadListResponseForRead, + canonicalizeWorkspaceRootsStateForRead, hasUsableCodexAuth, isEmptyThreadReadError, isUnauthenticatedRateLimitError, @@ -94,6 +96,94 @@ describe('callRpcWithArchiveRecovery', () => { }) }) +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: { + '/storage/projects/demo': 'Canonical Demo', + '/workspace-link/projects/demo': 'Symlink Demo', + 'remote-project-id': 'Remote 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).toEqual({ + '/storage/projects/demo': 'Canonical Demo', + 'remote-project-id': 'Remote 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, + }) + }) + + it('reuses cwd realpath results within one thread list response', async () => { + const calls: string[] = [] + const payload = await canonicalizeThreadListResponseForRead({ + data: [ + { id: 'first-symlink-thread', cwd: '/workspace-link/projects/demo' }, + { id: 'second-symlink-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) => { + calls.push(value) + return value.replace('/workspace-link/', '/storage/') + }) + + expect(payload).toEqual({ + data: [ + { id: 'first-symlink-thread', cwd: '/storage/projects/demo' }, + { id: 'second-symlink-thread', cwd: '/storage/projects/demo' }, + { id: 'canonical-cwd-thread', cwd: '/storage/projects/demo' }, + { id: 'remote-thread', cwd: 'remote-project-id' }, + ], + nextCursor: null, + }) + expect(calls).toEqual([ + '/workspace-link/projects/demo', + '/storage/projects/demo', + ]) + }) +}) + describe('isUnauthenticatedRateLimitError', () => { it('matches unauthenticated rate-limit failures from a fresh Codex home', () => { expect(isUnauthenticatedRateLimitError(new Error('codex account authentication required to read rate limits'))).toBe(true) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 3372e347d..389685d4e 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -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' @@ -79,7 +79,7 @@ type ServerRequestReply = { } } -type WorkspaceRootsState = { +export type WorkspaceRootsState = { order: string[] labels: Record active: string[] @@ -1356,7 +1356,10 @@ export async function callRpcWithArchiveRecovery( params: unknown, ): Promise { 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 @@ -4272,6 +4275,101 @@ async function readMergedThreadTitleCache(): Promise { return mergeThreadTitleCaches(persistedCache, sessionIndexCache) } +type PathRealpathResolver = (path: string) => Promise + +async function canonicalizeWorkspaceRootPath( + value: string, + pathRealpath: PathRealpathResolver, +): Promise { + if (!isAbsolute(value)) return value + try { + return await pathRealpath(value) + } catch { + return value + } +} + +async function canonicalizeWorkspaceRootPathList( + values: string[], + pathRealpath: PathRealpathResolver, +): Promise { + return normalizeStringArray(await Promise.all(values.map((value) => canonicalizeWorkspaceRootPath(value, pathRealpath)))) +} + +export async function canonicalizeWorkspaceRootsStateForRead( + state: WorkspaceRootsState, + pathRealpath: PathRealpathResolver = realpath, +): Promise { + const [order, active, projectOrder] = await Promise.all([ + canonicalizeWorkspaceRootPathList(state.order, pathRealpath), + canonicalizeWorkspaceRootPathList(state.active, pathRealpath), + canonicalizeWorkspaceRootPathList(state.projectOrder, pathRealpath), + ]) + const labelEntries = await Promise.all( + Object.entries(state.labels) + .sort(([first], [second]) => first.localeCompare(second)) + .map(async ([key, label]) => { + const canonicalKey = await canonicalizeWorkspaceRootPath(key, pathRealpath) + return { + canonicalKey, + label, + isCanonicalSource: canonicalKey === key, + } + }), + ) + const labels: Record = {} + const labelSourceByCanonicalKey = new Map() + for (const entry of labelEntries) { + const existing = labelSourceByCanonicalKey.get(entry.canonicalKey) + if (existing?.isCanonicalSource === true && !entry.isCanonicalSource) continue + if (existing && existing.isCanonicalSource === entry.isCanonicalSource) continue + labels[entry.canonicalKey] = entry.label + labelSourceByCanonicalKey.set(entry.canonicalKey, { + isCanonicalSource: entry.isCanonicalSource, + }) + } + + return { + order, + labels, + active, + projectOrder, + remoteProjects: state.remoteProjects.map((project) => ({ ...project })), + } +} + +async function canonicalizeThreadCwdRecord( + value: unknown, + canonicalizeCwd: (cwd: string) => Promise, +): Promise { + const record = asRecord(value) + const cwd = typeof record?.cwd === 'string' ? record.cwd : '' + if (!record || !cwd) return value + const canonicalCwd = await canonicalizeCwd(cwd) + return canonicalCwd === cwd ? value : { ...record, cwd: canonicalCwd } +} + +export async function canonicalizeThreadListResponseForRead( + payload: unknown, + pathRealpath: PathRealpathResolver = realpath, +): Promise { + const record = asRecord(payload) + if (!record || !Array.isArray(record.data)) return payload + const cwdCanonicalizationByValue = new Map>() + const canonicalizeCwd = (cwd: string): Promise => { + let canonicalized = cwdCanonicalizationByValue.get(cwd) + if (!canonicalized) { + canonicalized = canonicalizeWorkspaceRootPath(cwd, pathRealpath) + cwdCanonicalizationByValue.set(cwd, canonicalized) + } + return canonicalized + } + return { + ...record, + data: await Promise.all(record.data.map((item) => canonicalizeThreadCwdRecord(item, canonicalizeCwd))), + } +} + async function readWorkspaceRootsState(): Promise { const statePath = getCodexGlobalStatePath() let payload: Record = {} @@ -4284,13 +4382,13 @@ async function readWorkspaceRootsState(): Promise { 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']), - } + }) } async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise { diff --git a/tests.md b/tests.md index 2558d0dfa..df7662a71 100644 --- a/tests.md +++ b/tests.md @@ -336,6 +336,40 @@ Rollback/cleanup: --- +### 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. If both symlink and canonical forms have saved labels, confirm only the canonical path label is returned and displayed. +7. Fetch `thread/list` with multiple sessions that share the same cwd and confirm the rows still show under the canonical project. +8. 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. +- Duplicate symlink/canonical labels collapse deterministically to the canonical path label. +- Repeated cwd values in one `thread/list` response reuse the same canonical path result and do not change visible rows. +- Search and sidebar browsing both expose the session. +- Rows remain readable in light and dark themes. + +#### Rollback/Cleanup +- None. + +--- + ### Qodo feedback diagnostics reliability fixes #### Feature/Change Name