mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(server): keep history session timestamps stable (#951)
Use transcript activity timestamps for session list ordering instead of JSONL mtime, and skip duplicate runtime metadata appends. Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern "metadata-only writes|duplicate runtime metadata" Tested: bun test src/server/__tests__/sessions.test.ts Tested: bun run check:server Confidence: high Scope-risk: moderate
This commit is contained in:
parent
c4157a7e90
commit
7b2477f492
@ -551,7 +551,7 @@ describe('SessionService', () => {
|
||||
expect(page2.sessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should only scan the requested page when listing many sessions', async () => {
|
||||
it('should scan summaries before pagination so metadata-only writes cannot skew order', async () => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const id = `1000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
|
||||
const filePath = await writeSessionFile('-tmp-many-sessions', id, [
|
||||
@ -576,7 +576,49 @@ describe('SessionService', () => {
|
||||
|
||||
expect(result.total).toBe(12)
|
||||
expect(result.sessions).toHaveLength(3)
|
||||
expect(scanCount).toBe(3)
|
||||
expect(scanCount).toBe(12)
|
||||
})
|
||||
|
||||
it('should ignore metadata-only writes when sorting and dating the session list', async () => {
|
||||
const activeSessionId = '10000000-aaaa-bbbb-cccc-eeeeeeeeeeee'
|
||||
const viewedHistorySessionId = '10000001-aaaa-bbbb-cccc-eeeeeeeeeeee'
|
||||
const activeFilePath = await writeSessionFile('-tmp-viewed-history-sessions', activeSessionId, [
|
||||
makeSnapshotEntry(),
|
||||
{
|
||||
...makeUserEntry('Recent real work'),
|
||||
timestamp: '2026-07-02T02:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...makeAssistantEntry('Recent reply'),
|
||||
timestamp: '2026-07-02T02:05:00.000Z',
|
||||
},
|
||||
])
|
||||
const historyFilePath = await writeSessionFile('-tmp-viewed-history-sessions', viewedHistorySessionId, [
|
||||
makeSnapshotEntry(),
|
||||
{
|
||||
...makeUserEntry('Older work'),
|
||||
timestamp: '2026-07-01T02:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...makeAssistantEntry('Older reply'),
|
||||
timestamp: '2026-07-01T02:05:00.000Z',
|
||||
},
|
||||
{
|
||||
...makeSessionMetaEntry('/tmp/viewed-history'),
|
||||
timestamp: '2026-07-02T03:00:00.000Z',
|
||||
},
|
||||
])
|
||||
await fs.utimes(activeFilePath, new Date('2026-07-02T02:05:00.000Z'), new Date('2026-07-02T02:05:00.000Z'))
|
||||
await fs.utimes(historyFilePath, new Date('2026-07-02T03:00:00.000Z'), new Date('2026-07-02T03:00:00.000Z'))
|
||||
|
||||
const result = await service.listSessions({ project: '/tmp/viewed-history-sessions', limit: 2 })
|
||||
|
||||
expect(result.sessions.map((session) => session.id)).toEqual([
|
||||
activeSessionId,
|
||||
viewedHistorySessionId,
|
||||
])
|
||||
expect(result.sessions.find((session) => session.id === viewedHistorySessionId)?.modifiedAt)
|
||||
.toBe('2026-07-01T02:05:00.000Z')
|
||||
})
|
||||
|
||||
it('should reuse cached list metadata for repeated requests', async () => {
|
||||
@ -604,7 +646,7 @@ describe('SessionService', () => {
|
||||
const second = await service.listSessions({ limit: 3, offset: 0 })
|
||||
|
||||
expect(first.sessions.map((session) => session.id)).toEqual(second.sessions.map((session) => session.id))
|
||||
expect(scanCount).toBe(3)
|
||||
expect(scanCount).toBe(5)
|
||||
})
|
||||
|
||||
it('should reuse unchanged file summaries after the list response cache is cleared', async () => {
|
||||
@ -1345,6 +1387,42 @@ describe('SessionService', () => {
|
||||
expect(launchInfo?.permissionMode).toBe('plan')
|
||||
})
|
||||
|
||||
it('should not append duplicate runtime metadata when it already matches', async () => {
|
||||
const workDir = '/tmp/runtime-idempotent'
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const filePath = await writeSessionFile(sanitizePath(workDir), sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
{
|
||||
...makeSessionMetaEntry(workDir),
|
||||
runtimeProviderId: 'provider-a',
|
||||
runtimeModelId: 'model-a',
|
||||
effortLevel: 'max',
|
||||
},
|
||||
makeUserEntry('Runtime metadata should stay stable'),
|
||||
])
|
||||
const before = await fs.readFile(filePath, 'utf-8')
|
||||
|
||||
await service.appendSessionMetadata(sessionId, {
|
||||
workDir,
|
||||
runtimeProviderId: 'provider-a',
|
||||
runtimeModelId: 'model-a',
|
||||
effortLevel: 'max',
|
||||
})
|
||||
|
||||
expect(await fs.readFile(filePath, 'utf-8')).toBe(before)
|
||||
|
||||
await service.appendSessionMetadata(sessionId, {
|
||||
workDir,
|
||||
runtimeProviderId: 'provider-a',
|
||||
runtimeModelId: 'model-b',
|
||||
effortLevel: 'max',
|
||||
})
|
||||
|
||||
const afterChange = await fs.readFile(filePath, 'utf-8')
|
||||
expect(afterChange).not.toBe(before)
|
||||
expect(afterChange).toContain('"runtimeModelId":"model-b"')
|
||||
})
|
||||
|
||||
it('should remove stale placeholder files after native CLI worktree startup', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const sourceFile = await writeSessionFile('-tmp-source', sessionId, [
|
||||
|
||||
@ -286,6 +286,7 @@ type PersistedWorktreeSession = {
|
||||
type SessionListSummary = {
|
||||
title: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
messageCount: number
|
||||
workDir: string | null
|
||||
permissionMode?: string
|
||||
@ -369,6 +370,64 @@ export class SessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private latestTimestamp(current: string | null, candidate: unknown): string | null {
|
||||
if (typeof candidate !== 'string') return current
|
||||
const candidateTime = Date.parse(candidate)
|
||||
if (!Number.isFinite(candidateTime)) return current
|
||||
if (!current) return candidate
|
||||
const currentTime = Date.parse(current)
|
||||
return !Number.isFinite(currentTime) || candidateTime > currentTime
|
||||
? candidate
|
||||
: current
|
||||
}
|
||||
|
||||
private metadataMatchesLaunchInfo(
|
||||
launchInfo: SessionLaunchInfo | null,
|
||||
metadata: {
|
||||
workDir: string
|
||||
repository?: PreparedSessionWorkspace['repository']
|
||||
permissionMode?: string
|
||||
runtimeProviderId?: string | null
|
||||
runtimeModelId?: string
|
||||
effortLevel?: string
|
||||
},
|
||||
): boolean {
|
||||
if (!launchInfo) return false
|
||||
if (normalizeDriveRootPathForPlatform(launchInfo.workDir) !== metadata.workDir) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
JSON.stringify(launchInfo.repository ?? null) !==
|
||||
JSON.stringify(metadata.repository ?? null)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
metadata.permissionMode &&
|
||||
VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) &&
|
||||
launchInfo.permissionMode !== metadata.permissionMode
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
metadata.runtimeProviderId !== undefined &&
|
||||
launchInfo.runtimeProviderId !== metadata.runtimeProviderId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (metadata.runtimeModelId && launchInfo.runtimeModelId !== metadata.runtimeModelId) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
metadata.effortLevel &&
|
||||
VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) &&
|
||||
launchInfo.effortLevel !== metadata.effortLevel
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private async getCachedSessionListSummary(
|
||||
filePath: string,
|
||||
projectDir: string,
|
||||
@ -469,10 +528,11 @@ export class SessionService {
|
||||
private async scanSessionListSummary(
|
||||
filePath: string,
|
||||
projectDir: string,
|
||||
stat: { birthtime: Date },
|
||||
stat: { birthtime: Date; mtime: Date },
|
||||
): Promise<SessionListSummary> {
|
||||
let createdAt = stat.birthtime.toISOString()
|
||||
let hasCreatedAt = false
|
||||
let modifiedAt: string | null = null
|
||||
let messageCount = 0
|
||||
let firstUserTitle: string | null = null
|
||||
let goalTitle: string | null = null
|
||||
@ -515,6 +575,9 @@ export class SessionService {
|
||||
entry.message?.role
|
||||
) {
|
||||
messageCount += 1
|
||||
if (!entry.isMeta) {
|
||||
modifiedAt = this.latestTimestamp(modifiedAt, entry.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'session-meta') {
|
||||
@ -602,6 +665,7 @@ export class SessionService {
|
||||
firstUserTitle ||
|
||||
'Untitled Session',
|
||||
createdAt,
|
||||
modifiedAt: modifiedAt ?? stat.mtime.toISOString(),
|
||||
messageCount,
|
||||
workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir),
|
||||
...(permissionMode ? { permissionMode } : {}),
|
||||
@ -631,7 +695,7 @@ export class SessionService {
|
||||
const summary = await this.scanSessionListSummary(filePath, projectPath, stat)
|
||||
return {
|
||||
title: summary.title,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
modifiedAt: summary.modifiedAt,
|
||||
workDir: summary.workDir ?? null,
|
||||
projectPath,
|
||||
}
|
||||
@ -2163,20 +2227,44 @@ export class SessionService {
|
||||
}
|
||||
}))).filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
|
||||
filesWithStats.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())
|
||||
const summarizedFiles: Array<{
|
||||
filePath: string
|
||||
projectDir: string
|
||||
sessionId: string
|
||||
stat: Stats
|
||||
summary: SessionListSummary
|
||||
}> = []
|
||||
for (const item of filesWithStats) {
|
||||
try {
|
||||
summarizedFiles.push({
|
||||
...item,
|
||||
summary: await this.getCachedSessionListSummary(
|
||||
item.filePath,
|
||||
item.projectDir,
|
||||
item.stat,
|
||||
),
|
||||
})
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
const total = filesWithStats.length
|
||||
summarizedFiles.sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.summary.modifiedAt) - Date.parse(a.summary.modifiedAt),
|
||||
)
|
||||
|
||||
const total = summarizedFiles.length
|
||||
const offset = options?.offset ?? 0
|
||||
const limit = options?.limit ?? 50
|
||||
const paginatedFiles = filesWithStats.slice(offset, offset + limit)
|
||||
const paginatedFiles = summarizedFiles.slice(offset, offset + limit)
|
||||
|
||||
// Build session list items with metadata from file stats & a streaming
|
||||
// transcript summary. Keep this sequential so large JSONL files are not
|
||||
// loaded into memory concurrently by the sidebar's frequent refresh.
|
||||
const items: SessionListItem[] = []
|
||||
for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) {
|
||||
for (const { projectDir, sessionId, summary } of paginatedFiles) {
|
||||
try {
|
||||
const summary = await this.getCachedSessionListSummary(filePath, projectDir, stat)
|
||||
const workDir = summary.workDir
|
||||
const projectRoot = await this.resolveProjectRootFromSessionMetadata({
|
||||
worktreeSession: summary.worktreeSession,
|
||||
@ -2190,7 +2278,7 @@ export class SessionService {
|
||||
id: sessionId,
|
||||
title: summary.title,
|
||||
createdAt: summary.createdAt,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
modifiedAt: summary.modifiedAt,
|
||||
messageCount: summary.messageCount,
|
||||
projectPath: projectDir,
|
||||
projectRoot,
|
||||
@ -2604,6 +2692,18 @@ export class SessionService {
|
||||
const normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir)
|
||||
const targetProjectDir = this.sanitizePath(normalizedWorkDir)
|
||||
const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
|
||||
|
||||
if (!metadata.customTitle) {
|
||||
const launchInfo = await this.getSessionLaunchInfo(sessionId)
|
||||
if (this.metadataMatchesLaunchInfo(launchInfo, {
|
||||
...metadata,
|
||||
workDir: normalizedWorkDir,
|
||||
repository,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
|
||||
|
||||
await this.appendJsonlEntry(targetFilePath, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user