diff --git a/src/main/ai-vault-search/session-search-enablement.ts b/src/main/ai-vault-search/session-search-enablement.ts index 9d2a33f0ac9..a9bb508c67f 100644 --- a/src/main/ai-vault-search/session-search-enablement.ts +++ b/src/main/ai-vault-search/session-search-enablement.ts @@ -100,6 +100,17 @@ function updateRootRefreshTimer(refresh: RootRefresh, enabled: boolean): void { async function pushSessionSearchPolicy(refresh: RootRefresh, policyChanged = true): Promise { try { + const current = sessionSearchServiceInit() + if (!current || refresh.disposed) { + return + } + if (!current.settings.enabled) { + updateRootRefreshTimer(refresh, false) + if (policyChanged) { + updateSessionSearchInService(current) + } + return + } await refreshSessionSearchScanRoots() if (refresh.disposed) { return diff --git a/src/main/ai-vault-search/session-search-host-registration.test.ts b/src/main/ai-vault-search/session-search-host-registration.test.ts index 13bebc0adb2..d34a05537f0 100644 --- a/src/main/ai-vault-search/session-search-host-registration.test.ts +++ b/src/main/ai-vault-search/session-search-host-registration.test.ts @@ -143,18 +143,18 @@ it('starts root refresh on enable and cancels it on disable', async () => { getSettings: () => settings }) await vi.advanceTimersByTimeAsync(300_000) - expect(localAiVaultScanRoots).toHaveBeenCalledTimes(1) + expect(localAiVaultScanRoots).toHaveBeenCalledTimes(0) const before = settings settings = { aiVaultSearch: { enabled: true, historyDays: null } } applySessionSearchSettingsChange(before, settings) await vi.advanceTimersByTimeAsync(300_000) - expect(localAiVaultScanRoots).toHaveBeenCalledTimes(3) + expect(localAiVaultScanRoots).toHaveBeenCalledTimes(2) const enabled = settings settings = before applySessionSearchSettingsChange(enabled, settings) await vi.advanceTimersByTimeAsync(300_000) - expect(localAiVaultScanRoots).toHaveBeenCalledTimes(4) + expect(localAiVaultScanRoots).toHaveBeenCalledTimes(2) expect(updateSessionSearchInService).toHaveBeenLastCalledWith( expect.objectContaining({ settings: before.aiVaultSearch }) ) @@ -205,8 +205,8 @@ it('registers an in-process service for a host with no scanner child', async () // host's boot path reaches one, which no unit of either module can show. it.each([ [ - 'desktop main', - 'src/main/ipc/register-core-handlers/register-core-handlers.ts', + 'desktop and headless serve', + 'src/main/startup/main-process-runtime-service.ts', 'installChildSessionSearchService' ], ['orcad', 'src/main/orcad/orcad-session-search.ts', 'installInProcessSessionSearchService'], @@ -220,3 +220,28 @@ it.each([ expect(source).toContain(installer) expect(source).toMatch(new RegExp(`${installer}\\(\\{`)) }) + +it('disables immediately while an enabled root discovery is pending', async () => { + const { installChildSessionSearchService, applySessionSearchSettingsChange } = + await import('./session-search-enablement') + let settings = { aiVaultSearch: { enabled: true, historyDays: null } } + const pending = Promise.withResolvers() + localAiVaultScanRoots.mockReturnValueOnce(pending.promise) + installed = installChildSessionSearchService({ + dataRoot: harness.root, + getSettings: () => settings + }) + const before = settings + settings = { aiVaultSearch: { enabled: false, historyDays: null } } + applySessionSearchSettingsChange(before, settings) + expect(updateSessionSearchInService).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ settings: settings.aiVaultSearch }) + ) + expect(localAiVaultScanRoots).toHaveBeenCalledTimes(1) + pending.resolve(harness.roots) + await Promise.resolve() + await Promise.resolve() + expect(updateSessionSearchInService).toHaveBeenLastCalledWith( + expect.objectContaining({ settings: settings.aiVaultSearch }) + ) +}) diff --git a/src/main/ai-vault-search/session-search-instance.test.ts b/src/main/ai-vault-search/session-search-instance.test.ts index 579e4b3b4db..c917bd2d8a9 100644 --- a/src/main/ai-vault-search/session-search-instance.test.ts +++ b/src/main/ai-vault-search/session-search-instance.test.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs' import { utimes } from 'node:fs/promises' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' import { SessionSearchInstance } from './session-search-instance' @@ -27,6 +27,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() instance?.close() resetTranscriptConsumersForTests() resetSessionParseCacheForTests() @@ -165,3 +166,30 @@ it('removes the database on clear and rebuilds only while consent stands', async expect(existsSync(harness.databasePath)).toBe(false) expect(errors).toEqual([]) }) + +it('keeps pagination stable when the clock crosses retention before a purge', async () => { + for (const id of [RECENT_SESSION_ID, ANCIENT_SESSION_ID]) { + await writeClaudeTranscript(transcriptPath(id), [`distinctive conversation ${id}`], id) + } + const subject = newInstance() + subject.apply({ enabled: true, historyDays: 30 }) + await subject.settled() + const first = await subject.search({ query: 'distinctive', limit: 1 }) + if (first.kind !== 'results') { + throw new Error('expected results') + } + expect(first.page.cursor).toBeTruthy() + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31 * 86_400_000) + const second = await subject.search({ + query: 'distinctive', + limit: 1, + cursor: first.page.cursor! + }) + if (second.kind !== 'results') { + throw new Error('expected results') + } + expect(second.generation).toBe(first.generation) + expect(second.hits).toHaveLength(1) + expect(second.hits[0].sessionId).not.toBe(first.hits[0].sessionId) + expect(errors).toEqual([]) +}) diff --git a/src/main/ai-vault-search/session-search-instance.ts b/src/main/ai-vault-search/session-search-instance.ts index 98a5d72054d..ad1d4379be6 100644 --- a/src/main/ai-vault-search/session-search-instance.ts +++ b/src/main/ai-vault-search/session-search-instance.ts @@ -6,7 +6,7 @@ import type { } from '../../shared/ai-vault-search-types' import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' -import { SessionSearchEngine, type SessionSearchEngineOptions } from './session-search-engine' +import { SessionSearchEngine } from './session-search-engine' import { SessionSearchIndexer } from './session-search-indexer' import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy' import { openSessionSearchDatabase, removeSessionSearchDatabase } from './session-search-schema' @@ -26,9 +26,7 @@ type LiveIndex = { engine: SessionSearchEngine /** The engine's own handle; the indexer's store keeps a second, private one. */ db: SyncDatabase - engineOptions: SessionSearchEngineOptions service: SessionSearchService - historyDays: number | null } /** @@ -82,7 +80,6 @@ export class SessionSearchInstance { if (!live) { return { kind: 'unavailable', reason: this.settings.enabled ? 'not-ready' : 'disabled' } } - this.refreshRetentionCutoff(live) return live.service.search(request) } @@ -125,7 +122,8 @@ export class SessionSearchInstance { : { reconcileIntervalMs: this.options.reconcileIntervalMs }) }) db = openSessionSearchDatabase(this.options.databasePath) - const engineOptions: SessionSearchEngineOptions = { + // Later expiry comes from the indexer purge, which also invalidates page cursors. + const engineOptions = { retentionCutoffMs: sessionSearchHistoryCutoffMs(historyDays, Date.now()) } const engine = new SessionSearchEngine(db, engineOptions) @@ -133,8 +131,6 @@ export class SessionSearchInstance { indexer, engine, db, - engineOptions, - historyDays, service: createSessionSearchService({ engine, indexer }) } void indexer.start().catch(this.onError) @@ -148,18 +144,6 @@ export class SessionSearchInstance { } } - /** - * The window moves with the clock. The store re-reads it every pass; the - * engine holds its options object, so this is where a long-lived instance's - * search stops answering with rows the next purge will delete. - */ - private refreshRetentionCutoff(live: LiveIndex): void { - live.engineOptions.retentionCutoffMs = sessionSearchHistoryCutoffMs( - live.historyDays, - Date.now() - ) - } - private closeLive(): void { const live = this.live this.live = null diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 02ab7d6b593..cb08d7a511f 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -26,8 +26,6 @@ import { registerRuntimeEnvironmentHandlers } from '../runtime-environments' import { registerEphemeralVmHandlers } from '../ephemeral-vm' import { registerAiVaultHandlers } from '../ai-vault' import { registerAiVaultSearchHandlers } from '../ai-vault-search' -import { installChildSessionSearchService } from '../../ai-vault-search/session-search-enablement' -import { getCanonicalUserDataPath } from '../../persistence/loading-store/user-data-path' import { registerNativeChatHandlers } from '../native-chat' import { registerNotificationHandlers } from '../notifications' import { registerNotebookHandlers } from '../notebook' @@ -222,14 +220,6 @@ export function registerCoreHandlers( callRuntimeSearch: (environmentId, method, params) => callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params) }) - // Why beside the handlers and not in preflight: the handlers are what answer a - // search, and this is what gives them something to answer from. Same canonical - // path the parse cache takes, so both halves of the scanner's state agree. - const sessionSearch = installChildSessionSearchService({ - dataRoot: getCanonicalUserDataPath(), - getSettings: () => store.getSettings() - }) - app.once('will-quit', () => sessionSearch?.dispose()) registerAiVaultHandlers({ ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 3aac4a03b3c..a0a65d54e7a 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -1,3 +1,5 @@ +import { installChildSessionSearchService } from '../ai-vault-search/session-search-enablement' +import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path' import { app } from 'electron' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty' @@ -131,6 +133,12 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { orchestrationEnvironmentTransport, skillTransactionRecovery: state.skillTransactionRecovery }) + // Both desktop and headless serve own a host-local search service. + const sessionSearch = installChildSessionSearchService({ + dataRoot: getCanonicalUserDataPath(), + getSettings: () => store.getSettings() + }) + app.once('will-quit', () => sessionSearch?.dispose()) state.runtime = runtime agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)