feat(session-query): ship full-text session search opt-in via openAt never

The shipped bundles keep ctx.sessionQuery mounted but set the new
session-query-sqlite `openAt: never` phase: searchSessions/searchEvents
fail with the typed SESSION_QUERY_SEARCH_DISABLED code before any request
normalization, node:sqlite is never imported or opened, and no source
observation or reconciliation runs. Every inherited exact read, filter,
and trace — session export descendants, subagent-fork Workspace
inheritance, title reads — keeps working, and the Web sidebar search
degrades to its designed local title/workspace matching. Enabling content
search is a one-line openAt override in a later patch layer; the web e2e
scaffold keeps it enabled as the assembled opt-in coverage.
This commit is contained in:
Hypatia May
2026-08-13 11:38:38 +08:00
parent 137c3c9254
commit b6b6a72df7
23 changed files with 207 additions and 41 deletions

View File

@@ -231,6 +231,36 @@ describe('SQLite session search', () => {
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('refuses search in never mode while inherited reads and traces keep working', async () => {
const path = await temporaryPath('never-mode.db')
const ctx = new Context()
await ctx.plugin(SessionStore)
const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'never' })
const service = ctx.sessionQuery as SqliteSessionQueryEngine
expect(service.config.openAt).toBe('never')
const parent = SessionId('never-parent')
const child = SessionId('never-child')
ctx.sessions.create(parent, { seed: messageEvents('never opened needle'), meta: { createdAt: 10 } })
ctx.sessions.create(child, { meta: { parentSession: parent, createdAt: 20 } })
await expect(service.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SEARCH_DISABLED'))
await expect(service.searchEvents({ sessionId: parent, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SEARCH_DISABLED'))
expect((await service.listSessions()).map(record => record.header.id).sort())
.toEqual([child, parent])
const lineage = await service.traceSession(parent)
expect(lineage.complete).toBe(true)
expect(lineage.descendants.map(node => node.session.header.id)).toEqual([child])
// The disabled index never touches the filesystem, in mount, use, or disposal.
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
await search.dispose()
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('opens once on the first search and reuses readiness for later searches', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)