fix(recent-projects): recover codex projects after degraded startup
This commit is contained in:
parent
1173a4942a
commit
9437220133
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 {
|
export interface RecentProjectsElectronApi {
|
||||||
getDashboardRecentProjects(): Promise<DashboardRecentProject[]>;
|
getDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,8 @@ export interface DashboardRecentProject {
|
||||||
openTarget: DashboardRecentProjectOpenTarget;
|
openTarget: DashboardRecentProjectOpenTarget;
|
||||||
primaryBranch?: string;
|
primaryBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardRecentProjectsPayload {
|
||||||
|
projects: DashboardRecentProject[];
|
||||||
|
degraded: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
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 {
|
export interface ListDashboardRecentProjectsResponse {
|
||||||
projects: RecentProjectAggregate[];
|
projects: RecentProjectAggregate[];
|
||||||
|
degraded: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]> {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue