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 {
getDashboardRecentProjects(): Promise<DashboardRecentProject[]>;
getDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
}

View file

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

View file

@ -1,3 +1,4 @@
export type * from './api';
export * from './channels';
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 {
projects: RecentProjectAggregate[];
degraded: boolean;
}

View file

@ -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<RecentProjectCandidate[]>;
list(): Promise<RecentProjectsSourcePayload>;
}

View file

@ -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<TViewModel> {
sources: RecentProjectsSourcePort[];
cache: RecentProjectsCachePort<TViewModel>;
@ -66,6 +84,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
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<TViewModel> {
source
.list()
.then(
(candidates) =>
(payload) =>
({
kind: 'success',
candidates,
payload: normalizeSourcePayload(payload),
}) as const
)
.catch(
@ -130,7 +149,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
]);
if (result.kind === 'success') {
return { candidates: result.candidates, degraded: false };
return result.payload;
}
if (result.kind === 'timeout') {
@ -161,10 +180,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
sourceIndex: number
): Promise<SourceLoadResult> {
try {
return {
candidates: await source.list(),
degraded: false,
};
return normalizeSourcePayload(await source.list());
} catch (error) {
this.deps.logger.warn('recent-projects source failed', {
sourceId,

View file

@ -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<DashboardRecentProject[]> => {
app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise<DashboardRecentProjectsPayload> => {
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 };
}
});
}

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

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 { 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<DashboardRecentProjectsPayload> {
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,
})
),
};
}
}

View file

@ -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<RecentProjectCandidate[]> {
async list(): Promise<RecentProjectsSourceResult> {
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<RepositoryGroup[]> {

View file

@ -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<RecentProjectCandidate[]> {
async list(): Promise<RecentProjectsSourceResult> {
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<CodexRecentThreadsResult> {

View file

@ -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<DashboardRecentProject[]>;
listDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
}
export function createRecentProjectsFeature(deps: {
@ -22,7 +25,7 @@ export function createRecentProjectsFeature(deps: {
getLocalContext: () => ServiceContext | undefined;
logger: LoggerPort;
}): RecentProjectsFeatureFacade {
const cache = new InMemoryRecentProjectsCache<DashboardRecentProject[]>();
const cache = new InMemoryRecentProjectsCache<DashboardRecentProjectsPayload>();
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 };
},
};
}

View file

@ -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<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 [error, setError] = useState<string | null>(null);
@ -80,7 +89,9 @@ export function useRecentProjectsSection(
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [openHistoryVersion, setOpenHistoryVersion] = useState(0);
const hasFetchedTasksRef = useRef(globalTasksInitialized);
const recentProjectsRef = useRef<DashboardRecentProject[]>(initialSnapshot?.projects ?? []);
const recentProjectsRef = useRef<DashboardRecentProject[]>(
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;

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_DEGRADED_CACHE_TTL_MS = 1_500;
let cachedProjects: DashboardRecentProject[] | null = null;
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
let cachedAt = 0;
let inFlightLoad: Promise<DashboardRecentProject[]> | null = null;
let inFlightLoad: Promise<DashboardRecentProjectsPayload> | 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<DashboardRecentProject[]>,
loader: () => Promise<DashboardRecentProjectsPayloadLike>,
options?: { force?: boolean }
): Promise<DashboardRecentProject[]> {
): Promise<DashboardRecentProjectsPayload> {
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;
}

View file

@ -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<string> => this.get<string>('/api/version');
getDashboardRecentProjects = (): Promise<DashboardRecentProject[]> =>
this.get<DashboardRecentProject[]>('/api/dashboard/recent-projects');
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-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'],
});
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<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,
});
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();
});
});

View file

@ -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> = {}
): 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<DashboardRecentProject[]>>()
.mockResolvedValueOnce([project('alpha')])
.mockResolvedValueOnce([project('beta')]);
.fn<() => Promise<DashboardRecentProjectsPayload>>()
.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<DashboardRecentProject[]>((resolve) => {
new Promise<DashboardRecentProjectsPayload>((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<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'));
});
});