mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
fix(session-search): stabilize paging and host enablement
This commit is contained in:
@@ -100,6 +100,17 @@ function updateRootRefreshTimer(refresh: RootRefresh, enabled: boolean): void {
|
||||
|
||||
async function pushSessionSearchPolicy(refresh: RootRefresh, policyChanged = true): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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<typeof harness.roots>()
|
||||
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 })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user