fix: harden recent project and sidebar selection state

This commit is contained in:
777genius 2026-04-14 17:54:23 +03:00
parent d8fce1b3a3
commit 487dcabff5
6 changed files with 131 additions and 11 deletions

View file

@ -1,4 +1,5 @@
export interface RecentProjectsCachePort<T> {
get(key: string): Promise<T | null>;
getStale(key: string): Promise<T | null>;
set(key: string, value: T, ttlMs: number): Promise<void>;
}

View file

@ -57,6 +57,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
async #executeUncached(cacheKey: string): Promise<TViewModel> {
const startedAt = this.deps.clock.now();
const stale = await this.deps.cache.getStale(cacheKey);
const results = await Promise.all(
this.deps.sources.map((source, index) => this.#loadSource(source, index))
);
@ -64,6 +65,17 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
const successful = results.flatMap((result) => result.candidates);
const hasDegradedSources = results.some((result) => result.degraded);
if (hasDegradedSources && stale) {
await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs);
this.deps.logger.info('recent-projects served stale cache', {
cacheKey,
degradedSources: results.filter((result) => result.degraded).length,
cacheTtlMs: this.#degradedCacheTtlMs,
durationMs: this.deps.clock.now() - startedAt,
});
return stale;
}
const response: ListDashboardRecentProjectsResponse = {
projects: mergeRecentProjectCandidates(successful),
};

View file

@ -15,13 +15,16 @@ export class InMemoryRecentProjectsCache<T> implements RecentProjectsCachePort<T
}
if (entry.expiresAt <= Date.now()) {
this.#entries.delete(key);
return null;
}
return entry.value;
}
async getStale(key: string): Promise<T | null> {
return this.#entries.get(key)?.value ?? null;
}
async set(key: string, value: T, ttlMs: number): Promise<void> {
this.#entries.set(key, {
value,

View file

@ -11,17 +11,23 @@ export function resolveEffectiveSelectedRepositoryId({
selectedRepositoryId,
effectiveSelectedWorktreeId,
}: ResolveEffectiveSelectedRepositoryIdInput): string | null {
if (selectedRepositoryId) {
const worktreeOwnerRepositoryId =
effectiveSelectedWorktreeId == null
? null
: (repositoryGroups.find((repo) =>
repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId)
)?.id ?? null);
if (worktreeOwnerRepositoryId) {
return worktreeOwnerRepositoryId;
}
if (
selectedRepositoryId &&
repositoryGroups.some((repositoryGroup) => repositoryGroup.id === selectedRepositoryId)
) {
return selectedRepositoryId;
}
if (!effectiveSelectedWorktreeId) {
return null;
}
return (
repositoryGroups.find((repo) =>
repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId)
)?.id ?? null
);
return null;
}

View file

@ -50,6 +50,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
const cached: TestViewModel = { ids: ['cached'], sources: ['cached'] };
const cache: RecentProjectsCachePort<TestViewModel> = {
get: vi.fn().mockResolvedValue(cached),
getStale: vi.fn().mockResolvedValue(cached),
set: vi.fn(),
};
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
@ -77,6 +78,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
it('merges successful sources, degrades failed sources, and caches presenter output', async () => {
const cache: RecentProjectsCachePort<TestViewModel> = {
get: vi.fn().mockResolvedValue(null),
getStale: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
};
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
@ -172,6 +174,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
try {
const cache: RecentProjectsCachePort<TestViewModel> = {
get: vi.fn().mockResolvedValue(null),
getStale: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
};
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
@ -244,4 +247,61 @@ describe('ListDashboardRecentProjectsUseCase', () => {
vi.useRealTimers();
}
});
it('returns stale cached data when a source degrades after cache expiry', async () => {
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
const cache: RecentProjectsCachePort<TestViewModel> = {
get: vi.fn().mockResolvedValue(null),
getStale: vi.fn().mockResolvedValue(stale),
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:fresh',
providerIds: ['anthropic'],
sourceKind: 'claude',
}),
]),
},
{
sourceId: 'codex',
list: vi.fn().mockRejectedValue(new Error('codex unavailable')),
},
];
const logger = createLogger();
let now = 15_000;
const useCase = new ListDashboardRecentProjectsUseCase({
sources,
cache,
output,
clock: {
now: () => {
const current = now;
now += 200;
return current;
},
},
logger,
});
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
expect(output.present).not.toHaveBeenCalled();
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500);
expect(logger.info).toHaveBeenCalledWith('recent-projects served stale cache', {
cacheKey: 'recent-projects:stale',
degradedSources: 1,
cacheTtlMs: 1_500,
durationMs: 200,
});
});
});

View file

@ -50,4 +50,42 @@ describe('resolveEffectiveSelectedRepositoryId', () => {
})
).toBe('repo-headless');
});
it('falls back to the worktree owner when the explicit repository selection is stale', () => {
const repositoryGroups = [
{
id: 'repo-headless',
worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }],
},
] as const;
expect(
resolveEffectiveSelectedRepositoryId({
repositoryGroups,
selectedRepositoryId: 'repo-stale',
effectiveSelectedWorktreeId: 'worktree-headless',
})
).toBe('repo-headless');
});
it('prefers the repository that owns the active worktree over a different valid repository', () => {
const repositoryGroups = [
{
id: 'repo-headless',
worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }],
},
{
id: 'repo-other',
worktrees: [{ id: 'worktree-other', path: '/Users/belief/dev/projects/other' }],
},
] as const;
expect(
resolveEffectiveSelectedRepositoryId({
repositoryGroups,
selectedRepositoryId: 'repo-other',
effectiveSelectedWorktreeId: 'worktree-headless',
})
).toBe('repo-headless');
});
});