fix(recent-projects): recover codex projects after degraded startup

This commit is contained in:
777genius 2026-04-16 13:16:28 +03:00
parent 1173a4942a
commit 9437220133
20 changed files with 437 additions and 107 deletions

View file

@ -1,5 +1,5 @@
import type { DashboardRecentProject } from './dto'; import type { DashboardRecentProjectsPayload } from './dto';
export interface RecentProjectsElectronApi { export interface RecentProjectsElectronApi {
getDashboardRecentProjects(): Promise<DashboardRecentProject[]>; getDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
} }

View file

@ -17,3 +17,8 @@ export interface DashboardRecentProject {
openTarget: DashboardRecentProjectOpenTarget; openTarget: DashboardRecentProjectOpenTarget;
primaryBranch?: string; primaryBranch?: string;
} }
export interface DashboardRecentProjectsPayload {
projects: DashboardRecentProject[];
degraded: boolean;
}

View file

@ -1,3 +1,4 @@
export type * from './api'; export type * from './api';
export * from './channels'; export * from './channels';
export type * from './dto'; export type * from './dto';
export * from './normalize';

View file

@ -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,
};
}

View file

@ -2,4 +2,5 @@ import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAg
export interface ListDashboardRecentProjectsResponse { export interface ListDashboardRecentProjectsResponse {
projects: RecentProjectAggregate[]; projects: RecentProjectAggregate[];
degraded: boolean;
} }

View file

@ -1,7 +1,14 @@
import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate'; import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate';
export interface RecentProjectsSourceResult {
candidates: RecentProjectCandidate[];
degraded: boolean;
}
export type RecentProjectsSourcePayload = RecentProjectsSourceResult | RecentProjectCandidate[];
export interface RecentProjectsSourcePort { export interface RecentProjectsSourcePort {
readonly sourceId?: string; readonly sourceId?: string;
readonly timeoutMs?: number; readonly timeoutMs?: number;
list(): Promise<RecentProjectCandidate[]>; list(): Promise<RecentProjectsSourcePayload>;
} }

View file

@ -6,7 +6,11 @@ import type { ClockPort } from '../ports/ClockPort';
import type { ListDashboardRecentProjectsOutputPort } from '../ports/ListDashboardRecentProjectsOutputPort'; import type { ListDashboardRecentProjectsOutputPort } from '../ports/ListDashboardRecentProjectsOutputPort';
import type { LoggerPort } from '../ports/LoggerPort'; import type { LoggerPort } from '../ports/LoggerPort';
import type { RecentProjectsCachePort } from '../ports/RecentProjectsCachePort'; 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_CACHE_TTL_MS = 10_000;
const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
@ -16,6 +20,20 @@ interface SourceLoadResult {
degraded: boolean; 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<TViewModel> { export interface ListDashboardRecentProjectsDeps<TViewModel> {
sources: RecentProjectsSourcePort[]; sources: RecentProjectsSourcePort[];
cache: RecentProjectsCachePort<TViewModel>; cache: RecentProjectsCachePort<TViewModel>;
@ -66,6 +84,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
const hasDegradedSources = results.some((result) => result.degraded); const hasDegradedSources = results.some((result) => result.degraded);
const response: ListDashboardRecentProjectsResponse = { const response: ListDashboardRecentProjectsResponse = {
projects: mergeRecentProjectCandidates(successful), projects: mergeRecentProjectCandidates(successful),
degraded: hasDegradedSources,
}; };
if (hasDegradedSources && stale && response.projects.length === 0) { if (hasDegradedSources && stale && response.projects.length === 0) {
@ -111,10 +130,10 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
source source
.list() .list()
.then( .then(
(candidates) => (payload) =>
({ ({
kind: 'success', kind: 'success',
candidates, payload: normalizeSourcePayload(payload),
}) as const }) as const
) )
.catch( .catch(
@ -130,7 +149,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
]); ]);
if (result.kind === 'success') { if (result.kind === 'success') {
return { candidates: result.candidates, degraded: false }; return result.payload;
} }
if (result.kind === 'timeout') { if (result.kind === 'timeout') {
@ -161,10 +180,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
sourceIndex: number sourceIndex: number
): Promise<SourceLoadResult> { ): Promise<SourceLoadResult> {
try { try {
return { return normalizeSourcePayload(await source.list());
candidates: await source.list(),
degraded: false,
};
} catch (error) { } catch (error) {
this.deps.logger.warn('recent-projects source failed', { this.deps.logger.warn('recent-projects source failed', {
sourceId, sourceId,

View file

@ -1,6 +1,7 @@
import { import {
DASHBOARD_RECENT_PROJECTS_ROUTE, DASHBOARD_RECENT_PROJECTS_ROUTE,
type DashboardRecentProject, normalizeDashboardRecentProjectsPayload,
type DashboardRecentProjectsPayload,
} from '@features/recent-projects/contracts'; } from '@features/recent-projects/contracts';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
@ -13,12 +14,17 @@ export function registerRecentProjectsHttp(
app: FastifyInstance, app: FastifyInstance,
feature: RecentProjectsFeatureFacade feature: RecentProjectsFeatureFacade
): void { ): void {
app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise<DashboardRecentProject[]> => { app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise<DashboardRecentProjectsPayload> => {
try { try {
return await feature.listDashboardRecentProjects(); return (
normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
projects: [],
degraded: true,
}
);
} catch (error) { } catch (error) {
logger.error('Failed to load dashboard recent projects via HTTP', error); logger.error('Failed to load dashboard recent projects via HTTP', error);
return []; return { projects: [], degraded: true };
} }
}); });
} }

View file

@ -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 { createLogger } from '@shared/utils/logger';
import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature';
@ -12,10 +15,15 @@ export function registerRecentProjectsIpc(
): void { ): void {
ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => { ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => {
try { try {
return await feature.listDashboardRecentProjects(); return (
normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
projects: [],
degraded: true,
}
);
} catch (error) { } catch (error) {
logger.error('Failed to load dashboard recent projects via IPC', error); logger.error('Failed to load dashboard recent projects via IPC', error);
return []; return { projects: [], degraded: true };
} }
}); });
} }

View file

@ -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 { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse';
import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort'; import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort';
export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort< export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort<DashboardRecentProjectsPayload> {
DashboardRecentProject[] present(response: ListDashboardRecentProjectsResponse): DashboardRecentProjectsPayload {
> { return {
present(response: ListDashboardRecentProjectsResponse): DashboardRecentProject[] { degraded: response.degraded,
return response.projects.map((aggregate) => ({ projects: response.projects.map(
id: aggregate.identity, (aggregate): DashboardRecentProject => ({
name: aggregate.displayName, id: aggregate.identity,
primaryPath: aggregate.primaryPath, name: aggregate.displayName,
associatedPaths: aggregate.associatedPaths, primaryPath: aggregate.primaryPath,
mostRecentActivity: aggregate.lastActivityAt, associatedPaths: aggregate.associatedPaths,
providerIds: aggregate.providerIds, mostRecentActivity: aggregate.lastActivityAt,
source: aggregate.source, providerIds: aggregate.providerIds,
openTarget: aggregate.openTarget, source: aggregate.source,
primaryBranch: aggregate.branchName, openTarget: aggregate.openTarget,
})); primaryBranch: aggregate.branchName,
})
),
};
} }
} }

View file

@ -3,7 +3,10 @@ import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper';
import { getProjectsBasePath } from '@main/utils/pathDecoder'; import { getProjectsBasePath } from '@main/utils/pathDecoder';
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; 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 { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
import type { ServiceContext } from '@main/services'; import type { ServiceContext } from '@main/services';
import type { RepositoryGroup, Worktree } from '@main/types'; import type { RepositoryGroup, Worktree } from '@main/types';
@ -47,7 +50,7 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo
private readonly logger: LoggerPort private readonly logger: LoggerPort
) {} ) {}
async list(): Promise<RecentProjectCandidate[]> { async list(): Promise<RecentProjectsSourceResult> {
const activeContext = this.getActiveContext(); const activeContext = this.getActiveContext();
const groups = const groups =
activeContext.type === 'local' activeContext.type === 'local'
@ -63,7 +66,10 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo
contextId: activeContext.id, contextId: activeContext.id,
}); });
return candidates; return {
candidates,
degraded: false,
};
} }
async #groupLocalProjects(activeContext: ServiceContext): Promise<RepositoryGroup[]> { async #groupLocalProjects(activeContext: ServiceContext): Promise<RepositoryGroup[]> {

View file

@ -2,7 +2,10 @@ import { normalizeIdentityPath } from '@features/recent-projects/main/infrastruc
import path from 'path'; import path from 'path';
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; 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 { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
import type { import type {
CodexAppServerClient, CodexAppServerClient,
@ -34,6 +37,10 @@ function normalizeTimestamp(value: number | undefined): number {
return value < 1_000_000_000_000 ? value * 1000 : value; 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 { export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
readonly sourceId = 'codex'; readonly sourceId = 'codex';
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS; readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
@ -49,21 +56,28 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
} }
) {} ) {}
async list(): Promise<RecentProjectCandidate[]> { async list(): Promise<RecentProjectsSourceResult> {
const activeContext = this.deps.getActiveContext(); const activeContext = this.deps.getActiveContext();
const localContext = this.deps.getLocalContext(); const localContext = this.deps.getLocalContext();
if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) {
return []; return {
candidates: [],
degraded: false,
};
} }
const binaryPath = await this.deps.resolveBinary(); const binaryPath = await this.deps.resolveBinary();
if (!binaryPath) { if (!binaryPath) {
this.deps.logger.info('codex recent-projects source skipped - binary unavailable'); this.deps.logger.info('codex recent-projects source skipped - binary unavailable');
return []; return {
candidates: [],
degraded: false,
};
} }
const threadSegments = await this.#listRecentThreadsSafe(binaryPath); const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
const degraded = isDegradedThreadResult(threadSegments);
this.#logSegmentFailure(threadSegments, 'live'); this.#logSegmentFailure(threadSegments, 'live');
this.#logSegmentFailure(threadSegments, 'archived'); this.#logSegmentFailure(threadSegments, 'archived');
const liveThreads = threadSegments.live.threads; const liveThreads = threadSegments.live.threads;
@ -79,9 +93,13 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
this.deps.logger.info('codex recent-projects source loaded', { this.deps.logger.info('codex recent-projects source loaded', {
count: candidates.length, count: candidates.length,
degraded,
}); });
return candidates; return {
candidates,
degraded,
};
} }
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> { async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {

View file

@ -10,11 +10,14 @@ import { RecentProjectIdentityResolver } from '../infrastructure/identity/Recent
import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { ClockPort } from '../../core/application/ports/ClockPort';
import type { LoggerPort } from '../../core/application/ports/LoggerPort'; 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'; import type { ServiceContext } from '@main/services';
export interface RecentProjectsFeatureFacade { export interface RecentProjectsFeatureFacade {
listDashboardRecentProjects(): Promise<DashboardRecentProject[]>; listDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
} }
export function createRecentProjectsFeature(deps: { export function createRecentProjectsFeature(deps: {
@ -22,7 +25,7 @@ export function createRecentProjectsFeature(deps: {
getLocalContext: () => ServiceContext | undefined; getLocalContext: () => ServiceContext | undefined;
logger: LoggerPort; logger: LoggerPort;
}): RecentProjectsFeatureFacade { }): RecentProjectsFeatureFacade {
const cache = new InMemoryRecentProjectsCache<DashboardRecentProject[]>(); const cache = new InMemoryRecentProjectsCache<DashboardRecentProjectsPayload>();
const presenter = new DashboardRecentProjectsPresenter(); const presenter = new DashboardRecentProjectsPresenter();
const clock: ClockPort = { now: () => Date.now() }; const clock: ClockPort = { now: () => Date.now() };
const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger);
@ -48,9 +51,10 @@ export function createRecentProjectsFeature(deps: {
}); });
return { return {
listDashboardRecentProjects: () => { listDashboardRecentProjects: async () => {
const activeContext = deps.getActiveContext(); 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 };
}, },
}; };
} }

View file

@ -23,6 +23,9 @@ import type { TeamSummary } from '@shared/types';
const INITIAL_RECENT_PROJECTS = 11; const INITIAL_RECENT_PROJECTS = 11;
const LOAD_MORE_STEP = 8; 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 { function matchesSearch(project: DashboardRecentProject, query: string): boolean {
if (!query) { if (!query) {
@ -72,7 +75,13 @@ export function useRecentProjectsSection(
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>( const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
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 [loading, setLoading] = useState(initialSnapshot == null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -80,7 +89,9 @@ export function useRecentProjectsSection(
const [aliveTeams, setAliveTeams] = useState<string[]>([]); const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [openHistoryVersion, setOpenHistoryVersion] = useState(0); const [openHistoryVersion, setOpenHistoryVersion] = useState(0);
const hasFetchedTasksRef = useRef(globalTasksInitialized); const hasFetchedTasksRef = useRef(globalTasksInitialized);
const recentProjectsRef = useRef<DashboardRecentProject[]>(initialSnapshot?.projects ?? []); const recentProjectsRef = useRef<DashboardRecentProject[]>(
initialSnapshot?.payload.projects ?? []
);
useEffect(() => { useEffect(() => {
recentProjectsRef.current = recentProjects; recentProjectsRef.current = recentProjects;
@ -95,11 +106,13 @@ export function useRecentProjectsSection(
} }
setError(null); setError(null);
try { try {
const projects = await loadRecentProjectsWithClientCache( const payload = await loadRecentProjectsWithClientCache(
() => api.getDashboardRecentProjects(), () => api.getDashboardRecentProjects(),
options options
); );
setRecentProjects(projects); setRecentProjects(payload.projects);
setRecentProjectsDegraded(payload.degraded);
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
} catch (nextError) { } catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
} finally { } finally {
@ -116,6 +129,25 @@ export function useRecentProjectsSection(
void reload({ force: snapshot != null }); void reload({ force: snapshot != null });
}, [reload]); }, [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(() => { useEffect(() => {
if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) { if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) {
hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized; hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized;

View file

@ -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_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 cachedAt = 0;
let inFlightLoad: Promise<DashboardRecentProject[]> | null = null; let inFlightLoad: Promise<DashboardRecentProjectsPayload> | null = null;
export interface RecentProjectsClientSnapshot { export interface RecentProjectsClientSnapshot {
projects: DashboardRecentProject[]; payload: DashboardRecentProjectsPayload;
fetchedAt: number; fetchedAt: number;
isStale: boolean; isStale: boolean;
} }
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null {
if (!cachedProjects) { const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload);
if (!normalizedPayload) {
return null; 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 { return {
projects: cachedProjects, payload: normalizedPayload,
fetchedAt: cachedAt, fetchedAt: cachedAt,
isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS, isStale: Date.now() - cachedAt > ttlMs,
}; };
} }
export async function loadRecentProjectsWithClientCache( export async function loadRecentProjectsWithClientCache(
loader: () => Promise<DashboardRecentProject[]>, loader: () => Promise<DashboardRecentProjectsPayloadLike>,
options?: { force?: boolean } options?: { force?: boolean }
): Promise<DashboardRecentProject[]> { ): Promise<DashboardRecentProjectsPayload> {
const force = options?.force ?? false; const force = options?.force ?? false;
const snapshot = getRecentProjectsClientSnapshot(); const snapshot = getRecentProjectsClientSnapshot();
if (!force && snapshot && !snapshot.isStale) { if (!force && snapshot && !snapshot.isStale) {
return snapshot.projects; return snapshot.payload;
} }
if (inFlightLoad) { if (inFlightLoad) {
@ -40,10 +54,11 @@ export async function loadRecentProjectsWithClientCache(
} }
const request = loader() const request = loader()
.then((projects) => { .then((payloadLike) => {
cachedProjects = projects; const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike);
cachedPayload = normalizedPayload;
cachedAt = Date.now(); cachedAt = Date.now();
return projects; return normalizedPayload ?? { projects: [], degraded: true };
}) })
.finally(() => { .finally(() => {
if (inFlightLoad === request) { if (inFlightLoad === request) {
@ -56,7 +71,7 @@ export async function loadRecentProjectsWithClientCache(
} }
export function __resetRecentProjectsClientCacheForTests(): void { export function __resetRecentProjectsClientCacheForTests(): void {
cachedProjects = null; cachedPayload = null;
cachedAt = 0; cachedAt = 0;
inFlightLoad = null; inFlightLoad = null;
} }

View file

@ -6,7 +6,7 @@
* to run in a regular browser connected to an HTTP server. * 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 { import type {
AppConfig, AppConfig,
AttachmentFileData, AttachmentFileData,
@ -218,8 +218,8 @@ export class HttpAPIClient implements ElectronAPI {
getAppVersion = (): Promise<string> => this.get<string>('/api/version'); getAppVersion = (): Promise<string> => this.get<string>('/api/version');
getDashboardRecentProjects = (): Promise<DashboardRecentProject[]> => getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
this.get<DashboardRecentProject[]>('/api/dashboard/recent-projects'); this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects');
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects'); getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');

View file

@ -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();
});
});

View file

@ -140,6 +140,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
sources: ['mixed'], sources: ['mixed'],
}); });
expect(output.present).toHaveBeenCalledWith({ expect(output.present).toHaveBeenCalledWith({
degraded: true,
projects: [ projects: [
expect.objectContaining({ expect.objectContaining({
identity: 'repo:alpha', identity: 'repo:alpha',
@ -299,6 +300,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
sources: ['claude'], sources: ['claude'],
}); });
expect(output.present).toHaveBeenCalledWith({ expect(output.present).toHaveBeenCalledWith({
degraded: true,
projects: [ projects: [
expect.objectContaining({ expect.objectContaining({
identity: 'repo:fresh', identity: 'repo:fresh',
@ -370,4 +372,73 @@ describe('ListDashboardRecentProjectsUseCase', () => {
durationMs: 200, durationMs: 200,
}); });
}); });
it('treats explicitly degraded source payloads as degraded even when they resolve successfully', async () => {
const cache: RecentProjectsCachePort<TestViewModel> = {
get: vi.fn().mockResolvedValue(null),
getStale: vi.fn().mockResolvedValue(null),
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([
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,
});
});
}); });

View file

@ -57,12 +57,15 @@ describe('CodexRecentProjectsSourceAdapter', () => {
logger, logger,
}); });
await expect(adapter.list()).resolves.toEqual([ await expect(adapter.list()).resolves.toEqual({
expect.objectContaining({ candidates: [
identity: 'repo:headless', expect.objectContaining({
primaryPath: '/Users/belief/dev/projects/headless', identity: 'repo:headless',
}), primaryPath: '/Users/belief/dev/projects/headless',
]); }),
],
degraded: true,
});
expect(logger.info).toHaveBeenCalledWith( expect(logger.info).toHaveBeenCalledWith(
'codex recent-projects archived thread list degraded', 'codex recent-projects archived thread list degraded',
@ -110,20 +113,23 @@ describe('CodexRecentProjectsSourceAdapter', () => {
logger, logger,
}); });
await expect(adapter.list()).resolves.toEqual([ await expect(adapter.list()).resolves.toEqual({
expect.objectContaining({ candidates: [
identity: 'repo:headless', expect.objectContaining({
displayName: 'headless', identity: 'repo:headless',
primaryPath: '/Users/belief/dev/projects/headless', displayName: 'headless',
providerIds: ['codex'], primaryPath: '/Users/belief/dev/projects/headless',
sourceKind: 'codex', providerIds: ['codex'],
openTarget: { sourceKind: 'codex',
type: 'synthetic-path', openTarget: {
path: '/Users/belief/dev/projects/headless', type: 'synthetic-path',
}, path: '/Users/belief/dev/projects/headless',
branchName: 'main', },
}), branchName: 'main',
]); }),
],
degraded: true,
});
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1); expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1); expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1);
@ -153,7 +159,10 @@ describe('CodexRecentProjectsSourceAdapter', () => {
logger, logger,
}); });
await expect(adapter.list()).resolves.toEqual([]); await expect(adapter.list()).resolves.toEqual({
candidates: [],
degraded: true,
});
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
}); });
}); });

View file

@ -6,7 +6,10 @@ import {
loadRecentProjectsWithClientCache, loadRecentProjectsWithClientCache,
} from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; } 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 => ({ const project = (id: string): DashboardRecentProject => ({
id, id,
@ -22,6 +25,15 @@ const project = (id: string): DashboardRecentProject => ({
}, },
}); });
const payload = (
id: string,
overrides: Partial<DashboardRecentProjectsPayload> = {}
): DashboardRecentProjectsPayload => ({
projects: [project(id)],
degraded: false,
...overrides,
});
describe('recentProjectsClientCache', () => { describe('recentProjectsClientCache', () => {
afterEach(() => { afterEach(() => {
__resetRecentProjectsClientCacheForTests(); __resetRecentProjectsClientCacheForTests();
@ -30,13 +42,13 @@ describe('recentProjectsClientCache', () => {
}); });
it('returns cached projects while the client cache is fresh', async () => { 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(payload('alpha'));
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
expect(loader).toHaveBeenCalledTimes(1); 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 () => { 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')); vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
const loader = vi const loader = vi
.fn<() => Promise<DashboardRecentProject[]>>() .fn<() => Promise<DashboardRecentProjectsPayload>>()
.mockResolvedValueOnce([project('alpha')]) .mockResolvedValueOnce(payload('alpha'))
.mockResolvedValueOnce([project('beta')]); .mockResolvedValueOnce(payload('beta'));
await loadRecentProjectsWithClientCache(loader); await loadRecentProjectsWithClientCache(loader);
vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z')); vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({ expect(getRecentProjectsClientSnapshot()).toMatchObject({
projects: [project('alpha')], payload: payload('alpha'),
isStale: true, isStale: true,
}); });
await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([ await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual(
project('beta'), payload('beta')
]); );
expect(loader).toHaveBeenCalledTimes(2); expect(loader).toHaveBeenCalledTimes(2);
expect(getRecentProjectsClientSnapshot()).toMatchObject({ expect(getRecentProjectsClientSnapshot()).toMatchObject({
projects: [project('beta')], payload: payload('beta'),
isStale: false, isStale: false,
}); });
}); });
it('deduplicates concurrent client refreshes', async () => { it('deduplicates concurrent client refreshes', async () => {
const resolveLoaderRef: { const resolveLoaderRef: {
current: ((projects: DashboardRecentProject[]) => void) | null; current: ((payload: DashboardRecentProjectsPayload) => void) | null;
} = { } = {
current: null, current: null,
}; };
const loader = vi.fn( const loader = vi.fn(
() => () =>
new Promise<DashboardRecentProject[]>((resolve) => { new Promise<DashboardRecentProjectsPayload>((resolve) => {
resolveLoaderRef.current = resolve; resolveLoaderRef.current = resolve;
}) })
); );
@ -85,9 +97,41 @@ describe('recentProjectsClientCache', () => {
expect(loader).toHaveBeenCalledTimes(1); expect(loader).toHaveBeenCalledTimes(1);
resolveLoaderRef.current?.([project('alpha')]); resolveLoaderRef.current?.(payload('alpha'));
await expect(first).resolves.toEqual([project('alpha')]); await expect(first).resolves.toEqual(payload('alpha'));
await expect(second).resolves.toEqual([project('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<DashboardRecentProjectsPayload>>()
.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<DashboardRecentProject[]>>().mockResolvedValue([project('alpha')]);
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha'));
}); });
}); });