feat(session-search): report how many messages an index holds

The status contract gains an optional messagesIndexed, read from the store
beside the file-state counts and cached the same way, so a settings row can
say what is searchable rather than how many files were opened. Optional on
the wire: a paired server that predates the field degrades to a session count.
This commit is contained in:
Jinwoo-H
2026-09-15 22:04:07 -04:00
parent 5afd74ef88
commit 366118be0f
5 changed files with 39 additions and 6 deletions
@@ -61,6 +61,14 @@ function newIndexer(
return indexer
}
/** What the index holds, counted the way `status()` counts it. */
function indexedMessageCount(): number {
const row = harness.read((db: SyncDatabase) =>
db.prepare('SELECT count(*) AS n FROM messages').get()
)
return row && typeof row === 'object' && 'n' in row && typeof row.n === 'number' ? row.n : -1
}
/** Sessions a published-view read returns for one term, the only legal shape. */
function sessionsMatching(term: string): string[] {
return harness.read((db: SyncDatabase) =>
@@ -256,6 +264,9 @@ it('resumes after close and reopen without re-reading what it already indexed',
// `filesIndexed` is the count of rows the index holds at their current stat,
// so it stays 2. That nothing was opened again is the read loop's own test.
expect(reopened.status()).toMatchObject({ filesIndexed: 2, filesDue: 0 })
// The same number the pane shows as "messages searchable", read from the rows rather than counted as they land.
expect(indexedMessageCount()).toBeGreaterThan(0)
expect(reopened.status().messagesIndexed).toBe(indexedMessageCount())
expect(
harness.read((db: SyncDatabase) => db.prepare('SELECT count(*) AS n FROM messages').get())
).toEqual(indexedRows)
@@ -36,6 +36,8 @@ export type SessionSearchIndexStatus = {
filesDue: number
/** Rows whose last read did not commit. */
filesFailed: number
/** Messages the index holds across every indexed row. */
messagesIndexed: number
degradedRoots: SessionSearchDegradedRoot[]
lastReconcileAt: number | null
/** When a whole-machine sweep last finished; null until one has. */
@@ -187,13 +189,15 @@ export class SessionSearchIndexer {
const settled = (this.closed ? this.lastCounts : this.readCounts()) ?? {
current: 0,
due: 0,
failed: 0
failed: 0,
messages: 0
}
return {
phase: this.phase(settled),
filesIndexed: settled.current,
filesDue: settled.due,
filesFailed: settled.failed,
messagesIndexed: settled.messages,
degradedRoots: this.degradedRoots.map((root) => ({ ...root })),
lastReconcileAt: this.lastReconcileAt,
lastSweepCompletedAt: this.lastSweepCompletedAt
@@ -24,7 +24,8 @@ async function fixture() {
// degradedRoots is re-stated because the contract type leaves `root` optional
// for relay redaction, while the indexer always names the root it degraded.
const indexer = {
status: () => ({ ...status, degradedRoots: [] }),
// messagesIndexed is optional on the wire and required of an indexer, which has read the rows.
status: () => ({ ...status, messagesIndexed: 0, degradedRoots: [] }),
reconcile: vi.fn(async () => {})
}
const service = createSessionSearchService({ engine: harness.engine, indexer })
@@ -41,8 +41,13 @@ export type SessionSearchFileRow = {
failedMtimeMs: number | null
}
/** How many rows are in each state; the whole of the indexer's progress report. */
export type SessionSearchStateCounts = { current: number; due: number; failed: number }
/** How many rows are in each state, plus the indexed message total; the whole of the indexer's progress report. */
export type SessionSearchStateCounts = {
current: number
due: number
failed: number
messages: number
}
/**
* Owns the index database. PR 2 scope: the write half only — the transcript
@@ -266,19 +271,29 @@ export class SessionSearchStore {
}
}
/** Rows per state. The status is this query and the pass's own degraded roots. */
/** Rows per state and indexed messages. The status is these queries and the pass's own degraded roots. */
stateCounts(): SessionSearchStateCounts {
const rows = this.db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() as {
state: SessionSearchFileState
n: number
}[]
const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0 }
const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0, messages: 0 }
for (const row of rows) {
counts[row.state] = Number(row.n)
}
counts.messages = this.messageCount()
return counts
}
/** Messages the index holds. Read with the file states so both describe one moment. */
private messageCount(): number {
const row: unknown = this.db.prepare('SELECT count(*) AS n FROM messages').get()
if (row && typeof row === 'object' && 'n' in row && typeof row.n === 'number') {
return row.n
}
return 0
}
/**
* Drops a source's rows. Only a proven deletion may call this: an unreadable
* source is `unverifiable`, not `missing`, and keeps its rows
+2
View File
@@ -111,6 +111,8 @@ export const AiVaultSearchStatusSchema = z.object({
filesIndexed: z.number().int().nonnegative(),
filesDue: z.number().int().nonnegative(),
filesFailed: z.number().int().nonnegative(),
// Optional: a host that predates this field degrades to a session count only.
messagesIndexed: z.number().int().nonnegative().optional(),
// `root` is a host path, withheld over the relay; the array length is the count.
degradedRoots: z.array(z.object({ root: z.string().optional(), reason: z.string() })),
lastReconcileAt: z.number().nullable(),