fix: harden recent project recovery and path matching
This commit is contained in:
parent
68378c603c
commit
c3f18df062
8 changed files with 443 additions and 56 deletions
|
|
@ -64,8 +64,11 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
|
|
||||||
const successful = results.flatMap((result) => result.candidates);
|
const successful = results.flatMap((result) => result.candidates);
|
||||||
const hasDegradedSources = results.some((result) => result.degraded);
|
const hasDegradedSources = results.some((result) => result.degraded);
|
||||||
|
const response: ListDashboardRecentProjectsResponse = {
|
||||||
|
projects: mergeRecentProjectCandidates(successful),
|
||||||
|
};
|
||||||
|
|
||||||
if (hasDegradedSources && stale) {
|
if (hasDegradedSources && stale && response.projects.length === 0) {
|
||||||
await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs);
|
await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs);
|
||||||
this.deps.logger.info('recent-projects served stale cache', {
|
this.deps.logger.info('recent-projects served stale cache', {
|
||||||
cacheKey,
|
cacheKey,
|
||||||
|
|
@ -76,9 +79,6 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
return stale;
|
return stale;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response: ListDashboardRecentProjectsResponse = {
|
|
||||||
projects: mergeRecentProjectCandidates(successful),
|
|
||||||
};
|
|
||||||
const viewModel = this.deps.output.present(response);
|
const viewModel = this.deps.output.present(response);
|
||||||
const cacheTtlMs = hasDegradedSources
|
const cacheTtlMs = hasDegradedSources
|
||||||
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||||
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_ARCHIVED_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||||
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||||
|
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||||
|
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS + 1_500;
|
||||||
|
|
||||||
function isInteractiveSource(source: unknown): boolean {
|
function isInteractiveSource(source: unknown): boolean {
|
||||||
return source === 'vscode' || source === 'cli';
|
return source === 'vscode' || source === 'cli';
|
||||||
|
|
@ -116,6 +118,37 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
this.deps.logger.warn('codex recent-projects thread list session failed', {
|
this.deps.logger.warn('codex recent-projects thread list session failed', {
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (message.toLowerCase().includes('timed out')) {
|
||||||
|
return {
|
||||||
|
live: { threads: [], error: message },
|
||||||
|
archived: { threads: [], error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const liveFallback = await this.deps.appServerClient.listRecentLiveThreads(binaryPath, {
|
||||||
|
limit: CODEX_THREAD_LIMIT,
|
||||||
|
requestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||||
|
totalTimeoutMs: CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deps.logger.info('codex recent-projects recovered with live-only fallback', {
|
||||||
|
liveCount: liveFallback.threads.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
live: liveFallback,
|
||||||
|
archived: { threads: [], error: message },
|
||||||
|
};
|
||||||
|
} catch (fallbackError) {
|
||||||
|
const fallbackMessage =
|
||||||
|
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||||
|
this.deps.logger.warn('codex recent-projects live-only fallback failed', {
|
||||||
|
error: fallbackMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
live: { threads: [], error: message },
|
live: { threads: [], error: message },
|
||||||
archived: { threads: [], error: message },
|
archived: { threads: [], error: message },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { JsonRpcStdioClient } from './JsonRpcStdioClient';
|
import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient';
|
||||||
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||||
|
|
@ -49,9 +49,55 @@ export interface CodexRecentThreadsResult {
|
||||||
archived: CodexThreadSegmentResult;
|
archived: CodexThreadSegmentResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThreadListSessionOptions {
|
||||||
|
binaryPath: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
totalTimeoutMs: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CodexAppServerClient {
|
export class CodexAppServerClient {
|
||||||
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
|
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
|
||||||
|
|
||||||
|
async listRecentLiveThreads(
|
||||||
|
binaryPath: string,
|
||||||
|
options: {
|
||||||
|
limit: number;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
totalTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
): Promise<CodexThreadSegmentResult> {
|
||||||
|
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const totalTimeoutMs = Math.max(
|
||||||
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
|
requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.#withThreadListSession(
|
||||||
|
{
|
||||||
|
binaryPath,
|
||||||
|
requestTimeoutMs,
|
||||||
|
totalTimeoutMs,
|
||||||
|
label: 'codex app-server thread/list live',
|
||||||
|
},
|
||||||
|
async (session) => {
|
||||||
|
const live = await session.request<ThreadListResponse>(
|
||||||
|
'thread/list',
|
||||||
|
{
|
||||||
|
archived: false,
|
||||||
|
limit: options.limit,
|
||||||
|
sortKey: 'updated_at',
|
||||||
|
},
|
||||||
|
requestTimeoutMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threads: live.data ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async listRecentThreads(
|
async listRecentThreads(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -66,36 +112,17 @@ export class CodexAppServerClient {
|
||||||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||||
const totalTimeoutMs = Math.max(
|
const totalTimeoutMs = Math.max(
|
||||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
liveRequestTimeoutMs + archivedRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.rpcClient.withSession(
|
return this.#withThreadListSession(
|
||||||
{
|
{
|
||||||
binaryPath,
|
binaryPath,
|
||||||
args: ['app-server'],
|
|
||||||
requestTimeoutMs: sessionRequestTimeoutMs,
|
requestTimeoutMs: sessionRequestTimeoutMs,
|
||||||
totalTimeoutMs,
|
totalTimeoutMs,
|
||||||
label: 'codex app-server thread/list',
|
label: 'codex app-server thread/list',
|
||||||
},
|
},
|
||||||
async (session) => {
|
async (session) => {
|
||||||
await session.request(
|
|
||||||
'initialize',
|
|
||||||
{
|
|
||||||
clientInfo: {
|
|
||||||
name: 'claude-agent-teams-ui',
|
|
||||||
title: 'Claude Agent Teams UI',
|
|
||||||
version: '0.1.0',
|
|
||||||
},
|
|
||||||
capabilities: {
|
|
||||||
experimentalApi: false,
|
|
||||||
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sessionRequestTimeoutMs
|
|
||||||
);
|
|
||||||
|
|
||||||
await session.notify('initialized');
|
|
||||||
|
|
||||||
const [live, archived] = await Promise.allSettled([
|
const [live, archived] = await Promise.allSettled([
|
||||||
session.request<ThreadListResponse>(
|
session.request<ThreadListResponse>(
|
||||||
'thread/list',
|
'thread/list',
|
||||||
|
|
@ -139,4 +166,39 @@ export class CodexAppServerClient {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #withThreadListSession<T>(
|
||||||
|
options: ThreadListSessionOptions,
|
||||||
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return this.rpcClient.withSession(
|
||||||
|
{
|
||||||
|
binaryPath: options.binaryPath,
|
||||||
|
args: ['app-server'],
|
||||||
|
requestTimeoutMs: options.requestTimeoutMs,
|
||||||
|
totalTimeoutMs: options.totalTimeoutMs,
|
||||||
|
label: options.label,
|
||||||
|
},
|
||||||
|
async (session) => {
|
||||||
|
await session.request(
|
||||||
|
'initialize',
|
||||||
|
{
|
||||||
|
clientInfo: {
|
||||||
|
name: 'claude-agent-teams-ui',
|
||||||
|
title: 'Claude Agent Teams UI',
|
||||||
|
version: '0.1.0',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
experimentalApi: false,
|
||||||
|
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options.requestTimeoutMs
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.notify('initialized');
|
||||||
|
return handler(session);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
|
||||||
|
|
||||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
|
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
|
||||||
|
|
@ -22,11 +20,20 @@ function canUseLocalStorage(): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHistoryPath(projectPath: string): string | null {
|
function normalizeHistoryPath(projectPath: string): string | null {
|
||||||
const trimmed = projectPath.trim();
|
let normalizedPath = projectPath.trim().replace(/\\/g, '/');
|
||||||
if (!trimmed) {
|
if (!normalizedPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return normalizePath(trimmed);
|
if (normalizedPath !== '/' && !/^[A-Za-z]:\/$/.test(normalizedPath)) {
|
||||||
|
while (normalizedPath.endsWith('/')) {
|
||||||
|
normalizedPath = normalizedPath.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldHistoryPath(projectPath: string): string {
|
||||||
|
return projectPath.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function readHistoryState(): RecentProjectOpenHistoryState {
|
function readHistoryState(): RecentProjectOpenHistoryState {
|
||||||
|
|
@ -99,16 +106,72 @@ function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHistoryLookup(): Map<string, number> {
|
interface HistoryLookup {
|
||||||
return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt]));
|
exact: Map<string, number>;
|
||||||
|
folded: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
openedAt: number;
|
||||||
|
exactPaths: Set<string>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProjectPaths(
|
function createHistoryLookup(): HistoryLookup {
|
||||||
|
const exact = new Map<string, number>();
|
||||||
|
const folded = new Map<string, { openedAt: number; exactPaths: Set<string> }>();
|
||||||
|
|
||||||
|
for (const entry of readHistoryState().entries) {
|
||||||
|
const normalizedPath = normalizeHistoryPath(entry.path);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
exact.set(normalizedPath, Math.max(exact.get(normalizedPath) ?? 0, entry.openedAt));
|
||||||
|
|
||||||
|
const foldedKey = foldHistoryPath(normalizedPath);
|
||||||
|
const existingFolded = folded.get(foldedKey);
|
||||||
|
if (existingFolded) {
|
||||||
|
existingFolded.openedAt = Math.max(existingFolded.openedAt, entry.openedAt);
|
||||||
|
existingFolded.exactPaths.add(normalizedPath);
|
||||||
|
} else {
|
||||||
|
folded.set(foldedKey, {
|
||||||
|
openedAt: entry.openedAt,
|
||||||
|
exactPaths: new Set([normalizedPath]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exact, folded };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): number {
|
||||||
|
const normalizedPath = normalizeHistoryPath(projectPath);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatch = lookup.exact.get(normalizedPath);
|
||||||
|
if (exactMatch != null) {
|
||||||
|
return exactMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
|
||||||
|
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return foldedMatch.openedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectLastOpenedAtFromLookup(
|
||||||
|
lookup: HistoryLookup,
|
||||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||||
): string[] {
|
): number {
|
||||||
return [project.primaryPath, ...project.associatedPaths]
|
return [project.primaryPath, ...project.associatedPaths].reduce(
|
||||||
.map((projectPath) => normalizeHistoryPath(projectPath))
|
(latest, projectPath) => Math.max(latest, resolveHistoryOpenedAt(lookup, projectPath)),
|
||||||
.filter((projectPath): projectPath is string => Boolean(projectPath));
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordRecentProjectOpenPaths(
|
export function recordRecentProjectOpenPaths(
|
||||||
|
|
@ -141,10 +204,7 @@ export function getRecentProjectLastOpenedAt(
|
||||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||||
): number {
|
): number {
|
||||||
const historyLookup = createHistoryLookup();
|
const historyLookup = createHistoryLookup();
|
||||||
return getProjectPaths(project).reduce(
|
return getProjectLastOpenedAtFromLookup(historyLookup, project);
|
||||||
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortRecentProjectsByDisplayPriority(
|
export function sortRecentProjectsByDisplayPriority(
|
||||||
|
|
@ -153,20 +213,12 @@ export function sortRecentProjectsByDisplayPriority(
|
||||||
): DashboardRecentProject[] {
|
): DashboardRecentProject[] {
|
||||||
const historyLookup = createHistoryLookup();
|
const historyLookup = createHistoryLookup();
|
||||||
|
|
||||||
const getLastOpenedAt = (
|
|
||||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
|
||||||
): number =>
|
|
||||||
getProjectPaths(project).reduce(
|
|
||||||
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPriorityOpen = (openedAt: number): boolean =>
|
const isPriorityOpen = (openedAt: number): boolean =>
|
||||||
openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS;
|
openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS;
|
||||||
|
|
||||||
return [...projects].sort((left, right) => {
|
return [...projects].sort((left, right) => {
|
||||||
const leftOpenedAt = getLastOpenedAt(left);
|
const leftOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, left);
|
||||||
const rightOpenedAt = getLastOpenedAt(right);
|
const rightOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, right);
|
||||||
const leftPriority = isPriorityOpen(leftOpenedAt);
|
const leftPriority = isPriorityOpen(leftOpenedAt);
|
||||||
const rightPriority = isPriorityOpen(rightOpenedAt);
|
const rightPriority = isPriorityOpen(rightOpenedAt);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns stale cached data when a source degrades after cache expiry', async () => {
|
it('prefers fresh healthy-source results over stale cache when degraded sources still leave usable projects', async () => {
|
||||||
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
|
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
|
||||||
const cache: RecentProjectsCachePort<TestViewModel> = {
|
const cache: RecentProjectsCachePort<TestViewModel> = {
|
||||||
get: vi.fn().mockResolvedValue(null),
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
|
@ -294,6 +294,72 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual({
|
||||||
|
ids: ['repo:fresh'],
|
||||||
|
sources: ['claude'],
|
||||||
|
});
|
||||||
|
expect(output.present).toHaveBeenCalledWith({
|
||||||
|
projects: [
|
||||||
|
expect.objectContaining({
|
||||||
|
identity: 'repo:fresh',
|
||||||
|
source: 'claude',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(cache.set).toHaveBeenCalledWith(
|
||||||
|
'recent-projects:stale',
|
||||||
|
{ ids: ['repo:fresh'], sources: ['claude'] },
|
||||||
|
1_500
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
||||||
|
cacheKey: 'recent-projects:stale',
|
||||||
|
count: 1,
|
||||||
|
degradedSources: 1,
|
||||||
|
cacheTtlMs: 1_500,
|
||||||
|
durationMs: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to stale cache only when degraded sources leave no usable fresh projects', async () => {
|
||||||
|
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
|
||||||
|
const cache: RecentProjectsCachePort<TestViewModel> = {
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
getStale: vi.fn().mockResolvedValue(stale),
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||||
|
present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({
|
||||||
|
ids: response.projects.map((project) => project.identity),
|
||||||
|
sources: response.projects.map((project) => project.source),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const sources: RecentProjectsSourcePort[] = [
|
||||||
|
{
|
||||||
|
sourceId: 'claude',
|
||||||
|
list: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceId: 'codex',
|
||||||
|
list: vi.fn().mockRejectedValue(new Error('codex unavailable')),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const logger = createLogger();
|
||||||
|
let now = 15_000;
|
||||||
|
|
||||||
|
const useCase = new ListDashboardRecentProjectsUseCase({
|
||||||
|
sources,
|
||||||
|
cache,
|
||||||
|
output,
|
||||||
|
clock: {
|
||||||
|
now: () => {
|
||||||
|
const current = now;
|
||||||
|
now += 200;
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
|
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
|
||||||
expect(output.present).not.toHaveBeenCalled();
|
expect(output.present).not.toHaveBeenCalled();
|
||||||
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500);
|
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CodexRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter';
|
||||||
|
|
||||||
|
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||||
|
import type { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
|
||||||
|
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||||
|
|
||||||
|
function createLogger(): LoggerPort & {
|
||||||
|
info: ReturnType<typeof vi.fn>;
|
||||||
|
warn: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodexRecentProjectsSourceAdapter', () => {
|
||||||
|
it('falls back to live-only threads when the full app-server session fails fast', async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const appServerClient = {
|
||||||
|
listRecentThreads: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('JSON-RPC process exited unexpectedly (code=1 signal=null)')),
|
||||||
|
listRecentLiveThreads: vi.fn().mockResolvedValue({
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
id: 'thread-live',
|
||||||
|
cwd: '/Users/belief/dev/projects/headless',
|
||||||
|
source: 'cli',
|
||||||
|
updatedAt: 1_700_000_000,
|
||||||
|
gitInfo: { branch: 'main' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as CodexAppServerClient;
|
||||||
|
const identityResolver = {
|
||||||
|
resolve: vi.fn().mockResolvedValue({
|
||||||
|
id: 'repo:headless',
|
||||||
|
name: 'headless',
|
||||||
|
}),
|
||||||
|
} as unknown as RecentProjectIdentityResolver;
|
||||||
|
|
||||||
|
const adapter = new CodexRecentProjectsSourceAdapter({
|
||||||
|
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||||
|
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||||
|
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
|
||||||
|
appServerClient,
|
||||||
|
identityResolver,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(adapter.list()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
identity: 'repo:headless',
|
||||||
|
displayName: 'headless',
|
||||||
|
primaryPath: '/Users/belief/dev/projects/headless',
|
||||||
|
providerIds: ['codex'],
|
||||||
|
sourceKind: 'codex',
|
||||||
|
openTarget: {
|
||||||
|
type: 'synthetic-path',
|
||||||
|
path: '/Users/belief/dev/projects/headless',
|
||||||
|
},
|
||||||
|
branchName: 'main',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith('codex recent-projects recovered with live-only fallback', {
|
||||||
|
liveCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not spend extra time on live-only fallback after a full session timeout', async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const appServerClient = {
|
||||||
|
listRecentThreads: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('codex app-server thread/list timed out after 8500ms')),
|
||||||
|
listRecentLiveThreads: vi.fn(),
|
||||||
|
} as unknown as CodexAppServerClient;
|
||||||
|
const identityResolver = {
|
||||||
|
resolve: vi.fn(),
|
||||||
|
} as unknown as RecentProjectIdentityResolver;
|
||||||
|
|
||||||
|
const adapter = new CodexRecentProjectsSourceAdapter({
|
||||||
|
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||||
|
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||||
|
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
|
||||||
|
appServerClient,
|
||||||
|
identityResolver,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(adapter.list()).resolves.toEqual([]);
|
||||||
|
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -53,7 +53,7 @@ describe('CodexAppServerClient', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
binaryPath: '/usr/local/bin/codex',
|
binaryPath: '/usr/local/bin/codex',
|
||||||
requestTimeoutMs: 4500,
|
requestTimeoutMs: 4500,
|
||||||
totalTimeoutMs: 6000,
|
totalTimeoutMs: 8500,
|
||||||
}),
|
}),
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
|
|
@ -135,9 +135,49 @@ describe('CodexAppServerClient', () => {
|
||||||
|
|
||||||
expect(withSession).toHaveBeenCalledWith(
|
expect(withSession).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
totalTimeoutMs: 6000,
|
totalTimeoutMs: 8500,
|
||||||
}),
|
}),
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can load only live threads in a dedicated fallback session', async () => {
|
||||||
|
const session = createSession(
|
||||||
|
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||||
|
if (method === 'initialize') {
|
||||||
|
return Promise.resolve({});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'thread/list' && params?.archived === false) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected method: ${method}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||||
|
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
|
||||||
|
|
||||||
|
const result = await client.listRecentLiveThreads('/usr/local/bin/codex', {
|
||||||
|
limit: 40,
|
||||||
|
requestTimeoutMs: 4500,
|
||||||
|
totalTimeoutMs: 6000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
requestTimeoutMs: 4500,
|
||||||
|
totalTimeoutMs: 6000,
|
||||||
|
label: 'codex app-server thread/list live',
|
||||||
|
}),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -98,4 +98,36 @@ describe('recentProjectOpenHistory', () => {
|
||||||
).map((project) => project.id)
|
).map((project) => project.id)
|
||||||
).toEqual(['repo:active', 'repo:opened']);
|
).toEqual(['repo:active', 'repo:opened']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not collapse distinct case-variant paths when history contains ambiguous entries', () => {
|
||||||
|
recordRecentProjectOpenPaths(['/Work/Repo'], 5_000);
|
||||||
|
recordRecentProjectOpenPaths(['/work/repo'], 8_000);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getRecentProjectLastOpenedAt(
|
||||||
|
makeProject({
|
||||||
|
primaryPath: '/Work/Repo',
|
||||||
|
associatedPaths: ['/Work/Repo'],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(5_000);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getRecentProjectLastOpenedAt(
|
||||||
|
makeProject({
|
||||||
|
primaryPath: '/work/repo',
|
||||||
|
associatedPaths: ['/work/repo'],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(8_000);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getRecentProjectLastOpenedAt(
|
||||||
|
makeProject({
|
||||||
|
primaryPath: '/WORK/repo',
|
||||||
|
associatedPaths: ['/WORK/repo'],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue