From 23eb6ce9b64e017f9fc9f4ca7ca4d9e2df695214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 22 Sep 2026 09:16:14 +0800 Subject: [PATCH] fix(sessions): persist collaboration titles in local index --- .../local-index-session-parity.test.ts | 50 +++++++++++++++++++ src/server/services/localIndex/coordinator.ts | 10 ++++ .../services/localIndex/sessionIndex.ts | 10 ++++ .../localIndex/sessionProjector.test.ts | 41 +++++++++++++++ src/server/services/sessionService.ts | 14 ++++++ 5 files changed, 125 insertions(+) diff --git a/src/server/__tests__/local-index-session-parity.test.ts b/src/server/__tests__/local-index-session-parity.test.ts index eed937b7..7eccc44c 100644 --- a/src/server/__tests__/local-index-session-parity.test.ts +++ b/src/server/__tests__/local-index-session-parity.test.ts @@ -40,6 +40,7 @@ class FakeLocalIndexGateway implements LocalIndexGateway { buildOnList = false listCalls = 0 findCalls = 0 + titleWrites: Array<{ sessionId: string; title: string }> = [] lastListOptions: { project?: string; limit?: number; offset?: number } | undefined async start(): Promise {} @@ -67,6 +68,19 @@ class FakeLocalIndexGateway implements LocalIndexGateway { getSession(sessionId: string): IndexedSessionRow | null { return this.page.sessions.find(session => session.id === sessionId) ?? null } + updateSessionTitle(sessionId: string, title: string): boolean { + this.titleWrites.push({ sessionId, title }) + let changed = false + this.page = { + ...this.page, + sessions: this.page.sessions.map(session => { + if (session.id !== sessionId) return session + changed = true + return { ...session, title } + }), + } + return changed + } setReady(mode: LocalIndexMode = 'on'): void { this.mode = mode @@ -659,6 +673,42 @@ describe('SessionService local-index routing parity', () => { expect(gateway.listCalls).toBe(2) }) + it('persists a collaboration metadata title into the index before a cold restart', async () => { + const projectDir = '-tmp-project' + const filePath = await writeSession( + projectDir, + SESSION_A, + 'Initial child title', + '2026-07-15T00:00:00.000Z', + ) + const gateway = new FakeLocalIndexGateway() + gateway.setReady() + gateway.page = { + sessions: [indexedRow(filePath, projectDir, SESSION_A, 'Initial child title')], + total: 1, + } + gateway.matches = [{ filePath, projectDir }] + const writer = new SessionService(gateway) + + await writer.appendSessionMetadata(SESSION_A, { + workDir: configDir, + customTitle: '最终子会话标题', + }) + + expect(gateway.titleWrites).toContainEqual({ + sessionId: SESSION_A, + title: '最终子会话标题', + }) + const restartedGateway = new FakeLocalIndexGateway() + restartedGateway.setReady() + restartedGateway.page = { + sessions: gateway.page.sessions.map(session => ({ ...session })), + total: gateway.page.total, + } + const restarted = new SessionService(restartedGateway) + expect((await restarted.listSessions()).sessions[0]?.title).toBe('最终子会话标题') + }) + it('isolates shared mutation epochs between different gateways', async () => { const projectDir = '-tmp-project' const filePath = await writeSession( diff --git a/src/server/services/localIndex/coordinator.ts b/src/server/services/localIndex/coordinator.ts index 953f286d..b5eb9230 100644 --- a/src/server/services/localIndex/coordinator.ts +++ b/src/server/services/localIndex/coordinator.ts @@ -1523,6 +1523,16 @@ export function createLocalIndexCoordinator( } }, + updateSessionTitle(sessionId: string, title: string): boolean { + if (!synchronizeRuntimeConfiguration() || !index?.updateSessionTitle) return false + try { + return index.updateSessionTitle(sessionId, title) + } catch (error) { + markDegraded(error, 'LOCAL_INDEX_TITLE_WRITE_FAILED') + return false + } + }, + getSessionEntryLocators( transcriptPath: string, entryTypes?: string[], diff --git a/src/server/services/localIndex/sessionIndex.ts b/src/server/services/localIndex/sessionIndex.ts index 82868adc..9b11f634 100644 --- a/src/server/services/localIndex/sessionIndex.ts +++ b/src/server/services/localIndex/sessionIndex.ts @@ -80,6 +80,7 @@ export interface LocalIndexGateway extends SessionIndexReader { isActivityScopeReady?(): boolean getActivityStats?(range: StatsDateRange, now?: Date): ClaudeCodeStats | null rebuild(): Promise + updateSessionTitle?(sessionId: string, title: string): boolean getSessionEntryLocators?( transcriptPath: string, entryTypes?: string[], @@ -117,6 +118,7 @@ export interface SessionIndex extends SessionIndexReader, ActivityIndex { countSources(): number getProjectionSeed(path: string): TranscriptProjection | null getBackfillState(scope: string): PersistedBackfillState | null + updateSessionTitle?(sessionId: string, title: string): boolean getSessionEntryLocators( transcriptPath: string, entryTypes?: string[], @@ -426,6 +428,14 @@ export function createSessionIndex(database: LocalIndexDatabase): SessionIndex { }) }, + updateSessionTitle(sessionId, title): boolean { + return database.write(operation => operation.run( + 'UPDATE sessions SET title = ? WHERE session_id = ?', + title, + sessionId, + ).changes > 0) + }, + findSearchCandidates(filters): IndexedSessionSearchCandidate[] { const clauses: string[] = [] const values: Array = [] diff --git a/src/server/services/localIndex/sessionProjector.test.ts b/src/server/services/localIndex/sessionProjector.test.ts index 46afd542..08013850 100644 --- a/src/server/services/localIndex/sessionProjector.test.ts +++ b/src/server/services/localIndex/sessionProjector.test.ts @@ -88,6 +88,47 @@ async function sourceHash(path: string): Promise { } describe('session projector', () => { + it('persists an immediate title patch without waiting for transcript reprojection', async () => { + const root = await createTempDir('projector-title-patch') + const databasePath = join(root, 'index.sqlite') + const placeholder = await createCandidate({ + root, + projectPath: '-repo-placeholder', + sessionId: 'titled-child', + content: line(user('Initial child title', '2026-01-01T00:00:00Z')), + }) + const moved = await createCandidate({ + root, + projectPath: '-repo-worktree', + sessionId: 'titled-child', + content: line(user('Initial child title', '2026-01-01T00:00:01Z')), + }) + const database = openLocalIndexDatabase({ path: databasePath }) + const index = createSessionIndex(database) + try { + const projector = createSessionProjector({ database, index, scope: root }) + expect((await projector.projectSource(placeholder)).kind).toBe('indexed') + expect((await projector.projectSource(moved)).kind).toBe('indexed') + await appendFile(moved.path, line({ + type: 'custom-title', + customTitle: '最终子会话标题', + })) + + expect(index.updateSessionTitle?.(moved.sessionId, '最终子会话标题')).toBe(true) + } finally { + database.close() + } + + const restarted = openLocalIndexDatabase({ path: databasePath }) + try { + const sessions = createSessionIndex(restarted).listSessions().sessions + expect(sessions).toHaveLength(2) + expect(sessions.every(session => session.title === '最终子会话标题')).toBe(true) + } finally { + restarted.close() + } + }) + it('rejects oversized records before concatenation and preserves the canonical file', async () => { const root = await createTempDir('projector-record-budget') const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'large', content: line(user('x'.repeat(MAX_PROJECTION_RECORD_BYTES + 1), '2026-01-01T00:00:00Z')) }) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index f3c59fd2..182d1c1a 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -820,6 +820,14 @@ export class SessionService { this.observedSharedMutationEpoch = sharedState.epoch } + private syncIndexedSessionTitle(sessionId: string, title: string): void { + // Title entries are tiny, authoritative mutations. Patch an existing index + // row immediately so a cold restart cannot briefly serve the older title + // while the transcript watcher is still queued. The watcher still performs + // the full source projection (fingerprint, locators, and metadata) later. + this.localIndexGateway.updateSessionTitle?.(sessionId, title) + } + private prepareSessionListCaches(scope: string): void { if (this.activeSessionListCacheScope !== scope) { this.sessionListCache.clear() @@ -4517,6 +4525,7 @@ export class SessionService { } await this.appendJsonlEntry(found.filePath, entry) + this.syncIndexedSessionTitle(sessionId, title) this.invalidateSessionListCache() } @@ -4536,6 +4545,7 @@ export class SessionService { aiTitle: title, timestamp: new Date().toISOString(), }) + this.syncIndexedSessionTitle(sessionId, title) this.invalidateSessionListCache() } @@ -4713,6 +4723,9 @@ export class SessionService { .join('\n') + '\n', 'utf-8', ) + if (customTitleEntry) { + this.syncIndexedSessionTitle(sessionId, customTitleEntry.customTitle) + } this.invalidateSessionListCache() } catch (error) { // Clear aborts old-generation appends so none can land after a successful @@ -4862,6 +4875,7 @@ export class SessionService { customTitle, timestamp: new Date().toISOString(), }) + this.syncIndexedSessionTitle(sessionId, customTitle) } this.invalidateSessionListCache() }