mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Skip reindexing unchanged transcripts on cold session list reads (#28798)
* Skip reindexing unchanged transcripts on cold session list reads A cold session-list read re-parsed every self-contained transcript and rewrote its search rows, even when nothing had changed. That wasted work and churned the index on every list. - When a transcript's stat (mtime, size, inode, device) matches a complete index entry, the list read now skips the rewrite. Agents with sidecar metadata, unknown sizes, or partial or stale entries still reread. - Test fixtures that replay a "changed" transcript now pass a new mtime so they still exercise the replace path. - New tests cover unchanged skips, changed size/mtime/file identity, sidecar refresh, due/failed repair, and append. - Dependent-transcript and Codex title tests check that cold rereads keep search and title data current. * Make the self-contained transcript check exhaustive over every agent - Replace the hand-maintained `Set` of self-contained agents with a `Record<AiVaultAgent, boolean>`, so adding an agent type fails typecheck until someone decides whether its transcript is self-contained. - Classification is unchanged: the agents that were in the set are `true`, and the rest are `false` with a short note on the sidecar or metadata source they depend on. - Make the replace-generation tests derive the second mtime from the first candidate instead of a hard-coded timestamp, so they stay valid if the default fixture mtime changes. * Guard file-stat reuse with a self-contained transcript test Indexing skips reparsing unchanged transcripts for agents marked self-contained. That is only safe if their parsers read nothing but the transcript itself, and nothing enforced it. A parser that started reading a sidecar or dependency file could serve stale index entries. - Export SELF_CONTAINED_TRANSCRIPT_AGENTS so the test checks the real list - Add a test that parses each self-contained agent's fixture, both direct and cached, and fails if any file other than the transcript is read - Register the test in the SQLite runtime-file include list * Require stored file identity before skipping a transcript rewrite - Rows with a NULL dev or ino were treated as covered, so a file replaced with the same size and mtime could be skipped and never reindexed. - Such rows now count as uncovered, so the rewrite runs and records the identity. - The identity is read through the shared `fileIdentity` helper instead of the raw file fields. * Add regression test for session rows indexed without file identity A session row indexed before file identity (dev/ino) was recorded was never reread once the stat began supplying one, so it could keep stale search content. This test covers that case: the first read with identity replaces the row, and a second read with the same identity leaves it alone.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
// Database opening and lifecycle fixtures exercise Node SQLite behavior.
|
||||
export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
|
||||
'src/main/ai-vault-search/session-search-dependent-transcripts.test.ts',
|
||||
'src/main/ai-vault-search/session-search-file-write.test.ts',
|
||||
'src/main/ai-vault-search/session-search-live-transcript.test.ts',
|
||||
'src/main/ai-vault-search/session-search-list-reparse.test.ts',
|
||||
'src/main/ai-vault-search/session-search-transcript-dependencies.test.ts',
|
||||
'src/main/ai-vault/session-scanner-devin-contention.test.ts',
|
||||
'src/main/ai-vault/session-scanner-opencode-native-worker.test.ts',
|
||||
'src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts',
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import {
|
||||
parseAgentSessionFileCached,
|
||||
resetSessionParseCacheForTests
|
||||
} from '../ai-vault/session-scanner-parse-cache'
|
||||
import { resetCodexSessionIndexTitleCacheForTests } from '../ai-vault/session-scanner-codex-title-index'
|
||||
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
|
||||
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
|
||||
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
|
||||
import {
|
||||
openSessionSearchIndexFile,
|
||||
type SessionSearchIndexFile
|
||||
} from './session-search-index-test-fixture'
|
||||
import { SessionSearchStore } from './session-search-store'
|
||||
import {
|
||||
CODEX_ROLLOUT_FILE,
|
||||
CODEX_SESSION_ID,
|
||||
codexRolloutLines,
|
||||
sessionCandidate
|
||||
} from './session-search-transcript-fixtures'
|
||||
|
||||
let index: SessionSearchIndexFile
|
||||
let store: SessionSearchStore
|
||||
let root: string
|
||||
let errors: unknown[]
|
||||
const SESSION_ID = '0b5e1c2a-3d4f-4a5b-8c6d-7e8f9a0b1c2d'
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTranscriptConsumersForTests()
|
||||
resetCodexSessionIndexTitleCacheForTests()
|
||||
index = await openSessionSearchIndexFile('ss-dependent-transcripts')
|
||||
root = dirname(index.path)
|
||||
errors = []
|
||||
store = new SessionSearchStore(index.path, (error) => errors.push(error))
|
||||
registerSessionSearchIndexConsumer(store)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
resetCodexSessionIndexTitleCacheForTests()
|
||||
store.close()
|
||||
await index.close()
|
||||
})
|
||||
|
||||
async function writeSource(path: string, content: string): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, content)
|
||||
}
|
||||
|
||||
async function coldRead(candidate: SessionFileCandidate) {
|
||||
resetSessionParseCacheForTests()
|
||||
const session = await parseAgentSessionFileCached(candidate, process.platform)
|
||||
expect(errors).toEqual([])
|
||||
expect(session).not.toBeNull()
|
||||
return session
|
||||
}
|
||||
|
||||
function matches(term: string): unknown {
|
||||
return index.db
|
||||
.prepare(
|
||||
`SELECT count(*) AS n FROM messages_fts
|
||||
JOIN messages m ON m.id = messages_fts.rowid
|
||||
JOIN sessions s ON s.id = m.session_row_id
|
||||
WHERE messages_fts MATCH ?`
|
||||
)
|
||||
.get(term)
|
||||
}
|
||||
|
||||
it.each(['cline', 'kiro'] as const)(
|
||||
'removes old searchable messages when the %s sidecar is deleted',
|
||||
async (agent) => {
|
||||
const path = join(root, agent, SESSION_ID, `${SESSION_ID}.json`)
|
||||
const sidecarPath = agent === 'cline' ? path.replace(/\.json$/, '.messages.json') : `${path}l`
|
||||
await writeSource(path, JSON.stringify({ session_id: SESSION_ID, title: 'Conversation' }))
|
||||
await writeSource(
|
||||
sidecarPath,
|
||||
agent === 'cline'
|
||||
? JSON.stringify({ messages: [{ role: 'user', content: 'oldsidecarmessage' }] })
|
||||
: `${JSON.stringify({
|
||||
kind: 'Prompt',
|
||||
data: { content: [{ kind: 'text', data: 'oldsidecarmessage' }] }
|
||||
})}\n`
|
||||
)
|
||||
const candidate = await sessionCandidate(agent, path)
|
||||
const sidecar = await stat(sidecarPath)
|
||||
candidate.file.sidecar = {
|
||||
path: sidecarPath,
|
||||
mtimeMs: sidecar.mtimeMs,
|
||||
sizeBytes: sidecar.size
|
||||
}
|
||||
await coldRead(candidate)
|
||||
expect(matches('oldsidecarmessage')).toEqual({ n: 1 })
|
||||
|
||||
await unlink(sidecarPath)
|
||||
const after = await sessionCandidate(agent, path)
|
||||
expect(after.file).toEqual({ ...candidate.file, sidecar: undefined })
|
||||
after.file.sidecar = 'none'
|
||||
expect(await coldRead(after)).toMatchObject({ messageCount: 0 })
|
||||
expect(matches('oldsidecarmessage')).toEqual({ n: 0 })
|
||||
expect(index.db.prepare('SELECT state FROM files').get()).toEqual({ state: 'current' })
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['opencode', 'kimi', 'grok'] as const)(
|
||||
'repairs %s search content after a cold reread of unchanged candidate metadata',
|
||||
async (agent) => {
|
||||
const source = await multiFileSource(agent)
|
||||
await writeSource(source.messagesPath, source.message('olddependentmessage'))
|
||||
const candidate = await sessionCandidate(agent, source.path)
|
||||
candidate.file.sidecar = 'none'
|
||||
await coldRead(candidate)
|
||||
expect(matches('olddependentmessage')).toEqual({ n: 1 })
|
||||
|
||||
await writeSource(source.messagesPath, source.message('newdependentmessage'))
|
||||
expect((await sessionCandidate(agent, source.path)).file).toEqual({
|
||||
...candidate.file,
|
||||
sidecar: undefined
|
||||
})
|
||||
const session = await coldRead(candidate)
|
||||
expect(session?.previewMessages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ text: 'newdependentmessage' })])
|
||||
)
|
||||
expect(matches('newdependentmessage')).toEqual({ n: 1 })
|
||||
expect(matches('olddependentmessage')).toEqual({ n: 0 })
|
||||
}
|
||||
)
|
||||
|
||||
async function multiFileSource(agent: 'opencode' | 'kimi' | 'grok') {
|
||||
if (agent === 'opencode') {
|
||||
const path = join(root, 'storage', 'session', 'project', 'ses_fixture.json')
|
||||
await writeSource(path, JSON.stringify({ id: SESSION_ID, title: 'Conversation' }))
|
||||
return {
|
||||
path,
|
||||
messagesPath: join(root, 'storage', 'message', SESSION_ID, 'msg_fixture.json'),
|
||||
message: (text: string) => JSON.stringify({ role: 'user', content: text })
|
||||
}
|
||||
}
|
||||
if (agent === 'kimi') {
|
||||
const directory = join(root, 'kimi', 'sessions', 'wd_app_abc', `session_${SESSION_ID}`)
|
||||
const path = join(directory, 'state.json')
|
||||
await writeSource(path, JSON.stringify({ title: 'Conversation' }))
|
||||
return {
|
||||
path,
|
||||
messagesPath: join(directory, 'agents', 'main', 'wire.jsonl'),
|
||||
message: (text: string) =>
|
||||
`${JSON.stringify({
|
||||
type: 'context.append_message',
|
||||
message: { role: 'user', origin: { kind: 'user' }, content: text }
|
||||
})}\n`
|
||||
}
|
||||
}
|
||||
const path = join(root, 'grok', SESSION_ID, 'summary.json')
|
||||
await writeSource(
|
||||
path,
|
||||
JSON.stringify({ info: { id: SESSION_ID }, generated_title: 'Conversation' })
|
||||
)
|
||||
return {
|
||||
path,
|
||||
messagesPath: join(dirname(path), 'chat_history.jsonl'),
|
||||
message: (text: string) =>
|
||||
`${JSON.stringify({ type: 'user', content: `<user_query>${text}</user_query>` })}\n`
|
||||
}
|
||||
}
|
||||
|
||||
it('updates the indexed Codex title when only session_index.jsonl changes', async () => {
|
||||
const home = join(root, 'codex')
|
||||
const path = join(home, 'sessions', CODEX_ROLLOUT_FILE)
|
||||
await writeSource(path, `${codexRolloutLines(['pwd'], '/repo/app', 'codexmessage').join('\n')}\n`)
|
||||
const titleIndex = join(home, 'session_index.jsonl')
|
||||
await writeSource(
|
||||
titleIndex,
|
||||
`${JSON.stringify({ id: CODEX_SESSION_ID, thread_name: 'Original' })}\n`
|
||||
)
|
||||
const candidate = await sessionCandidate('codex', path, home)
|
||||
candidate.file.sidecar = 'none'
|
||||
await coldRead(candidate)
|
||||
expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({ title: 'Original' })
|
||||
|
||||
await writeSource(
|
||||
titleIndex,
|
||||
`${JSON.stringify({ id: CODEX_SESSION_ID, thread_name: 'Renamed conversation' })}\n`
|
||||
)
|
||||
expect((await sessionCandidate('codex', path, home)).file).toEqual({
|
||||
...candidate.file,
|
||||
sidecar: undefined
|
||||
})
|
||||
expect(await coldRead(candidate)).toMatchObject({ title: 'Renamed conversation' })
|
||||
expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({
|
||||
title: 'Renamed conversation'
|
||||
})
|
||||
expect(matches('codexmessage')).toEqual({ n: 1 })
|
||||
})
|
||||
@@ -491,8 +491,12 @@ it('fences a first-ever read whose file was removed before it committed', () =>
|
||||
})
|
||||
|
||||
it('replaces the previous generation without ever showing both', async () => {
|
||||
replayTranscriptRead({ messages: userMessages('firstgeneration', 10) })
|
||||
replayTranscriptRead({ messages: userMessages('secondgeneration', 10) })
|
||||
const candidate = syntheticCandidate()
|
||||
replayTranscriptRead({ candidate, messages: userMessages('firstgeneration', 10) })
|
||||
replayTranscriptRead({
|
||||
candidate: syntheticCandidate({ mtimeMs: candidate.file.mtimeMs + 1 }),
|
||||
messages: userMessages('secondgeneration', 10)
|
||||
})
|
||||
|
||||
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
|
||||
expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(10)
|
||||
@@ -541,8 +545,12 @@ it('replaces a generation by cutting the old one loose, not by deleting it inlin
|
||||
})
|
||||
|
||||
it('drains what a replace cut loose without being asked', async () => {
|
||||
replayTranscriptRead({ messages: userMessages('firstgeneration', 200) })
|
||||
replayTranscriptRead({ messages: userMessages('secondgeneration', 3) })
|
||||
const candidate = syntheticCandidate()
|
||||
replayTranscriptRead({ candidate, messages: userMessages('firstgeneration', 200) })
|
||||
replayTranscriptRead({
|
||||
candidate: syntheticCandidate({ mtimeMs: candidate.file.mtimeMs + 1 }),
|
||||
messages: userMessages('secondgeneration', 3)
|
||||
})
|
||||
|
||||
// The store schedules the reclaim the way it schedules retention's. Hiding a
|
||||
// generation and never reclaiming it would grow the file by every re-read.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
registerTranscriptConsumer,
|
||||
type TranscriptConsumer,
|
||||
@@ -10,6 +11,35 @@ import { fileIdentity } from './session-search-file-cursor'
|
||||
import type { SessionSearchFileWrite } from './session-search-index-writer'
|
||||
import type { SessionSearchStore } from './session-search-store'
|
||||
|
||||
// File-read assumptions are checked by session-search-transcript-dependencies.test.ts.
|
||||
export const SELF_CONTAINED_TRANSCRIPT_AGENTS = {
|
||||
claude: true,
|
||||
qoder: true,
|
||||
codebuddy: true,
|
||||
codex: false, // session_index.jsonl supplies titles.
|
||||
gemini: true,
|
||||
copilot: true,
|
||||
cursor: false, // Sibling chat metadata enriches the transcript.
|
||||
antigravity: false, // Workspace history enriches the transcript separately.
|
||||
opencode: false, // SQLite or a separate message directory.
|
||||
opencode2: false,
|
||||
zcode: false,
|
||||
grok: false, // chat_history.jsonl supplies messages.
|
||||
hermes: true,
|
||||
rovo: false, // session_context.json supplies messages.
|
||||
openclaw: true,
|
||||
pi: true,
|
||||
omp: true,
|
||||
'prime-agent': true,
|
||||
droid: true,
|
||||
cline: false, // A .messages.json sidecar supplies messages.
|
||||
devin: false, // sessions.db supplies metadata.
|
||||
kimi: false, // agents/main/wire.jsonl supplies messages.
|
||||
muse: true,
|
||||
jcode: true,
|
||||
kiro: false // A .jsonl sidecar supplies messages.
|
||||
} satisfies Record<AiVaultAgent, boolean>
|
||||
|
||||
/**
|
||||
* The search index as a consumer of the transcript reader.
|
||||
*
|
||||
@@ -29,6 +59,14 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
|
||||
|
||||
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null {
|
||||
const { candidate } = start
|
||||
if (
|
||||
start.mode === 'replace' &&
|
||||
SELF_CONTAINED_TRANSCRIPT_AGENTS[candidate.agent] &&
|
||||
(candidate.file.sidecar === undefined || candidate.file.sidecar === 'none') &&
|
||||
this.store.coversTranscript(candidate)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (start.mode === 'append') {
|
||||
const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file))
|
||||
if (!cursor || cursor.byteOffset !== start.previousByteOffset) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
|
||||
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
|
||||
import {
|
||||
openSessionSearchIndexFile,
|
||||
replayTranscriptRead,
|
||||
syntheticCandidate,
|
||||
userMessages,
|
||||
type SessionSearchIndexFile
|
||||
} from './session-search-index-test-fixture'
|
||||
import { SessionSearchStore } from './session-search-store'
|
||||
|
||||
let index: SessionSearchIndexFile
|
||||
let store: SessionSearchStore
|
||||
const messages = userMessages('Searchable conversation', 2)
|
||||
|
||||
beforeEach(async () => {
|
||||
index = await openSessionSearchIndexFile('ss-list-reparse')
|
||||
store = new SessionSearchStore(index.path)
|
||||
registerSessionSearchIndexConsumer(store)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetTranscriptConsumersForTests()
|
||||
store.close()
|
||||
await index.close()
|
||||
})
|
||||
|
||||
function sessionRow(): unknown {
|
||||
return index.db.prepare('SELECT id FROM sessions').get()
|
||||
}
|
||||
|
||||
it('keeps current search rows when a cold history list rereads the same transcript', () => {
|
||||
replayTranscriptRead({ messages })
|
||||
const before = sessionRow()
|
||||
replayTranscriptRead({ messages })
|
||||
expect(sessionRow()).toEqual(before)
|
||||
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 2 })
|
||||
})
|
||||
|
||||
it('still replaces a changed transcript', () => {
|
||||
const candidate = syntheticCandidate()
|
||||
replayTranscriptRead({ candidate, messages })
|
||||
const before = sessionRow()
|
||||
replayTranscriptRead({
|
||||
candidate: syntheticCandidate({ mtimeMs: candidate.file.mtimeMs + 1 }),
|
||||
messages
|
||||
})
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
})
|
||||
|
||||
it('still replaces a transcript whose size changed without a timestamp change', () => {
|
||||
replayTranscriptRead({ messages })
|
||||
const before = sessionRow()
|
||||
replayTranscriptRead({ candidate: syntheticCandidate({ sizeBytes: 5000 }), messages })
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
})
|
||||
|
||||
it('refreshes metadata supplied by a sidecar despite an unchanged transcript', () => {
|
||||
replayTranscriptRead({ messages })
|
||||
const before = sessionRow()
|
||||
replayTranscriptRead({
|
||||
candidate: syntheticCandidate({
|
||||
sidecar: { path: 'metadata.json', mtimeMs: 1, sizeBytes: 10 }
|
||||
}),
|
||||
messages
|
||||
})
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
})
|
||||
|
||||
it('rereads when the transcript size is unknown', () => {
|
||||
replayTranscriptRead({ messages })
|
||||
const before = sessionRow()
|
||||
replayTranscriptRead({ candidate: syntheticCandidate({ sizeBytes: undefined }), messages })
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
})
|
||||
|
||||
it('still replaces a different file with identical size and timestamp', () => {
|
||||
replayTranscriptRead({ candidate: syntheticCandidate({ dev: 1, ino: 10 }), messages })
|
||||
const before = sessionRow()
|
||||
replayTranscriptRead({ candidate: syntheticCandidate({ dev: 1, ino: 11 }), messages })
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
})
|
||||
|
||||
it('rereads a row indexed without a file identity once the stat supplies one', () => {
|
||||
replayTranscriptRead({ messages })
|
||||
const before = sessionRow()
|
||||
const identified = syntheticCandidate({ dev: 1, ino: 10 })
|
||||
replayTranscriptRead({ candidate: identified, messages })
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
const repaired = sessionRow()
|
||||
replayTranscriptRead({ candidate: identified, messages })
|
||||
expect(sessionRow()).toEqual(repaired)
|
||||
})
|
||||
|
||||
it.each(['due', 'failed'] as const)('repairs an unchanged transcript marked %s', (state) => {
|
||||
replayTranscriptRead({ messages })
|
||||
const before = sessionRow()
|
||||
store.setFileState(syntheticCandidate().file.path, state)
|
||||
replayTranscriptRead({ messages })
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
expect(index.db.prepare('SELECT state FROM files').get()).toEqual({ state: 'current' })
|
||||
})
|
||||
|
||||
it('repairs a partial index even when the stat matches', () => {
|
||||
replayTranscriptRead({ messages })
|
||||
const before = sessionRow()
|
||||
index.db.exec('UPDATE files SET byte_offset = -1')
|
||||
replayTranscriptRead({ messages })
|
||||
expect(sessionRow()).not.toEqual(before)
|
||||
})
|
||||
|
||||
it('still indexes new appended messages', () => {
|
||||
replayTranscriptRead({ messages })
|
||||
replayTranscriptRead({
|
||||
candidate: syntheticCandidate({ sizeBytes: 5000 }),
|
||||
mode: 'append',
|
||||
previousByteOffset: 4096,
|
||||
messages,
|
||||
outcome: { byteOffset: 5000 }
|
||||
})
|
||||
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 4 })
|
||||
})
|
||||
@@ -2,9 +2,10 @@ import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { asRecord } from '../ai-vault/session-scanner-record-value'
|
||||
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
|
||||
import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
|
||||
import type {
|
||||
SessionSearchFileIdentity,
|
||||
SessionSearchIndexedFile
|
||||
import {
|
||||
fileIdentity,
|
||||
type SessionSearchFileIdentity,
|
||||
type SessionSearchIndexedFile
|
||||
} from './session-search-file-cursor'
|
||||
import {
|
||||
SESSION_SEARCH_COMMIT_CHARS,
|
||||
@@ -171,6 +172,37 @@ export class SessionSearchStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** A cold list read must not rewrite a complete, unchanged search entry. */
|
||||
coversTranscript(candidate: SessionFileCandidate): boolean {
|
||||
const file = candidate.file
|
||||
if (this.closed || typeof file.sizeBytes !== 'number') {
|
||||
return false
|
||||
}
|
||||
const identity = fileIdentity(file)
|
||||
try {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
// A row without a stored identity is uncovered so the rewrite records one.
|
||||
`SELECT 1 FROM files WHERE path = ? AND state = 'current'
|
||||
AND mtime_ms = ? AND size_bytes = ? AND byte_offset = size_bytes
|
||||
AND (? IS NULL OR (CAST(dev AS REAL) = ? AND CAST(ino AS REAL) = ?))`
|
||||
)
|
||||
.get(
|
||||
file.path,
|
||||
file.mtimeMs,
|
||||
file.sizeBytes,
|
||||
identity?.dev ?? null,
|
||||
identity?.dev ?? null,
|
||||
identity?.ino ?? null
|
||||
) !== undefined
|
||||
)
|
||||
} catch (error) {
|
||||
this.onError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Null when this read cannot extend the index, or when the store refuses writes. */
|
||||
beginWrite(
|
||||
candidate: SessionFileCandidate,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
|
||||
import * as transcriptFs from '../native-chat/wsl-transcript-fs-access'
|
||||
import { parseAgentSessionFile } from '../ai-vault/session-scanner-agent-parser'
|
||||
import { AI_VAULT_AGENT_SOURCES } from '../ai-vault/session-scanner-agent-sources'
|
||||
import { discoverFiles } from '../ai-vault/session-scanner-discovery'
|
||||
import {
|
||||
writeEveryAgentVault,
|
||||
type EveryAgentVault
|
||||
} from '../ai-vault/session-scanner-every-agent-fixture'
|
||||
import { geminiJsonlFixture } from '../ai-vault/session-scanner-incremental-fixtures'
|
||||
import {
|
||||
parseAgentSessionFileCached,
|
||||
resetSessionParseCacheForTests
|
||||
} from '../ai-vault/session-scanner-parse-cache'
|
||||
import {
|
||||
registerTranscriptConsumer,
|
||||
resetTranscriptConsumersForTests,
|
||||
type TranscriptMessage
|
||||
} from '../ai-vault/session-transcript-consumers'
|
||||
import { SELF_CONTAINED_TRANSCRIPT_AGENTS } from './session-search-index-consumer'
|
||||
|
||||
vi.mock('node:fs', { spy: true })
|
||||
vi.mock('node:fs/promises', { spy: true })
|
||||
vi.mock('../native-chat/wsl-transcript-fs-access', { spy: true })
|
||||
|
||||
let root: string
|
||||
let vault: EveryAgentVault
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fsPromises.mkdtemp(join(tmpdir(), 'ss-transcript-dependencies-'))
|
||||
vault = await writeEveryAgentVault(root)
|
||||
const gemini = geminiJsonlFixture()
|
||||
await fsPromises.writeFile(
|
||||
join(vault.roots.geminiSessionsDir, gemini.fileName),
|
||||
`${gemini.seedLines.join('\n')}\n`
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await fsPromises.rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function contentReadPaths(): unknown[] {
|
||||
return [
|
||||
...vi.mocked(fsPromises.readFile).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(fsPromises.open).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(fs.readFile).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(fs.readFileSync).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(fs.open).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(fs.openSync).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(fs.createReadStream).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(transcriptFs.wslGatedReadFile).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(transcriptFs.openTranscriptFile).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(transcriptFs.openTranscriptReadStream).mock.calls.map(([path]) => path),
|
||||
...vi.mocked(transcriptFs.readTranscriptSlice).mock.calls.map(([path]) => path)
|
||||
]
|
||||
}
|
||||
|
||||
it.each(AI_VAULT_AGENTS.filter((agent) => SELF_CONTAINED_TRANSCRIPT_AGENTS[agent]))(
|
||||
'%s reads indexed content only from its candidate transcript',
|
||||
async (agent) => {
|
||||
const source = AI_VAULT_AGENT_SOURCES[agent]
|
||||
if (!source) {
|
||||
throw new Error(`${agent} needs a real transcript fixture before enabling file-stat reuse`)
|
||||
}
|
||||
expect(source.contentDependencyPath).toBeUndefined()
|
||||
const discoveries = await Promise.all(
|
||||
source.rootDirs(vault.roots, []).map((rootDir) =>
|
||||
discoverFiles({
|
||||
...source,
|
||||
rootDir,
|
||||
agent,
|
||||
limit: 10,
|
||||
issues: [],
|
||||
extensions: [...source.extensions]
|
||||
})
|
||||
)
|
||||
)
|
||||
const files = discoveries.flatMap((discovery) => discovery.files)
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
|
||||
for (const file of files) {
|
||||
const candidate = { agent, file, codexHome: null }
|
||||
for (const read of ['direct', 'cached'] as const) {
|
||||
const messages: TranscriptMessage[] = []
|
||||
const sink = { active: true, push: (message: TranscriptMessage) => messages.push(message) }
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
registerTranscriptConsumer({
|
||||
beginRead: () => ({ message: sink.push, finish: () => undefined })
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
|
||||
const session = await (read === 'direct'
|
||||
? parseAgentSessionFile(candidate, process.platform, sink)
|
||||
: parseAgentSessionFileCached(candidate, process.platform))
|
||||
|
||||
expect(session).not.toBeNull()
|
||||
expect(messages.length).toBeGreaterThan(0)
|
||||
const paths = contentReadPaths()
|
||||
expect(paths).toContain(file.path)
|
||||
// Include failed optional reads: an absent sidecar can appear on a later scan.
|
||||
expect(
|
||||
paths.filter((path) => path !== file.path),
|
||||
`${read}: ${file.path}`
|
||||
).toEqual([])
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user