fix(sessions): persist collaboration titles in local index

This commit is contained in:
程序员阿江(Relakkes)
2026-09-22 09:16:14 +08:00
parent 1be51c9298
commit 23eb6ce9b6
5 changed files with 125 additions and 0 deletions
@@ -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<void> {}
@@ -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(
@@ -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[],
@@ -80,6 +80,7 @@ export interface LocalIndexGateway extends SessionIndexReader {
isActivityScopeReady?(): boolean
getActivityStats?(range: StatsDateRange, now?: Date): ClaudeCodeStats | null
rebuild(): Promise<LocalIndexStatus>
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<string | number> = []
@@ -88,6 +88,47 @@ async function sourceHash(path: string): Promise<string> {
}
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')) })
+14
View File
@@ -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()
}