mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(sessions): stop full transcript parses on every session lookup
Every session route resolves its transcript first, and that lookup parsed the whole file to rank candidates even when only one file matched. The desktop pages a long session's history request by request, so reopening it cost one full parse per page and grew quadratically with file size. Skip the content check when a single file matches, and stop a multi-file check at the first conversation record. Ranking and API responses are unchanged.
This commit is contained in:
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Replays the requests the desktop sends when a session is opened, against a
|
||||||
|
* real server booted in a quality-gate sandbox, and reports how long each one
|
||||||
|
* takes. Local diagnostic only: it is not part of any PR lane.
|
||||||
|
*
|
||||||
|
* bun run scripts/perf/session-open-benchmark.ts --transcript a.jsonl [--transcript b.jsonl] [--rounds 2] [--sidebar]
|
||||||
|
*
|
||||||
|
* Each `--transcript` is copied (read-only) into the sandbox and concatenated
|
||||||
|
* into one session, with `sessionId`/`cwd` rewritten so nothing points back at
|
||||||
|
* the source. The sandbox is deleted afterwards.
|
||||||
|
*/
|
||||||
|
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { stat } from 'node:fs/promises'
|
||||||
|
import { createServer } from 'node:net'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { createInterface } from 'node:readline'
|
||||||
|
import { createQualityGateSandbox } from '../quality-gate/sandbox.js'
|
||||||
|
import { sanitizePath } from '../../src/utils/sessionStoragePortable.js'
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 120_000
|
||||||
|
const SESSION_ID = '0b0e1c5e-5e55-4a11-9b0b-0000000b0e1c'
|
||||||
|
|
||||||
|
type Timing = { name: string; ms: number; status: number | 'timeout' | 'error'; bytes: number }
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]) {
|
||||||
|
const transcripts: string[] = []
|
||||||
|
let rounds = 2
|
||||||
|
let sidebar = false
|
||||||
|
let keep = false
|
||||||
|
for (let index = 0; index < argv.length; index++) {
|
||||||
|
const arg = argv[index]
|
||||||
|
if (arg === '--transcript') transcripts.push(resolve(argv[++index]!))
|
||||||
|
else if (arg === '--rounds') rounds = Number(argv[++index])
|
||||||
|
else if (arg === '--sidebar') sidebar = true
|
||||||
|
else if (arg === '--keep') keep = true
|
||||||
|
else throw new Error(`Unknown argument: ${arg}`)
|
||||||
|
}
|
||||||
|
if (transcripts.length === 0) throw new Error('Pass at least one --transcript <path>')
|
||||||
|
return { transcripts, rounds, sidebar, keep }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freePort(): Promise<number> {
|
||||||
|
return new Promise((resolvePort, reject) => {
|
||||||
|
const server = createServer()
|
||||||
|
server.once('error', reject)
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address()
|
||||||
|
server.close(() => resolvePort(typeof address === 'object' && address ? address.port : 0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTranscripts(sources: string[], target: string, workDir: string) {
|
||||||
|
const output = createWriteStream(target)
|
||||||
|
let records = 0
|
||||||
|
let conversation = 0
|
||||||
|
for (const source of sources) {
|
||||||
|
const lines = createInterface({ input: createReadStream(source), crlfDelay: Infinity })
|
||||||
|
for await (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
let text = line
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as Record<string, unknown>
|
||||||
|
if ('sessionId' in entry) entry.sessionId = SESSION_ID
|
||||||
|
if ('cwd' in entry) entry.cwd = workDir
|
||||||
|
const message = entry.message as { role?: unknown } | undefined
|
||||||
|
if ((entry.type === 'user' || entry.type === 'assistant') && message?.role) conversation++
|
||||||
|
text = JSON.stringify(entry)
|
||||||
|
} catch {
|
||||||
|
// Keep malformed lines byte-for-byte; the server must tolerate them.
|
||||||
|
}
|
||||||
|
if (!output.write(text + '\n')) await new Promise(done => output.once('drain', done))
|
||||||
|
records++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise<void>((done, reject) => output.end((error?: Error | null) => error ? reject(error) : done()))
|
||||||
|
return { records, conversation, bytes: (await stat(target)).size }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function timed(base: string, name: string, path: string): Promise<{ timing: Timing; body: unknown }> {
|
||||||
|
const started = performance.now()
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
|
||||||
|
const text = await response.text()
|
||||||
|
let body: unknown = null
|
||||||
|
try { body = JSON.parse(text) } catch { /* non-JSON error bodies are still timed */ }
|
||||||
|
return { timing: { name, ms: performance.now() - started, status: response.status, bytes: text.length }, body }
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as Error).name === 'TimeoutError' ? 'timeout' : 'error'
|
||||||
|
return { timing: { name, ms: performance.now() - started, status, bytes: 0 }, body: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryPage = { messages?: unknown[]; page?: { nextCursor?: string | null } }
|
||||||
|
|
||||||
|
/** Mirrors desktop/src/api/sessions.ts getFullHistory: mode=full, then every cursor page. */
|
||||||
|
async function fullHistory(base: string, timings: Timing[]) {
|
||||||
|
const started = performance.now()
|
||||||
|
const first = await timed(base, 'messages?mode=full', `/api/sessions/${SESSION_ID}/messages?mode=full`)
|
||||||
|
timings.push(first.timing)
|
||||||
|
let cursor = (first.body as HistoryPage | null)?.page?.nextCursor ?? null
|
||||||
|
let messages = (first.body as HistoryPage | null)?.messages?.length ?? 0
|
||||||
|
let pages = 0
|
||||||
|
let slowestPage = 0
|
||||||
|
let failed = first.timing.status !== 200
|
||||||
|
while (cursor && !failed) {
|
||||||
|
const page = await timed(base, 'messages?cursor', `/api/sessions/${SESSION_ID}/messages?${new URLSearchParams({ cursor })}`)
|
||||||
|
pages++
|
||||||
|
slowestPage = Math.max(slowestPage, page.timing.ms)
|
||||||
|
if (page.timing.status !== 200) {
|
||||||
|
timings.push(page.timing)
|
||||||
|
failed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
messages += (page.body as HistoryPage | null)?.messages?.length ?? 0
|
||||||
|
cursor = (page.body as HistoryPage | null)?.page?.nextCursor ?? null
|
||||||
|
}
|
||||||
|
timings.push({ name: `history total (${pages} cursor pages, slowest ${Math.round(slowestPage)}ms, ${messages} msgs)`, ms: performance.now() - started, status: failed ? 'error' : 200, bytes: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSession(base: string, sidebar: boolean): Promise<Timing[]> {
|
||||||
|
const timings: Timing[] = []
|
||||||
|
const id = SESSION_ID
|
||||||
|
const burst: Array<Promise<void>> = [
|
||||||
|
fullHistory(base, timings),
|
||||||
|
...[
|
||||||
|
['summary', `/api/sessions/${id}/summary`],
|
||||||
|
['git-info', `/api/sessions/${id}/git-info`],
|
||||||
|
['slash-commands', `/api/sessions/${id}/slash-commands`],
|
||||||
|
['chat/status', `/api/sessions/${id}/chat/status`],
|
||||||
|
['inspection(context)', `/api/sessions/${id}/inspection?includeContext=1&contextOnly=1`],
|
||||||
|
['workspace/status#1', `/api/sessions/${id}/workspace/status`],
|
||||||
|
['team plan', `/api/teams/session/${id}/plan`],
|
||||||
|
].map(([name, path]) => timed(base, name!, path!).then(result => { timings.push(result.timing) })),
|
||||||
|
]
|
||||||
|
if (sidebar) burst.push(timed(base, 'sidebar list', '/api/sessions?limit=60&perProjectLimit=6').then(result => { timings.push(result.timing) }))
|
||||||
|
await Promise.all(burst)
|
||||||
|
// MessageList asks for these only after history is ready.
|
||||||
|
await Promise.all([
|
||||||
|
timed(base, 'turn-checkpoints', `/api/sessions/${id}/turn-checkpoints`),
|
||||||
|
timed(base, 'workspace/status#2', `/api/sessions/${id}/workspace/status`),
|
||||||
|
].map(request => request.then(result => { timings.push(result.timing) })))
|
||||||
|
return timings
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2))
|
||||||
|
for (const transcript of options.transcripts) {
|
||||||
|
if (!existsSync(transcript)) throw new Error(`Transcript not found: ${transcript}`)
|
||||||
|
}
|
||||||
|
const sandbox = createQualityGateSandbox({ label: 'session-open' })
|
||||||
|
const workDir = join(sandbox.home, 'workdir')
|
||||||
|
mkdirSync(workDir, { recursive: true })
|
||||||
|
const projectDir = join(sandbox.configDir, 'projects', sanitizePath(workDir))
|
||||||
|
mkdirSync(projectDir, { recursive: true })
|
||||||
|
const target = join(projectDir, `${SESSION_ID}.jsonl`)
|
||||||
|
let server: ReturnType<typeof Bun.spawn> | undefined
|
||||||
|
try {
|
||||||
|
const copied = await copyTranscripts(options.transcripts, target, workDir)
|
||||||
|
console.log(`transcript: ${copied.records} records, ${copied.conversation} conversation messages, ${(copied.bytes / 1048576).toFixed(1)} MiB`)
|
||||||
|
|
||||||
|
const port = await freePort()
|
||||||
|
const base = `http://127.0.0.1:${port}`
|
||||||
|
const logPath = join(sandbox.home, 'server.log')
|
||||||
|
server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)], {
|
||||||
|
env: { ...sandbox.env, SERVER_PORT: String(port), CLAUDE_CLI_PATH: resolve('src/server/__tests__/fixtures/mock-sdk-cli.ts') },
|
||||||
|
stdout: Bun.file(logPath),
|
||||||
|
stderr: Bun.file(logPath),
|
||||||
|
})
|
||||||
|
const bootDeadline = Date.now() + 60_000
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
if ((await fetch(`${base}/health`)).ok) break
|
||||||
|
} catch { /* not listening yet */ }
|
||||||
|
if (Date.now() > bootDeadline) throw new Error(`Server did not start; see ${logPath}`)
|
||||||
|
await Bun.sleep(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let round = 1; round <= options.rounds; round++) {
|
||||||
|
const started = performance.now()
|
||||||
|
const timings = await openSession(base, options.sidebar)
|
||||||
|
console.log(`\nround ${round}: ${((performance.now() - started) / 1000).toFixed(1)}s wall`)
|
||||||
|
for (const timing of timings.sort((a, b) => b.ms - a.ms)) {
|
||||||
|
const flag = timing.status === 'timeout' || timing.ms >= REQUEST_TIMEOUT_MS ? ' <-- exceeds 120s' : ''
|
||||||
|
console.log(` ${(timing.ms / 1000).toFixed(2).padStart(8)}s ${String(timing.status).padEnd(7)} ${timing.name}${flag}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnostics = join(sandbox.configDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl')
|
||||||
|
if (existsSync(diagnostics)) {
|
||||||
|
const stalls = readFileSync(diagnostics, 'utf8').split('\n').filter(line => line.includes('event_loop'))
|
||||||
|
if (stalls.length) console.log(`\nevent-loop stall diagnostics: ${stalls.length}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server?.kill()
|
||||||
|
await server?.exited
|
||||||
|
const mutations = sandbox.detectUserStateMutations()
|
||||||
|
if (mutations.length) console.error(`Real user state changed: ${mutations.join(', ')}`)
|
||||||
|
if (options.keep) console.log(`sandbox kept at ${sandbox.home}`)
|
||||||
|
else sandbox.cleanup()
|
||||||
|
if (mutations.length) process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main()
|
||||||
@@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises'
|
|||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { SessionService } from '../services/sessionService.js'
|
import { SessionService } from '../services/sessionService.js'
|
||||||
|
import * as boundedSessionHistory from '../services/boundedSessionHistory.js'
|
||||||
import type {
|
import type {
|
||||||
IndexedSessionRow,
|
IndexedSessionRow,
|
||||||
LocalIndexGateway,
|
LocalIndexGateway,
|
||||||
@@ -277,6 +278,20 @@ describe('SessionService local-index routing parity', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves a single indexed match from its stat without reading records', async () => {
|
||||||
|
const filePath = await writeSession('-tmp-only', SESSION_A, 'Only', '2026-07-15T00:01:00.000Z')
|
||||||
|
const gateway = new FakeLocalIndexGateway()
|
||||||
|
gateway.setReady()
|
||||||
|
gateway.matches = [{ filePath, projectDir: '-tmp-only' }]
|
||||||
|
const service = new SessionService(gateway)
|
||||||
|
const reads = spyOn(boundedSessionHistory, 'streamBoundedHistory')
|
||||||
|
try {
|
||||||
|
expect((await service.findSessionFile(SESSION_A))?.filePath).toBe(filePath)
|
||||||
|
expect(gateway.findCalls).toBe(1)
|
||||||
|
expect(reads).not.toHaveBeenCalled()
|
||||||
|
} finally { reads.mockRestore() }
|
||||||
|
})
|
||||||
|
|
||||||
it('falls back after an indexed read failure and suppresses retries during cooldown', async () => {
|
it('falls back after an indexed read failure and suppresses retries during cooldown', async () => {
|
||||||
await writeSession(
|
await writeSession(
|
||||||
'-tmp-project',
|
'-tmp-project',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
prepareSessionWorkspace,
|
prepareSessionWorkspace,
|
||||||
} from '../services/repositoryLaunchService.js'
|
} from '../services/repositoryLaunchService.js'
|
||||||
import { conversationService } from '../services/conversationService.js'
|
import { conversationService } from '../services/conversationService.js'
|
||||||
|
import * as boundedSessionHistory from '../services/boundedSessionHistory.js'
|
||||||
import { clearCommandsCache } from '../../commands.js'
|
import { clearCommandsCache } from '../../commands.js'
|
||||||
import { parseJSONL } from '../../utils/json.js'
|
import { parseJSONL } from '../../utils/json.js'
|
||||||
import { formatSessionCollaborationPrompt } from '../../utils/sessionCollaborationEnvelope.js'
|
import { formatSessionCollaborationPrompt } from '../../utils/sessionCollaborationEnvelope.js'
|
||||||
@@ -586,6 +587,64 @@ describe('SessionService', () => {
|
|||||||
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
|
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Every session route resolves its file first. Reading the whole transcript
|
||||||
|
// here made each history page of a long session cost a full parse, so a
|
||||||
|
// 2,500-message session reopened in quadratic time.
|
||||||
|
describe('session file lookup cost', () => {
|
||||||
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
|
|
||||||
|
function countDeliveredRecords() {
|
||||||
|
const delivered = new Map<string, number>()
|
||||||
|
const original = boundedSessionHistory.streamBoundedHistory
|
||||||
|
const spy = spyOn(boundedSessionHistory, 'streamBoundedHistory').mockImplementation(
|
||||||
|
(filePath, onEntry, signal, options) => original(filePath, (...args) => {
|
||||||
|
delivered.set(filePath, (delivered.get(filePath) ?? 0) + 1)
|
||||||
|
onEntry(...args)
|
||||||
|
}, signal, options),
|
||||||
|
)
|
||||||
|
return { delivered, spy }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('resolves a lone transcript without reading its records', async () => {
|
||||||
|
const transcript = await writeSessionFile('-tmp-lone', sessionId, [
|
||||||
|
makeSnapshotEntry(),
|
||||||
|
...Array.from({ length: 50 }, (_, n) => makeUserEntry(`turn ${n}`)),
|
||||||
|
])
|
||||||
|
const { delivered, spy } = countDeliveredRecords()
|
||||||
|
try {
|
||||||
|
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
|
||||||
|
expect(delivered.size).toBe(0)
|
||||||
|
} finally { spy.mockRestore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops reading a candidate at its first conversation record', async () => {
|
||||||
|
const transcript = await writeSessionFile('-tmp-long-worktree', sessionId, [
|
||||||
|
makeSnapshotEntry(),
|
||||||
|
makeUserEntry('first turn'),
|
||||||
|
...Array.from({ length: 200 }, (_, n) => makeAssistantEntry(`reply ${n}`)),
|
||||||
|
])
|
||||||
|
const placeholder = await writeSessionFile('-tmp-newer-placeholder', sessionId, [makeSnapshotEntry()])
|
||||||
|
const later = new Date(Date.now() + 60_000)
|
||||||
|
await fs.utimes(placeholder, later, later)
|
||||||
|
const { delivered, spy } = countDeliveredRecords()
|
||||||
|
try {
|
||||||
|
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
|
||||||
|
expect(delivered.get(transcript)).toBe(2)
|
||||||
|
expect(delivered.get(placeholder)).toBe(1)
|
||||||
|
} finally { spy.mockRestore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still recognizes a conversation on a final line without a newline', async () => {
|
||||||
|
const transcript = await writeSessionFile('-tmp-unterminated', sessionId, [makeSnapshotEntry()])
|
||||||
|
await fs.appendFile(transcript, JSON.stringify(makeUserEntry('last turn')))
|
||||||
|
const placeholder = await writeSessionFile('-tmp-unterminated-placeholder', sessionId, [makeSnapshotEntry()])
|
||||||
|
const later = new Date(Date.now() + 60_000)
|
||||||
|
await fs.utimes(placeholder, later, later)
|
||||||
|
|
||||||
|
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should return empty list when no sessions exist', async () => {
|
it('should return empty list when no sessions exist', async () => {
|
||||||
const result = await service.listSessions()
|
const result = await service.listSessions()
|
||||||
expect(result.sessions).toEqual([])
|
expect(result.sessions).toEqual([])
|
||||||
|
|||||||
@@ -1903,18 +1903,43 @@ export class SessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fileHasConversationTranscript(filePath: string): Promise<boolean> {
|
private async fileHasConversationTranscript(filePath: string): Promise<boolean> {
|
||||||
let hasTranscript = false
|
// The first conversation record settles the answer. Scanning on would make
|
||||||
const scan = await withHistoryReadBudget(undefined, () => streamBoundedHistory(
|
// every session lookup cost a full parse of the transcript.
|
||||||
filePath,
|
const found = new AbortController()
|
||||||
entry => {
|
try {
|
||||||
if (!hasTranscript && this.hasConversationTranscript([entry as RawEntry])) hasTranscript = true
|
const scan = await withHistoryReadBudget(undefined, () => streamBoundedHistory(
|
||||||
},
|
filePath,
|
||||||
undefined,
|
entry => {
|
||||||
{ maxRecordBytes: HISTORY_SEMANTIC_RECORD_BYTES },
|
if (this.hasConversationTranscript([entry as RawEntry])) found.abort()
|
||||||
), 'metadata')
|
},
|
||||||
// An oversized record may be the only conversation turn. Prefer that
|
found.signal,
|
||||||
// transcript over a newer metadata-only placeholder until it can be read.
|
{ maxRecordBytes: HISTORY_SEMANTIC_RECORD_BYTES },
|
||||||
return hasTranscript || scan.oversizedRecords > 0
|
), 'metadata')
|
||||||
|
// An oversized record may be the only conversation turn. Prefer that
|
||||||
|
// transcript over a newer metadata-only placeholder until it can be read.
|
||||||
|
// A final line without a newline is reported after the last abort check.
|
||||||
|
return found.signal.aborted || scan.oversizedRecords > 0
|
||||||
|
} catch (error) {
|
||||||
|
if (found.signal.aborted && error === found.signal.reason) return true
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content is only needed to choose between several files for one session. */
|
||||||
|
private async rankSessionFileMatches<T extends { filePath: string; mtimeMs: number }>(
|
||||||
|
matches: T[],
|
||||||
|
onReadError: (error: unknown) => 'drop' | 'fail',
|
||||||
|
): Promise<T[] | null> {
|
||||||
|
if (matches.length < 2) return matches
|
||||||
|
const ranked: Array<T & { hasTranscript: boolean }> = []
|
||||||
|
for (const match of matches) {
|
||||||
|
try {
|
||||||
|
ranked.push({ ...match, hasTranscript: await this.fileHasConversationTranscript(match.filePath) })
|
||||||
|
} catch (error) {
|
||||||
|
if (onReadError(error) === 'fail') return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ranked.sort((a, b) => Number(b.hasTranscript) - Number(a.hasTranscript) || b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -2736,8 +2761,10 @@ export class SessionService {
|
|||||||
const projectsRoot = indexedMatches.length > 0
|
const projectsRoot = indexedMatches.length > 0
|
||||||
? await fs.realpath(this.getProjectsDir())
|
? await fs.realpath(this.getProjectsDir())
|
||||||
: null
|
: null
|
||||||
const hydratedMatches: Array<SessionFileMatch & { mtimeMs: number; hasTranscript: boolean }> = []
|
const hydratedMatches: Array<SessionFileMatch & { mtimeMs: number }> = []
|
||||||
let hydrationFailed = false
|
let hydrationFailed = false
|
||||||
|
const failUnlessMissing = (error: unknown) =>
|
||||||
|
(error as NodeJS.ErrnoException).code === 'ENOENT' ? 'drop' as const : 'fail' as const
|
||||||
for (const match of indexedMatches) {
|
for (const match of indexedMatches) {
|
||||||
try {
|
try {
|
||||||
const stat = await this.validateIndexedTranscriptPath(
|
const stat = await this.validateIndexedTranscriptPath(
|
||||||
@@ -2746,26 +2773,24 @@ export class SessionService {
|
|||||||
sessionId,
|
sessionId,
|
||||||
projectsRoot!,
|
projectsRoot!,
|
||||||
)
|
)
|
||||||
hydratedMatches.push({
|
hydratedMatches.push({ ...match, mtimeMs: stat.mtimeMs })
|
||||||
...match,
|
|
||||||
mtimeMs: stat.mtimeMs,
|
|
||||||
hasTranscript: await this.fileHasConversationTranscript(match.filePath),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
if (failUnlessMissing(error) === 'fail') {
|
||||||
hydrationFailed = true
|
hydrationFailed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const rankedMatches = hydrationFailed
|
||||||
|
? null
|
||||||
|
: await this.rankSessionFileMatches(hydratedMatches, failUnlessMissing)
|
||||||
|
if (!rankedMatches) hydrationFailed = true
|
||||||
if (
|
if (
|
||||||
!hydrationFailed &&
|
rankedMatches &&
|
||||||
hydratedMatches.length > 0 &&
|
rankedMatches.length > 0 &&
|
||||||
indexedMutationEpoch === getSharedSessionMutationState(this.localIndexGateway).epoch
|
indexedMutationEpoch === getSharedSessionMutationState(this.localIndexGateway).epoch
|
||||||
) {
|
) {
|
||||||
return hydratedMatches
|
return rankedMatches.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
|
||||||
.sort((a, b) => Number(b.hasTranscript) - Number(a.hasTranscript) || b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath))
|
|
||||||
.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
|
|
||||||
}
|
}
|
||||||
if (hydrationFailed) this.markIndexReadFailure()
|
if (hydrationFailed) this.markIndexReadFailure()
|
||||||
}
|
}
|
||||||
@@ -2791,25 +2816,19 @@ export class SessionService {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches: Array<{ filePath: string; projectDir: string; mtimeMs: number; hasTranscript: boolean }> = []
|
const matches: Array<{ filePath: string; projectDir: string; mtimeMs: number }> = []
|
||||||
for (const dir of projectDirs) {
|
for (const dir of projectDirs) {
|
||||||
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(filePath)
|
const stat = await fs.stat(filePath)
|
||||||
matches.push({
|
matches.push({ filePath, projectDir: dir, mtimeMs: stat.mtimeMs })
|
||||||
filePath,
|
|
||||||
projectDir: dir,
|
|
||||||
mtimeMs: stat.mtimeMs,
|
|
||||||
hasTranscript: await this.fileHasConversationTranscript(filePath),
|
|
||||||
})
|
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches
|
const ranked = await this.rankSessionFileMatches(matches, () => 'drop')
|
||||||
.sort((a, b) => Number(b.hasTranscript) - Number(a.hasTranscript) || b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath))
|
return ranked!.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
|
||||||
.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSessionFile(
|
async findSessionFile(
|
||||||
|
|||||||
Reference in New Issue
Block a user