diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 901db6f4..2c135a76 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -607,6 +607,57 @@ describe('SessionService', () => { expect(scanCount).toBe(3) }) + it('should reuse unchanged file summaries after the list response cache is cleared', async () => { + const sessionFiles: Array<{ id: string; filePath: string }> = [] + for (let i = 0; i < 3; i++) { + const id = `2500000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee` + const filePath = await writeSessionFile('-tmp-file-summary-cache', id, [ + makeSnapshotEntry(), + makeUserEntry(`Cached file summary ${i}`), + ]) + const mtime = new Date(Date.now() - i * 1000) + await fs.utimes(filePath, mtime, mtime) + sessionFiles.push({ id, filePath }) + } + + const serviceWithSpy = service as unknown as { + scanSessionListSummary: (...args: unknown[]) => Promise + } + const serviceInternals = service as unknown as { + sessionListCache: Map + } + const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service) + let scanCount = 0 + serviceWithSpy.scanSessionListSummary = async (...args) => { + scanCount += 1 + return originalScanSessionListSummary(...args) + } + + await service.listSessions({ limit: 3, offset: 0 }) + expect(scanCount).toBe(3) + + serviceInternals.sessionListCache.clear() + const second = await service.listSessions({ limit: 3, offset: 0 }) + expect(second.sessions).toHaveLength(3) + expect(scanCount).toBe(3) + + await fs.appendFile( + sessionFiles[1]!.filePath, + `${JSON.stringify({ + type: 'custom-title', + customTitle: 'Changed cached file summary', + timestamp: new Date().toISOString(), + })}\n`, + 'utf-8', + ) + serviceInternals.sessionListCache.clear() + + const third = await service.listSessions({ limit: 3, offset: 0 }) + expect(third.sessions.find((session) => session.id === sessionFiles[1]!.id)?.title) + .toBe('Changed cached file summary') + expect(scanCount).toBe(4) + }) + it('should invalidate cached list metadata after writes', async () => { const sessionId = '30000000-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-cache-invalidation', sessionId, [ diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 303eb101..3a46c109 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -5,7 +5,7 @@ * 确保 Desktop App 与 CLI 的数据完全互通。 */ -import { createReadStream } from 'node:fs' +import { createReadStream, type Stats } from 'node:fs' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' @@ -286,6 +286,12 @@ type SessionListSummary = { worktreeSession?: PersistedWorktreeSession | null } +type SessionListSummaryCacheEntry = { + mtimeMs: number + size: number + summary: SessionListSummary +} + const VALID_SESSION_PERMISSION_MODES = new Set([ 'default', 'acceptEdits', @@ -318,6 +324,7 @@ export class SessionService { expiresAt: number result: { sessions: SessionListItem[]; total: number } }>() + private readonly sessionListSummaryCache = new Map() private sessionListCacheKey(options?: { project?: string @@ -342,6 +349,35 @@ export class SessionService { this.sessionListCache.clear() } + private cloneSessionListSummary(summary: SessionListSummary): SessionListSummary { + return { + ...summary, + repository: summary.repository ? { ...summary.repository } : undefined, + worktreeSession: summary.worktreeSession + ? { ...summary.worktreeSession } + : summary.worktreeSession, + } + } + + private async getCachedSessionListSummary( + filePath: string, + projectDir: string, + stat: Stats, + ): Promise { + const cached = this.sessionListSummaryCache.get(filePath) + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return this.cloneSessionListSummary(cached.summary) + } + + const summary = await this.scanSessionListSummary(filePath, projectDir, stat) + this.sessionListSummaryCache.set(filePath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + summary: this.cloneSessionListSummary(summary), + }) + return summary + } + // -------------------------------------------------------------------------- // Config helpers // -------------------------------------------------------------------------- @@ -1746,7 +1782,7 @@ export class SessionService { const items: SessionListItem[] = [] for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) { try { - const summary = await this.scanSessionListSummary(filePath, projectDir, stat) + const summary = await this.getCachedSessionListSummary(filePath, projectDir, stat) const workDir = summary.workDir const projectRoot = await this.resolveProjectRootFromSessionMetadata({ worktreeSession: summary.worktreeSession,