fix: harden recent project and sidebar selection state
This commit is contained in:
parent
d8fce1b3a3
commit
487dcabff5
6 changed files with 131 additions and 11 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
export interface RecentProjectsCachePort<T> {
|
export interface RecentProjectsCachePort<T> {
|
||||||
get(key: string): Promise<T | null>;
|
get(key: string): Promise<T | null>;
|
||||||
|
getStale(key: string): Promise<T | null>;
|
||||||
set(key: string, value: T, ttlMs: number): Promise<void>;
|
set(key: string, value: T, ttlMs: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
|
|
||||||
async #executeUncached(cacheKey: string): Promise<TViewModel> {
|
async #executeUncached(cacheKey: string): Promise<TViewModel> {
|
||||||
const startedAt = this.deps.clock.now();
|
const startedAt = this.deps.clock.now();
|
||||||
|
const stale = await this.deps.cache.getStale(cacheKey);
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
this.deps.sources.map((source, index) => this.#loadSource(source, index))
|
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 successful = results.flatMap((result) => result.candidates);
|
||||||
const hasDegradedSources = results.some((result) => result.degraded);
|
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 = {
|
const response: ListDashboardRecentProjectsResponse = {
|
||||||
projects: mergeRecentProjectCandidates(successful),
|
projects: mergeRecentProjectCandidates(successful),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,16 @@ export class InMemoryRecentProjectsCache<T> implements RecentProjectsCachePort<T
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.expiresAt <= Date.now()) {
|
if (entry.expiresAt <= Date.now()) {
|
||||||
this.#entries.delete(key);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.value;
|
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> {
|
async set(key: string, value: T, ttlMs: number): Promise<void> {
|
||||||
this.#entries.set(key, {
|
this.#entries.set(key, {
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,23 @@ export function resolveEffectiveSelectedRepositoryId({
|
||||||
selectedRepositoryId,
|
selectedRepositoryId,
|
||||||
effectiveSelectedWorktreeId,
|
effectiveSelectedWorktreeId,
|
||||||
}: ResolveEffectiveSelectedRepositoryIdInput): string | null {
|
}: 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;
|
return selectedRepositoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveSelectedWorktreeId) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
repositoryGroups.find((repo) =>
|
|
||||||
repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId)
|
|
||||||
)?.id ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
const cached: TestViewModel = { ids: ['cached'], sources: ['cached'] };
|
const cached: TestViewModel = { ids: ['cached'], sources: ['cached'] };
|
||||||
const cache: RecentProjectsCachePort<TestViewModel> = {
|
const cache: RecentProjectsCachePort<TestViewModel> = {
|
||||||
get: vi.fn().mockResolvedValue(cached),
|
get: vi.fn().mockResolvedValue(cached),
|
||||||
|
getStale: vi.fn().mockResolvedValue(cached),
|
||||||
set: vi.fn(),
|
set: vi.fn(),
|
||||||
};
|
};
|
||||||
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||||
|
|
@ -77,6 +78,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
it('merges successful sources, degrades failed sources, and caches presenter output', async () => {
|
it('merges successful sources, degrades failed sources, and caches presenter output', async () => {
|
||||||
const cache: RecentProjectsCachePort<TestViewModel> = {
|
const cache: RecentProjectsCachePort<TestViewModel> = {
|
||||||
get: vi.fn().mockResolvedValue(null),
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
getStale: vi.fn().mockResolvedValue(null),
|
||||||
set: vi.fn().mockResolvedValue(undefined),
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||||
|
|
@ -172,6 +174,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
try {
|
try {
|
||||||
const cache: RecentProjectsCachePort<TestViewModel> = {
|
const cache: RecentProjectsCachePort<TestViewModel> = {
|
||||||
get: vi.fn().mockResolvedValue(null),
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
getStale: vi.fn().mockResolvedValue(null),
|
||||||
set: vi.fn().mockResolvedValue(undefined),
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||||
|
|
@ -244,4 +247,61 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
vi.useRealTimers();
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,42 @@ describe('resolveEffectiveSelectedRepositoryId', () => {
|
||||||
})
|
})
|
||||||
).toBe('repo-headless');
|
).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue