diff --git a/src/main/ai-vault-search/session-search-child-service.ts b/src/main/ai-vault-search/session-search-child-service.ts index 92ad066d417..7859d4e612e 100644 --- a/src/main/ai-vault-search/session-search-child-service.ts +++ b/src/main/ai-vault-search/session-search-child-service.ts @@ -27,7 +27,9 @@ export function createChildSessionSearchService( search: async (request, hostScope) => { try { return await calls.search(request, hostScope) - } catch { + } catch (error) { + // The child's own search errors land here too; without this they read only as "not ready". + console.warn('[ai-vault-search] search failed', error) return { kind: 'unavailable', reason: 'not-ready' } } }, diff --git a/src/main/ai-vault-search/session-search-row-filter.test.ts b/src/main/ai-vault-search/session-search-row-filter.test.ts index 1cec0a528f9..680b7801714 100644 --- a/src/main/ai-vault-search/session-search-row-filter.test.ts +++ b/src/main/ai-vault-search/session-search-row-filter.test.ts @@ -59,6 +59,7 @@ describe('a cwd scope is the sidebar key, or anything below it', () => { ['/work/app-other', '/work/app', false], ['/work/a_b/src', '/work/a_b', true], ['/work/axb/src', '/work/a_b', false], + ['/work/lone\ud800/src', '/work/lone\ud800', true], // Roots: `/` is the one key that is already a separator, which is where a // range bound is easiest to get wrong. A Windows key is not under POSIX `/`. ['/', '/', true], @@ -143,5 +144,13 @@ it('plans a cwd scope as a seek on sessions_cwd_key, never a scan', async () => expect(plan.join(' | ')).toContain('sessions_cwd_key') expect(plan.some((detail) => detail.startsWith('SEARCH'))).toBe(true) - expect(plan.some((detail) => detail.startsWith('SCAN sessions'))).toBe(false) + expect(plan.some((detail) => /^SCAN (sessions|scoped)\b/.test(detail))).toBe(false) +}) + +it('matches under a thousand-plus scopes, past where an OR chain exceeds SQLite expression depth', async () => { + const db = await openIndex() + addSession(db, 1, '/lanes/lane-1199/src') + addSession(db, 2, '/elsewhere') + const scopePaths = Array.from({ length: 1_200 }, (_, index) => `/lanes/lane-${index}`) + expect(selected(db, { scopePaths })).toEqual([1]) }) diff --git a/src/main/ai-vault-search/session-search-row-filter.ts b/src/main/ai-vault-search/session-search-row-filter.ts index 4f5b6106e7d..a268a06ef8b 100644 --- a/src/main/ai-vault-search/session-search-row-filter.ts +++ b/src/main/ai-vault-search/session-search-row-filter.ts @@ -49,39 +49,44 @@ export function sessionRowFilter( filter.values.push(filters.since) } if (filters.scopePaths && filters.scopePaths.length > 0) { - // Several scopes mean any of them; every other narrowing is ANDed on. - const present = filters.scopePaths - .map((scope) => scopeCondition(filter, scope)) - .filter((condition) => condition !== null) - // Every scope unkeyable still means a scope, so it narrows to nothing; - // pushing no condition would widen the search to every session instead. - filter.conditions.push(present.length > 0 ? `(${present.join(' OR ')})` : '0 = 1') + // Any scope matches; one that cannot be keyed matches nothing, and all-unkeyable must not widen to every session. + const keys = filters.scopePaths.map(cwdKey).filter((key) => key !== null) + if (keys.length === 0) { + filter.conditions.push('0 = 1') + } else { + filter.conditions.push(SCOPE_CONDITION) + filter.values.push(JSON.stringify(keys.map(scopeRange))) + } } return filter } -/** A scope the caller could not key is a scope nothing is inside of. */ -function scopeCondition(filter: SessionRowFilter, scope: string): string | null { - const key = cwdKey(scope) - return key === null ? null : insideCondition(filter, key) -} - /** - * `key` itself, or anything below it. Why a half-open range and not - * `substr(key, 1, length(?)) = ?`: only `>=`/`<` can seek `sessions_cwd_key`; - * the substr form scans it. The bound is the child prefix with its last byte - * incremented, so it stops at the end of that prefix and nowhere else. The two - * arms cannot merge: one range over the bare key would also swallow a sibling - * like `/work/app-other`. No wildcards, so `%`/`_` in a folder name are literal. + * `key` itself, or anything below it. One JSON parameter, not an OR per scope: + * ~500 OR arms exceed SQLite's expression depth of 1000 and the search throws. + * + * Why a half-open range and not `substr(key, 1, length(?)) = ?`: only `>=`/`<` + * can seek `sessions_cwd_key`; the substr form scans it. The bound is the child + * prefix with its last byte incremented, so it stops at the end of that prefix + * and nowhere else. The two arms cannot merge: one range over the bare key would + * also swallow a sibling like `/work/app-other`. No wildcards, so `%`/`_` in a + * folder name are literal. * * The filesystem root is the one key that already ends in a separator, and * appending a second one would bound the range at `//`, which sorts below every * real child; `cwdKey` keeps it as `/` for exactly this reason. */ -function insideCondition(filter: SessionRowFilter, key: string): string { +const SCOPE_CONDITION = `id IN ( + SELECT scoped.id FROM json_each(?) AS scope JOIN sessions AS scoped + ON scoped.${CWD} = scope.value ->> 0 + OR (scoped.${CWD} >= scope.value ->> 1 AND scoped.${CWD} < scope.value ->> 2) +)` + +function scopeRange(scopeKey: string): [string, string, string] { + // A bound key stores a lone surrogate as U+FFFD; JSON would decode it to other bytes. + const key = scopeKey.toWellFormed() const children = key.endsWith('/') ? key : `${key}/` - filter.values.push(key, children, nextAfterPrefix(children)) - return `(${CWD} = ? OR (${CWD} >= ? AND ${CWD} < ?))` + return [key, children, nextAfterPrefix(children)] } /** The first string that sorts after every string starting with `prefix`. */