From 943722013306fef86abc0698bb76df6b0ad41a31 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:16:28 +0300 Subject: [PATCH] fix(recent-projects): recover codex projects after degraded startup --- src/features/recent-projects/contracts/api.ts | 4 +- src/features/recent-projects/contracts/dto.ts | 5 ++ .../recent-projects/contracts/index.ts | 1 + .../recent-projects/contracts/normalize.ts | 31 +++++++ .../ListDashboardRecentProjectsResponse.ts | 1 + .../ports/RecentProjectsSourcePort.ts | 9 ++- .../ListDashboardRecentProjectsUseCase.ts | 32 ++++++-- .../input/http/registerRecentProjectsHttp.ts | 14 +++- .../input/ipc/registerRecentProjectsIpc.ts | 14 +++- .../DashboardRecentProjectsPresenter.ts | 38 +++++---- .../ClaudeRecentProjectsSourceAdapter.ts | 12 ++- .../CodexRecentProjectsSourceAdapter.ts | 28 +++++-- .../createRecentProjectsFeature.ts | 14 ++-- .../hooks/useRecentProjectsSection.ts | 40 +++++++++- .../utils/recentProjectsClientCache.ts | 43 ++++++---- src/renderer/api/httpClient.ts | 6 +- ...lizeDashboardRecentProjectsPayload.test.ts | 50 ++++++++++++ ...ListDashboardRecentProjectsUseCase.test.ts | 71 ++++++++++++++++ .../CodexRecentProjectsSourceAdapter.test.ts | 51 +++++++----- .../utils/recentProjectsClientCache.test.ts | 80 ++++++++++++++----- 20 files changed, 437 insertions(+), 107 deletions(-) create mode 100644 src/features/recent-projects/contracts/normalize.ts create mode 100644 test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts diff --git a/src/features/recent-projects/contracts/api.ts b/src/features/recent-projects/contracts/api.ts index 285ce11e..c1a74622 100644 --- a/src/features/recent-projects/contracts/api.ts +++ b/src/features/recent-projects/contracts/api.ts @@ -1,5 +1,5 @@ -import type { DashboardRecentProject } from './dto'; +import type { DashboardRecentProjectsPayload } from './dto'; export interface RecentProjectsElectronApi { - getDashboardRecentProjects(): Promise; + getDashboardRecentProjects(): Promise; } diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts index 253ac36e..bdb4eda0 100644 --- a/src/features/recent-projects/contracts/dto.ts +++ b/src/features/recent-projects/contracts/dto.ts @@ -17,3 +17,8 @@ export interface DashboardRecentProject { openTarget: DashboardRecentProjectOpenTarget; primaryBranch?: string; } + +export interface DashboardRecentProjectsPayload { + projects: DashboardRecentProject[]; + degraded: boolean; +} diff --git a/src/features/recent-projects/contracts/index.ts b/src/features/recent-projects/contracts/index.ts index 69f32f5a..41e0bc74 100644 --- a/src/features/recent-projects/contracts/index.ts +++ b/src/features/recent-projects/contracts/index.ts @@ -1,3 +1,4 @@ export type * from './api'; export * from './channels'; export type * from './dto'; +export * from './normalize'; diff --git a/src/features/recent-projects/contracts/normalize.ts b/src/features/recent-projects/contracts/normalize.ts new file mode 100644 index 00000000..e38ce700 --- /dev/null +++ b/src/features/recent-projects/contracts/normalize.ts @@ -0,0 +1,31 @@ +import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './dto'; + +export type DashboardRecentProjectsPayloadLike = + | DashboardRecentProjectsPayload + | DashboardRecentProject[] + | null + | undefined; + +export function normalizeDashboardRecentProjectsPayload( + value: DashboardRecentProjectsPayloadLike +): DashboardRecentProjectsPayload | null { + if (!value) { + return null; + } + + if (Array.isArray(value)) { + return { + projects: value, + degraded: false, + }; + } + + if (!Array.isArray(value.projects)) { + return null; + } + + return { + projects: value.projects, + degraded: value.degraded === true, + }; +} diff --git a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts index 7016a81f..0800ee06 100644 --- a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts +++ b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts @@ -2,4 +2,5 @@ import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAg export interface ListDashboardRecentProjectsResponse { projects: RecentProjectAggregate[]; + degraded: boolean; } diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts index 004a8d72..cba03607 100644 --- a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts +++ b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts @@ -1,7 +1,14 @@ import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate'; +export interface RecentProjectsSourceResult { + candidates: RecentProjectCandidate[]; + degraded: boolean; +} + +export type RecentProjectsSourcePayload = RecentProjectsSourceResult | RecentProjectCandidate[]; + export interface RecentProjectsSourcePort { readonly sourceId?: string; readonly timeoutMs?: number; - list(): Promise; + list(): Promise; } diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index fefd56d8..348fc1b4 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -6,7 +6,11 @@ import type { ClockPort } from '../ports/ClockPort'; import type { ListDashboardRecentProjectsOutputPort } from '../ports/ListDashboardRecentProjectsOutputPort'; import type { LoggerPort } from '../ports/LoggerPort'; import type { RecentProjectsCachePort } from '../ports/RecentProjectsCachePort'; -import type { RecentProjectsSourcePort } from '../ports/RecentProjectsSourcePort'; +import type { + RecentProjectsSourcePayload, + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '../ports/RecentProjectsSourcePort'; const DEFAULT_CACHE_TTL_MS = 10_000; const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; @@ -16,6 +20,20 @@ interface SourceLoadResult { degraded: boolean; } +function normalizeSourcePayload(payload: RecentProjectsSourcePayload): RecentProjectsSourceResult { + if (Array.isArray(payload)) { + return { + candidates: payload, + degraded: false, + }; + } + + return { + candidates: payload.candidates, + degraded: payload.degraded === true, + }; +} + export interface ListDashboardRecentProjectsDeps { sources: RecentProjectsSourcePort[]; cache: RecentProjectsCachePort; @@ -66,6 +84,7 @@ export class ListDashboardRecentProjectsUseCase { const hasDegradedSources = results.some((result) => result.degraded); const response: ListDashboardRecentProjectsResponse = { projects: mergeRecentProjectCandidates(successful), + degraded: hasDegradedSources, }; if (hasDegradedSources && stale && response.projects.length === 0) { @@ -111,10 +130,10 @@ export class ListDashboardRecentProjectsUseCase { source .list() .then( - (candidates) => + (payload) => ({ kind: 'success', - candidates, + payload: normalizeSourcePayload(payload), }) as const ) .catch( @@ -130,7 +149,7 @@ export class ListDashboardRecentProjectsUseCase { ]); if (result.kind === 'success') { - return { candidates: result.candidates, degraded: false }; + return result.payload; } if (result.kind === 'timeout') { @@ -161,10 +180,7 @@ export class ListDashboardRecentProjectsUseCase { sourceIndex: number ): Promise { try { - return { - candidates: await source.list(), - degraded: false, - }; + return normalizeSourcePayload(await source.list()); } catch (error) { this.deps.logger.warn('recent-projects source failed', { sourceId, diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts index 991cab02..ac3001f0 100644 --- a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -1,6 +1,7 @@ import { DASHBOARD_RECENT_PROJECTS_ROUTE, - type DashboardRecentProject, + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProjectsPayload, } from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; @@ -13,12 +14,17 @@ export function registerRecentProjectsHttp( app: FastifyInstance, feature: RecentProjectsFeatureFacade ): void { - app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { + app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { try { - return await feature.listDashboardRecentProjects(); + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); } catch (error) { logger.error('Failed to load dashboard recent projects via HTTP', error); - return []; + return { projects: [], degraded: true }; } }); } diff --git a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts index 906c97b5..a18ea436 100644 --- a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -1,4 +1,7 @@ -import { GET_DASHBOARD_RECENT_PROJECTS } from '@features/recent-projects/contracts'; +import { + GET_DASHBOARD_RECENT_PROJECTS, + normalizeDashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; @@ -12,10 +15,15 @@ export function registerRecentProjectsIpc( ): void { ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => { try { - return await feature.listDashboardRecentProjects(); + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); } catch (error) { logger.error('Failed to load dashboard recent projects via IPC', error); - return []; + return { projects: [], degraded: true }; } }); } diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts index 638c662e..c9a3e5fb 100644 --- a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -1,21 +1,27 @@ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; import type { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse'; import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort'; -export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort< - DashboardRecentProject[] -> { - present(response: ListDashboardRecentProjectsResponse): DashboardRecentProject[] { - return response.projects.map((aggregate) => ({ - id: aggregate.identity, - name: aggregate.displayName, - primaryPath: aggregate.primaryPath, - associatedPaths: aggregate.associatedPaths, - mostRecentActivity: aggregate.lastActivityAt, - providerIds: aggregate.providerIds, - source: aggregate.source, - openTarget: aggregate.openTarget, - primaryBranch: aggregate.branchName, - })); +export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort { + present(response: ListDashboardRecentProjectsResponse): DashboardRecentProjectsPayload { + return { + degraded: response.degraded, + projects: response.projects.map( + (aggregate): DashboardRecentProject => ({ + id: aggregate.identity, + name: aggregate.displayName, + primaryPath: aggregate.primaryPath, + associatedPaths: aggregate.associatedPaths, + mostRecentActivity: aggregate.lastActivityAt, + providerIds: aggregate.providerIds, + source: aggregate.source, + openTarget: aggregate.openTarget, + primaryBranch: aggregate.branchName, + }) + ), + }; } } diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts index 2d5aa3c5..700b9122 100644 --- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -3,7 +3,10 @@ import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper'; import { getProjectsBasePath } from '@main/utils/pathDecoder'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; -import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; import type { ServiceContext } from '@main/services'; import type { RepositoryGroup, Worktree } from '@main/types'; @@ -47,7 +50,7 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo private readonly logger: LoggerPort ) {} - async list(): Promise { + async list(): Promise { const activeContext = this.getActiveContext(); const groups = activeContext.type === 'local' @@ -63,7 +66,10 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo contextId: activeContext.id, }); - return candidates; + return { + candidates, + degraded: false, + }; } async #groupLocalProjects(activeContext: ServiceContext): Promise { diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index fa0af4a9..f597fcfe 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -2,7 +2,10 @@ import { normalizeIdentityPath } from '@features/recent-projects/main/infrastruc import path from 'path'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; -import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; import type { CodexAppServerClient, @@ -34,6 +37,10 @@ function normalizeTimestamp(value: number | undefined): number { return value < 1_000_000_000_000 ? value * 1000 : value; } +function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean { + return Boolean(result.live.error || result.archived.error); +} + export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort { readonly sourceId = 'codex'; readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS; @@ -49,21 +56,28 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor } ) {} - async list(): Promise { + async list(): Promise { const activeContext = this.deps.getActiveContext(); const localContext = this.deps.getLocalContext(); if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { - return []; + return { + candidates: [], + degraded: false, + }; } const binaryPath = await this.deps.resolveBinary(); if (!binaryPath) { this.deps.logger.info('codex recent-projects source skipped - binary unavailable'); - return []; + return { + candidates: [], + degraded: false, + }; } const threadSegments = await this.#listRecentThreadsSafe(binaryPath); + const degraded = isDegradedThreadResult(threadSegments); this.#logSegmentFailure(threadSegments, 'live'); this.#logSegmentFailure(threadSegments, 'archived'); const liveThreads = threadSegments.live.threads; @@ -79,9 +93,13 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor this.deps.logger.info('codex recent-projects source loaded', { count: candidates.length, + degraded, }); - return candidates; + return { + candidates, + degraded, + }; } async #listRecentThreads(binaryPath: string): Promise { diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index effb5a8c..f77986e8 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -10,11 +10,14 @@ import { RecentProjectIdentityResolver } from '../infrastructure/identity/Recent import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import { + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; import type { ServiceContext } from '@main/services'; export interface RecentProjectsFeatureFacade { - listDashboardRecentProjects(): Promise; + listDashboardRecentProjects(): Promise; } export function createRecentProjectsFeature(deps: { @@ -22,7 +25,7 @@ export function createRecentProjectsFeature(deps: { getLocalContext: () => ServiceContext | undefined; logger: LoggerPort; }): RecentProjectsFeatureFacade { - const cache = new InMemoryRecentProjectsCache(); + const cache = new InMemoryRecentProjectsCache(); const presenter = new DashboardRecentProjectsPresenter(); const clock: ClockPort = { now: () => Date.now() }; const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); @@ -48,9 +51,10 @@ export function createRecentProjectsFeature(deps: { }); return { - listDashboardRecentProjects: () => { + listDashboardRecentProjects: async () => { const activeContext = deps.getActiveContext(); - return useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true }; }, }; } diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 63d2c223..baa2f48b 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -23,6 +23,9 @@ import type { TeamSummary } from '@shared/types'; const INITIAL_RECENT_PROJECTS = 11; const LOAD_MORE_STEP = 8; +const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500; +const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000; +const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3; function matchesSearch(project: DashboardRecentProject, query: string): boolean { if (!query) { @@ -72,7 +75,13 @@ export function useRecentProjectsSection( const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const [recentProjects, setRecentProjects] = useState( - initialSnapshot?.projects ?? [] + initialSnapshot?.payload.projects ?? [] + ); + const [recentProjectsDegraded, setRecentProjectsDegraded] = useState( + initialSnapshot?.payload.degraded ?? false + ); + const [degradedRefreshCount, setDegradedRefreshCount] = useState( + initialSnapshot?.payload.degraded ? 1 : 0 ); const [loading, setLoading] = useState(initialSnapshot == null); const [error, setError] = useState(null); @@ -80,7 +89,9 @@ export function useRecentProjectsSection( const [aliveTeams, setAliveTeams] = useState([]); const [openHistoryVersion, setOpenHistoryVersion] = useState(0); const hasFetchedTasksRef = useRef(globalTasksInitialized); - const recentProjectsRef = useRef(initialSnapshot?.projects ?? []); + const recentProjectsRef = useRef( + initialSnapshot?.payload.projects ?? [] + ); useEffect(() => { recentProjectsRef.current = recentProjects; @@ -95,11 +106,13 @@ export function useRecentProjectsSection( } setError(null); try { - const projects = await loadRecentProjectsWithClientCache( + const payload = await loadRecentProjectsWithClientCache( () => api.getDashboardRecentProjects(), options ); - setRecentProjects(projects); + setRecentProjects(payload.projects); + setRecentProjectsDegraded(payload.degraded); + setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0)); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); } finally { @@ -116,6 +129,25 @@ export function useRecentProjectsSection( void reload({ force: snapshot != null }); }, [reload]); + useEffect(() => { + if (!recentProjectsDegraded) { + return; + } + + const delayMs = + degradedRefreshCount <= DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT + ? DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS + : DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS; + + const timer = window.setTimeout(() => { + void reload({ force: true }); + }, delayMs); + + return () => { + window.clearTimeout(timer); + }; + }, [degradedRefreshCount, recentProjectsDegraded, reload]); + useEffect(() => { if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) { hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized; diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts index e28d89b2..dc804d0d 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -1,38 +1,52 @@ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProjectsPayloadLike, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; +import { normalizeDashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; +const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500; -let cachedProjects: DashboardRecentProject[] | null = null; +let cachedPayload: DashboardRecentProjectsPayloadLike = null; let cachedAt = 0; -let inFlightLoad: Promise | null = null; +let inFlightLoad: Promise | null = null; export interface RecentProjectsClientSnapshot { - projects: DashboardRecentProject[]; + payload: DashboardRecentProjectsPayload; fetchedAt: number; isStale: boolean; } export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { - if (!cachedProjects) { + const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload); + if (!normalizedPayload) { return null; } + if (cachedPayload !== normalizedPayload) { + cachedPayload = normalizedPayload; + } + + const ttlMs = normalizedPayload.degraded + ? RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS + : RECENT_PROJECTS_CLIENT_CACHE_TTL_MS; + return { - projects: cachedProjects, + payload: normalizedPayload, fetchedAt: cachedAt, - isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS, + isStale: Date.now() - cachedAt > ttlMs, }; } export async function loadRecentProjectsWithClientCache( - loader: () => Promise, + loader: () => Promise, options?: { force?: boolean } -): Promise { +): Promise { const force = options?.force ?? false; const snapshot = getRecentProjectsClientSnapshot(); if (!force && snapshot && !snapshot.isStale) { - return snapshot.projects; + return snapshot.payload; } if (inFlightLoad) { @@ -40,10 +54,11 @@ export async function loadRecentProjectsWithClientCache( } const request = loader() - .then((projects) => { - cachedProjects = projects; + .then((payloadLike) => { + const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike); + cachedPayload = normalizedPayload; cachedAt = Date.now(); - return projects; + return normalizedPayload ?? { projects: [], degraded: true }; }) .finally(() => { if (inFlightLoad === request) { @@ -56,7 +71,7 @@ export async function loadRecentProjectsWithClientCache( } export function __resetRecentProjectsClientCacheForTests(): void { - cachedProjects = null; + cachedPayload = null; cachedAt = 0; inFlightLoad = null; } diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d5afc4a0..f76bf13b 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,7 +6,7 @@ * to run in a regular browser connected to an HTTP server. */ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { AppConfig, AttachmentFileData, @@ -218,8 +218,8 @@ export class HttpAPIClient implements ElectronAPI { getAppVersion = (): Promise => this.get('/api/version'); - getDashboardRecentProjects = (): Promise => - this.get('/api/dashboard/recent-projects'); + getDashboardRecentProjects = (): Promise => + this.get('/api/dashboard/recent-projects'); getProjects = (): Promise => this.get('/api/projects'); diff --git a/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts new file mode 100644 index 00000000..de8345a6 --- /dev/null +++ b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProject, +} from '@features/recent-projects/contracts'; + +const project = (id: string): DashboardRecentProject => ({ + id, + name: id, + primaryPath: `/tmp/${id}`, + associatedPaths: [`/tmp/${id}`], + mostRecentActivity: Date.parse('2026-04-14T12:00:00.000Z'), + providerIds: ['anthropic'], + source: 'claude', + openTarget: { + type: 'synthetic-path', + path: `/tmp/${id}`, + }, +}); + +describe('normalizeDashboardRecentProjectsPayload', () => { + it('keeps payload objects intact except for degraded normalization', () => { + expect( + normalizeDashboardRecentProjectsPayload({ + degraded: true, + projects: [project('alpha')], + }) + ).toEqual({ + degraded: true, + projects: [project('alpha')], + }); + }); + + it('normalizes legacy project arrays into healthy payloads', () => { + expect(normalizeDashboardRecentProjectsPayload([project('alpha')])).toEqual({ + degraded: false, + projects: [project('alpha')], + }); + }); + + it('returns null for malformed payloads', () => { + expect( + normalizeDashboardRecentProjectsPayload({ + degraded: false, + projects: null, + } as unknown as { degraded: boolean; projects: null }) + ).toBeNull(); + }); +}); diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts index 14fedfc6..b68d88f3 100644 --- a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -140,6 +140,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { sources: ['mixed'], }); expect(output.present).toHaveBeenCalledWith({ + degraded: true, projects: [ expect.objectContaining({ identity: 'repo:alpha', @@ -299,6 +300,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { sources: ['claude'], }); expect(output.present).toHaveBeenCalledWith({ + degraded: true, projects: [ expect.objectContaining({ identity: 'repo:fresh', @@ -370,4 +372,73 @@ describe('ListDashboardRecentProjectsUseCase', () => { durationMs: 200, }); }); + + it('treats explicitly degraded source payloads as degraded even when they resolve successfully', async () => { + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + 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([ + makeCandidate({ + identity: 'repo:alpha', + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }, + { + sourceId: 'codex', + list: vi.fn().mockResolvedValue({ + candidates: [], + degraded: true, + }), + }, + ]; + const logger = createLogger(); + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { now: () => 25_000 }, + logger, + }); + + await expect(useCase.execute('recent-projects:explicit-degraded')).resolves.toEqual({ + ids: ['repo:alpha'], + sources: ['claude'], + }); + + expect(output.present).toHaveBeenCalledWith({ + degraded: true, + projects: [ + expect.objectContaining({ + identity: 'repo:alpha', + source: 'claude', + }), + ], + }); + expect(cache.set).toHaveBeenCalledWith( + 'recent-projects:explicit-degraded', + { ids: ['repo:alpha'], sources: ['claude'] }, + 1_500 + ); + expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { + cacheKey: 'recent-projects:explicit-degraded', + count: 1, + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 0, + }); + }); }); diff --git a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts index d963ae88..40b2f9ee 100644 --- a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts @@ -57,12 +57,15 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([ - expect.objectContaining({ - identity: 'repo:headless', - primaryPath: '/Users/belief/dev/projects/headless', - }), - ]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:headless', + primaryPath: '/Users/belief/dev/projects/headless', + }), + ], + degraded: true, + }); expect(logger.info).toHaveBeenCalledWith( 'codex recent-projects archived thread list degraded', @@ -110,20 +113,23 @@ describe('CodexRecentProjectsSourceAdapter', () => { 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', - }), - ]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + 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', + }), + ], + degraded: true, + }); expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1); expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1); @@ -153,7 +159,10 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: true, + }); expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); }); }); diff --git a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts index 7f9acf74..d23e49a9 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts @@ -6,7 +6,10 @@ import { loadRecentProjectsWithClientCache, } from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; const project = (id: string): DashboardRecentProject => ({ id, @@ -22,6 +25,15 @@ const project = (id: string): DashboardRecentProject => ({ }, }); +const payload = ( + id: string, + overrides: Partial = {} +): DashboardRecentProjectsPayload => ({ + projects: [project(id)], + degraded: false, + ...overrides, +}); + describe('recentProjectsClientCache', () => { afterEach(() => { __resetRecentProjectsClientCacheForTests(); @@ -30,13 +42,13 @@ describe('recentProjectsClientCache', () => { }); it('returns cached projects while the client cache is fresh', async () => { - const loader = vi.fn().mockResolvedValue([project('alpha')]); + const loader = vi.fn().mockResolvedValue(payload('alpha')); - await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); - await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); expect(loader).toHaveBeenCalledTimes(1); - expect(getRecentProjectsClientSnapshot()?.projects).toEqual([project('alpha')]); + expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); }); it('revalidates stale cache without dropping the previous snapshot', async () => { @@ -44,38 +56,38 @@ describe('recentProjectsClientCache', () => { vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); const loader = vi - .fn<() => Promise>() - .mockResolvedValueOnce([project('alpha')]) - .mockResolvedValueOnce([project('beta')]); + .fn<() => Promise>() + .mockResolvedValueOnce(payload('alpha')) + .mockResolvedValueOnce(payload('beta')); await loadRecentProjectsWithClientCache(loader); vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z')); expect(getRecentProjectsClientSnapshot()).toMatchObject({ - projects: [project('alpha')], + payload: payload('alpha'), isStale: true, }); - await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([ - project('beta'), - ]); + await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual( + payload('beta') + ); expect(loader).toHaveBeenCalledTimes(2); expect(getRecentProjectsClientSnapshot()).toMatchObject({ - projects: [project('beta')], + payload: payload('beta'), isStale: false, }); }); it('deduplicates concurrent client refreshes', async () => { const resolveLoaderRef: { - current: ((projects: DashboardRecentProject[]) => void) | null; + current: ((payload: DashboardRecentProjectsPayload) => void) | null; } = { current: null, }; const loader = vi.fn( () => - new Promise((resolve) => { + new Promise((resolve) => { resolveLoaderRef.current = resolve; }) ); @@ -85,9 +97,41 @@ describe('recentProjectsClientCache', () => { expect(loader).toHaveBeenCalledTimes(1); - resolveLoaderRef.current?.([project('alpha')]); + resolveLoaderRef.current?.(payload('alpha')); - await expect(first).resolves.toEqual([project('alpha')]); - await expect(second).resolves.toEqual([project('alpha')]); + await expect(first).resolves.toEqual(payload('alpha')); + await expect(second).resolves.toEqual(payload('alpha')); + }); + + it('marks degraded payload snapshots stale faster than healthy payloads', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); + + const loader = vi + .fn<() => Promise>() + .mockResolvedValueOnce(payload('alpha', { degraded: true })); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual( + payload('alpha', { degraded: true }) + ); + + vi.setSystemTime(new Date('2026-04-14T12:00:01.000Z')); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + payload: payload('alpha', { degraded: true }), + isStale: false, + }); + + vi.setSystemTime(new Date('2026-04-14T12:00:02.000Z')); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + payload: payload('alpha', { degraded: true }), + isStale: true, + }); + }); + + it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => { + const loader = vi.fn<() => Promise>().mockResolvedValue([project('alpha')]); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); + expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); }); });