mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(sessions): persist collaboration titles in local index
This commit is contained in:
@@ -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')) })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user