fix(recent-projects): recover codex projects after degraded startup
This commit is contained in:
parent
4630442149
commit
345fd3e41d
20 changed files with 437 additions and 107 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import type { DashboardRecentProject } from './dto';
|
||||
import type { DashboardRecentProjectsPayload } from './dto';
|
||||
|
||||
export interface RecentProjectsElectronApi {
|
||||
getDashboardRecentProjects(): Promise<DashboardRecentProject[]>;
|
||||
getDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,8 @@ export interface DashboardRecentProject {
|
|||
openTarget: DashboardRecentProjectOpenTarget;
|
||||
primaryBranch?: string;
|
||||
}
|
||||
|
||||
export interface DashboardRecentProjectsPayload {
|
||||
projects: DashboardRecentProject[];
|
||||
degraded: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export type * from './api';
|
||||
export * from './channels';
|
||||
export type * from './dto';
|
||||
export * from './normalize';
|
||||
|
|
|
|||
31
src/features/recent-projects/contracts/normalize.ts
Normal file
31
src/features/recent-projects/contracts/normalize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,4 +2,5 @@ import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAg
|
|||
|
||||
export interface ListDashboardRecentProjectsResponse {
|
||||
projects: RecentProjectAggregate[];
|
||||
degraded: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -217,8 +217,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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue