feat(session-search): enable indexing on paired servers from a client

Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's
transcript index on or off and have the server apply it without a restart.

The runtime method refuses any caller without a `pairedDeviceId` with a
`forbidden`-class error, writes the whole resolved policy through the runtime
store so retention rides along untouched, then reaches the index through a
host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the
in-process instance's new `apply` on orcad. The relay is unchanged.

Wire compatibility is Rule 1 shaped: a new optional method. A server that
predates it answers method-not-found, which the desktop IPC handler maps to an
error whose message is exactly `host-too-old`. Old clients never call it. The
method is deliberately absent from the mobile allowlist, and `aiVaultSearch`
stays out of the paired settings projection.
This commit is contained in:
Jinwoo-H
2026-09-14 18:32:54 -04:00
parent 2895c96f61
commit 640c715fbd
24 changed files with 463 additions and 25 deletions
@@ -1,7 +1,4 @@
import {
resolveAiVaultSearchSettings,
sameAiVaultSearchSettings
} from '../../shared/ai-vault-search-settings'
import { changedAiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { updateSessionSearchInService } from '../ai-vault/session-scanner-service-spawn'
import { createChildSessionSearchService } from './session-search-child-service'
@@ -49,12 +46,7 @@ export function applySessionSearchSettingsChange(
before: Pick<GlobalSettings, 'aiVaultSearch'>,
after: Pick<GlobalSettings, 'aiVaultSearch'>
): void {
if (
sameAiVaultSearchSettings(
resolveAiVaultSearchSettings(before),
resolveAiVaultSearchSettings(after)
)
) {
if (!changedAiVaultSearchSettings(before, after)) {
return
}
if (installed) {
@@ -3,6 +3,7 @@ import { join } from 'node:path'
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 type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
import { installInProcessSessionSearchService } from './session-search-in-process-service'
import {
openSessionSearchIndexerHarness,
@@ -35,7 +36,7 @@ vi.mock('../ai-vault/cached-session-list', async (importOriginal) => ({
const ROOT = join(import.meta.dirname, '..', '..', '..')
let harness: SessionSearchIndexerHarness
let installed: { dispose(): void } | null
let installed: { apply?(settings: AiVaultSearchSettings): void; dispose(): void } | null
beforeEach(async () => {
resetSessionParseCacheForTests()
@@ -204,3 +205,37 @@ it('orcad resolves no roots while disabled and discovers late roots when enabled
expect(response.hits.map((hit) => hit.sessionId)).toEqual([id])
}
})
// A host with no scanner child has nothing to forward a policy to, so the installed
// service is itself how a settings write reaches the index.
it('re-applies consent on an in-process host without reinstalling the service', async () => {
installed = installInProcessSessionSearchService({
dataRoot: harness.root,
roots: harness.roots,
settings: { enabled: false, historyDays: null }
})
expect(await searchSessionService({ query: 'ledger' }, 'relay')).toEqual({
kind: 'unavailable',
reason: 'disabled'
})
installed?.apply?.({ enabled: true, historyDays: null })
expect(await searchSessionService({ query: 'ledger' }, 'relay')).not.toMatchObject({
kind: 'unavailable',
reason: 'disabled'
})
installed?.apply?.({ enabled: false, historyDays: null })
expect(await searchSessionService({ query: 'ledger' }, 'relay')).toEqual({
kind: 'unavailable',
reason: 'disabled'
})
})
// orcad reaches the index through the deps hook the runtime RPC calls; the wiring is
// what no unit of either module can show.
it('wires orcad consent from the runtime hook to the installed service', () => {
const source = readFileSync(join(ROOT, 'src/main/orcad/orcad-entry.ts'), 'utf8')
expect(source).toContain('applySessionSearchSettings:')
expect(source).toContain('sessionSearch?.apply(next)')
})
@@ -28,7 +28,7 @@ export function installInProcessSessionSearchService(args: {
resolveRoots?: SessionSearchIndexerOptions['resolveRoots']
settings: AiVaultSearchSettings
onError?: (error: unknown) => void
}): { dispose(): void } | null {
}): { apply(settings: AiVaultSearchSettings): void; dispose(): void } | null {
if (!sessionSearchSqliteAvailable()) {
return null
}
@@ -45,6 +45,9 @@ export function installInProcessSessionSearchService(args: {
reconcile: () => instance.reconcile()
})
return {
// Why exposed: on these hosts a settings write reaches the index through this
// object, there being no scanner child to forward a policy to.
apply: (settings) => instance.apply(settings),
dispose: () => {
setSessionSearchService(null)
instance.close()
+44
View File
@@ -187,4 +187,48 @@ describe('desktop IPC and preload search boundary', () => {
'not available for this execution host'
)
})
it('turns a paired runtime host on and answers with the status it reported', async () => {
const local = fakeSearchService()
setSessionSearchService(local)
const enabled = { ...unavailableSessionSearchStatus(), enabled: true, generation: 4 }
runtimeSearch.mockResolvedValue(enabled)
expect(await aiVaultApi.setSearchEnabled('runtime:env-1', true)).toEqual(enabled)
expect(runtimeSearch).toHaveBeenCalledExactlyOnceWith('env-1', 'aiVault.setSearchEnabled', {
enabled: true
})
// The desktop's own index is never a side effect of enabling a remote one.
expect(local.status).not.toHaveBeenCalled()
})
it('maps an unknown-method refusal to host-too-old and keeps every other failure', async () => {
runtimeSearch.mockRejectedValue(
Object.assign(new Error('Unknown method: aiVault.setSearchEnabled'), { code: -32601 })
)
await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('host-too-old')
runtimeSearch.mockRejectedValue(Object.assign(new Error('not paired'), { code: 'forbidden' }))
await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('not paired')
})
it('rejects a host answer that is not a status rather than reporting success', async () => {
runtimeSearch.mockResolvedValue({ enabled: true })
await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow()
})
it('refuses local, SSH, unroutable hosts and a non-boolean', async () => {
await expect(aiVaultApi.setSearchEnabled('local', true)).rejects.toThrow('through Settings')
await expect(aiVaultApi.setSearchEnabled('ssh:box', true)).rejects.toThrow('unsupported')
await expect(handlers.get('aiVault:setSearchEnabled')!(null, 'nope', true)).rejects.toThrow(
'not available for this execution host'
)
await expect(
handlers.get('aiVault:setSearchEnabled')!(null, 'runtime:env-1', 'yes')
).rejects.toThrow()
expect(runtimeSearch).not.toHaveBeenCalled()
expect(sshSearch).not.toHaveBeenCalled()
})
it('reports host-too-old when this desktop has no runtime transport injected', async () => {
handlers.clear()
registerAiVaultSearchHandlers()
await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('host-too-old')
})
})
+51 -1
View File
@@ -6,9 +6,14 @@ import {
} from '../ai-vault-search/session-search-service-registry'
import {
createSessionSearchClient,
isUnknownSessionSearchMethod,
unavailableSessionSearchStatus
} from '../../shared/ai-vault-search-client'
import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contract'
import {
AiVaultSearchRequestSchema,
AiVaultSearchStatusSchema,
AiVaultSetSearchEnabledParamsSchema
} from '../../shared/ai-vault-search-contract'
import type {
AiVaultSearchRequest,
AiVaultSearchResponse,
@@ -42,6 +47,12 @@ export type AiVaultSearchHandlerOptions = {
// One wording with the session list, which refuses the same unroutable scope.
const UNROUTABLE_HOST_MESSAGE = 'Agent Session History is not available for this execution host.'
// Consent is written where the index lives: locally through settings, never here.
const LOCAL_ENABLE_MESSAGE =
'Local Agent Session History indexing is changed through Settings, not this channel.'
const SSH_ENABLE_MESSAGE = 'unsupported'
/** Exact text, not a class: the renderer maps this one message to its own copy. */
const HOST_TOO_OLD_MESSAGE = 'host-too-old'
const scopeSchema = z.string().min(1).optional()
let handlerOptions: AiVaultSearchHandlerOptions = {}
@@ -61,9 +72,48 @@ export function registerAiVaultSearchHandlers(options: AiVaultSearchHandlerOptio
const scope = requestedSearchScope(rawScope)
return statusByExecutionHost(scope)
})
ipcMain.handle(
'aiVault:setSearchEnabled',
async (_event, rawScope: unknown, rawEnabled: unknown) => {
const { enabled } = AiVaultSetSearchEnabledParamsSchema.parse({ enabled: rawEnabled })
return setSearchEnabledByExecutionHost(requestedSearchScope(rawScope), enabled)
}
)
ipcMain.handle('aiVault:clearSearchIndex', () => clearSessionSearchInService())
}
/**
* Only a paired runtime host can be toggled from here. The local index answers to this
* desktop's own settings write, and an SSH host has no method to carry the change.
*/
async function setSearchEnabledByExecutionHost(
scope: ParsedExecutionHost,
enabled: boolean
): Promise<AiVaultSearchStatus> {
if (scope.kind === 'local') {
throw new Error(LOCAL_ENABLE_MESSAGE)
}
if (scope.kind === 'ssh') {
throw new Error(SSH_ENABLE_MESSAGE)
}
const call = handlerOptions.callRuntimeSearch
if (!call) {
throw new Error(HOST_TOO_OLD_MESSAGE)
}
const { environmentId } = scope
try {
return AiVaultSearchStatusSchema.parse(
await call(environmentId, 'aiVault.setSearchEnabled', { enabled })
)
} catch (error) {
// An old host has no such method; every other refusal is the host's own answer.
if (isUnknownSessionSearchMethod(error)) {
throw new Error(HOST_TOO_OLD_MESSAGE)
}
throw error
}
}
/**
* Why not the list's `requestedExecutionHostScope`: it normalizes an unparseable
* id to `all`, which would answer an unroutable request by searching every host.
+18 -2
View File
@@ -26,6 +26,10 @@ import {
import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock'
import { startOrcadWithLifecycle } from './orcad-lifecycle'
import { parseArgs } from './orcad-command-arguments'
import {
changedAiVaultSearchSettings,
type AiVaultSearchSettings
} from '../../shared/ai-vault-search-settings'
export { parseArgs }
@@ -205,6 +209,10 @@ async function startOrcadRuntime(
// registerPtyHandlers so the IPC layer routes through the daemon from the first call.
await startOrcadDaemon()
// Why a holder and not a direct reference: the index is installed after the runtime is
// constructed, and the deps hook is only ever called later, from an RPC.
let sessionSearch: { apply(settings: AiVaultSearchSettings): void; dispose(): void } | null = null
const runtime = new OrcaRuntimeService(store, undefined, {
// Why lazy: a daemon swap replaces the provider after construction, so an eager
// reference would freeze the pre-daemon one.
@@ -242,11 +250,19 @@ async function startOrcadRuntime(
reconcileAgentStatusForEndedProcess: (paneKeys) =>
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys),
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {}
isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {},
// Why the dedupe here and not in the instance: `apply` closes and reconstructs
// unconditionally, so an unchanged value would restart a healthy index.
applySessionSearchSettings: (before, after) => {
const next = changedAiVaultSearchSettings(before, after)
if (next) {
sessionSearch?.apply(next)
}
}
})
const { installOrcadSessionSearchService } = await import('./orcad-session-search')
const sessionSearch = await installOrcadSessionSearchService({
sessionSearch = await installOrcadSessionSearchService({
userDataPath: runtimeUserDataPath,
getSettings: () => store.getSettings()
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { localAiVaultScanRoots } from '../ai-vault/cached-session-list'
@@ -15,7 +16,7 @@ import { installInProcessSessionSearchService } from '../ai-vault-search/session
export async function installOrcadSessionSearchService(args: {
userDataPath: string
getSettings: () => Pick<GlobalSettings, 'aiVaultSearch'>
}): Promise<{ dispose(): void } | null> {
}): Promise<{ apply(settings: AiVaultSearchSettings): void; dispose(): void } | null> {
return installInProcessSessionSearchService({
dataRoot: args.userDataPath,
roots: { executionHostId: LOCAL_EXECUTION_HOST_ID },
@@ -28,6 +28,10 @@ import {
} from './runtime-skill-command-surface'
import { getAppEnvironment } from '../../shared/app-environment'
import { RuntimeClientSettingsController } from './runtime-client-settings'
import {
RuntimeSessionSearchSettingsController,
type SessionSearchSettingsApply
} from './runtime-session-search-settings'
import { RuntimeAutomationController } from './runtime-automation-controller'
import { RuntimeOrchestrationFederation } from './runtime-orchestration-federation'
import { configureAiVaultSessionSources } from '../ai-vault/cached-session-list'
@@ -90,6 +94,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
agentSessionClaimSigner?: AgentSessionClaimSigner
skillTransactionRecovery?: Promise<unknown>
// Why a host hook and not a direct call: the process that owns this runtime's index
// differs per host (scanner child on the desktop, in-process on orcad), and on orcad
// it is installed after construction, so the closure has to resolve it at call time.
applySessionSearchSettings?: SessionSearchSettingsApply
orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport
}
) {
@@ -128,6 +136,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
})
installRuntimeServiceCommandSurface(runtime, {
aiVault: this.aiVault,
sessionSearchSettings: new RuntimeSessionSearchSettingsController(
store,
deps?.applySessionSearchSettings ?? null
),
clientEvents: this.clientEvents,
nativeChatDraftResolutions: this.nativeChatDraftResolutions,
subscriptions: this.subscriptions,
+3
View File
@@ -126,6 +126,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'stale_delivery',
'waiter_exists',
'invalid_argument',
// Why here and not only on the transport: a method that admits paired clients only refuses
// with the same code the mobile-allowlist check does, so a caller reads one answer either way.
'forbidden',
NESTED_WORKER_DEPTH_EXCEEDED_CODE,
GIT_DIFF_TOO_LARGE_CODE,
ARTIFACT_SHARING_DISABLED_CODE,
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import { OrcaRuntimeService } from '../../orca-runtime'
import { AI_VAULT_METHODS } from './ai-vault'
@@ -76,3 +76,106 @@ describe('session search runtime RPC', () => {
await expect(broken.searchSessions({ query: 'needle' })).rejects.toThrow()
})
})
describe('session search consent over the runtime RPC', () => {
const enableRequest = (params: unknown) => ({
id: 'enable-1',
authToken: 'test',
method: 'aiVault.setSearchEnabled',
params
})
function consentDispatcher(runtime = new OrcaRuntimeService()) {
const setSessionSearchEnabled = vi.fn(async () => {})
// Overrides the surface-installed method, which proves it is there to override.
Object.assign(runtime, { setSessionSearchEnabled })
const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS })
// Why the streaming entry point: `pairedDeviceId` only reaches a handler through it, and
// it is the one the WebSocket transport a paired client connects over actually calls.
const call = async (
params: unknown,
options?: { pairedDeviceId?: string; clientKind?: 'runtime' | 'mobile' }
): Promise<{ ok: boolean; result?: unknown; error?: { code: string } }> => {
let raw = ''
await dispatcher.dispatchStreaming(
enableRequest(params),
(response) => {
raw = response
},
options
)
return JSON.parse(raw)
}
return { setSessionSearchEnabled, call }
}
it('refuses an in-process caller and never touches the setting', async () => {
const { call, setSessionSearchEnabled } = consentDispatcher()
expect(await call({ enabled: true })).toMatchObject({
ok: false,
error: { code: 'forbidden' }
})
expect(setSessionSearchEnabled).not.toHaveBeenCalled()
})
it('applies a paired change and answers with this host status', async () => {
setSessionSearchService(fakeSearchService())
const { call, setSessionSearchEnabled } = consentDispatcher()
const response = await call(
{ enabled: true },
{ pairedDeviceId: 'device-7', clientKind: 'runtime' }
)
expect(setSessionSearchEnabled).toHaveBeenCalledExactlyOnceWith(true)
expect(response).toMatchObject({ ok: true, result: { enabled: true, generation: 7 } })
})
it('withholds host roots from a paired client, as searchStatus does', async () => {
setSessionSearchService({
...fakeSearchService(),
status: async () => ({
enabled: true,
phase: 'degraded' as const,
filesIndexed: 0,
filesDue: 0,
filesFailed: 1,
degradedRoots: [{ root: '/Users/someone/.claude', reason: 'unreadable' }],
lastReconcileAt: null,
lastSweepCompletedAt: null,
generation: 3
})
})
const { call } = consentDispatcher()
const response = await call(
{ enabled: false },
{ pairedDeviceId: 'device-7', clientKind: 'runtime' }
)
expect(JSON.stringify(response)).not.toContain('/Users/someone/.claude')
})
it('reports the host refusal when this runtime has no settings store', async () => {
const runtime = new OrcaRuntimeService()
const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS })
let raw = ''
await dispatcher.dispatchStreaming(
enableRequest({ enabled: true }),
(response) => {
raw = response
},
{ pairedDeviceId: 'device-7', clientKind: 'runtime' }
)
expect(JSON.parse(raw)).toMatchObject({
ok: false,
error: { code: 'runtime_unavailable' }
})
})
it('rejects a non-boolean before reaching the runtime', async () => {
const { call, setSessionSearchEnabled } = consentDispatcher()
expect(await call({ enabled: 'yes' }, { pairedDeviceId: 'device-7' })).toMatchObject({
ok: false
})
expect(setSessionSearchEnabled).not.toHaveBeenCalled()
})
})
+21 -1
View File
@@ -1,6 +1,7 @@
import {
AiVaultSearchRequestSchema,
AiVaultSearchStatusRequestSchema
AiVaultSearchStatusRequestSchema,
AiVaultSetSearchEnabledParamsSchema
} from '../../../../shared/ai-vault-search-contract'
import {
searchSessionService,
@@ -36,6 +37,25 @@ export const AI_VAULT_METHODS = [
handler: (params, { clientKind }) =>
sessionSearchServiceStatus(params, clientKind ? 'relay' : 'runtime')
}),
defineMethod({
name: 'aiVault.setSearchEnabled',
params: AiVaultSetSearchEnabledParamsSchema,
handler: async (params, { runtime, clientKind, pairedDeviceId }) => {
// Paired clients only: an in-process caller writes this host's own settings directly,
// and admitting one here would let any unauthenticated local path flip consent.
if (!pairedDeviceId) {
throw Object.assign(
new Error('Session search consent can only be changed by a paired client.'),
{ code: 'forbidden' }
)
}
await runtime.setSessionSearchEnabled(params.enabled)
console.warn(
`[ai-vault-search] device ${pairedDeviceId} set indexing enabled=${params.enabled}`
)
return sessionSearchServiceStatus({}, clientKind ? 'relay' : 'runtime')
}
}),
defineMethod({
name: 'aiVault.resolveSessionTitles',
params: AiVaultSessionTitlesParams,
@@ -7,12 +7,14 @@ import type { RuntimeMobileDictationController } from './runtime-mobile-dictatio
import type { RuntimeMobileNotificationController } from './runtime-mobile-notification-controller'
import type { RuntimeMobileSpeechCatalog } from './runtime-mobile-speech-catalog'
import type { RuntimeNativeChatDraftResolutions } from './runtime-native-chat-draft-resolutions'
import type { RuntimeSessionSearchSettingsController } from './runtime-session-search-settings'
import type { RuntimeSubscriptionRegistry } from './runtime-subscription-registry'
export type RuntimeServiceCommandSurface = {
listAiVaultSessions: RuntimeAiVaultCommands['list']
resolveAiVaultSessionTitles: RuntimeAiVaultCommands['resolveTitles']
prepareAiVaultSessionResume: RuntimeAiVaultCommands['prepare']
setSessionSearchEnabled: RuntimeSessionSearchSettingsController['setEnabled']
onClientEvent: RuntimeClientEventBus['on']
notifyNativeChatLaunchDraftResolved: RuntimeNativeChatDraftResolutions['notify']
registerSubscriptionCleanup: RuntimeSubscriptionRegistry['register']
@@ -69,6 +71,7 @@ export type RuntimeServiceCommandSurface = {
type RuntimeServiceCommandOwners = {
aiVault: RuntimeAiVaultCommands
sessionSearchSettings: RuntimeSessionSearchSettingsController
clientEvents: RuntimeClientEventBus
nativeChatDraftResolutions: RuntimeNativeChatDraftResolutions
subscriptions: RuntimeSubscriptionRegistry
@@ -85,6 +88,7 @@ export function installRuntimeServiceCommandSurface(
owners: RuntimeServiceCommandOwners
): void {
const vault = owners.aiVault
const sessionSearchSettings = owners.sessionSearchSettings
const events = owners.clientEvents
const drafts = owners.nativeChatDraftResolutions
const subscriptions = owners.subscriptions
@@ -98,6 +102,7 @@ export function installRuntimeServiceCommandSurface(
listAiVaultSessions: vault.list.bind(vault),
resolveAiVaultSessionTitles: vault.resolveTitles.bind(vault),
prepareAiVaultSessionResume: vault.prepare.bind(vault),
setSessionSearchEnabled: sessionSearchSettings.setEnabled.bind(sessionSearchSettings),
onClientEvent: events.on.bind(events),
notifyNativeChatLaunchDraftResolved: drafts.notify.bind(drafts),
registerSubscriptionCleanup: subscriptions.register.bind(subscriptions),
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import {
RuntimeSessionSearchSettingsController,
type SessionSearchSettingsStore
} from './runtime-session-search-settings'
import type { GlobalSettings } from '../../shared/global-settings-types'
type Settings = ReturnType<SessionSearchSettingsStore['getSettings']>
function storeWith(aiVaultSearch: GlobalSettings['aiVaultSearch'] | undefined) {
let settings: Settings = {
workspaceDir: '/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
...(aiVaultSearch ? { aiVaultSearch } : {})
}
const updateSettings = vi.fn((updates: Partial<GlobalSettings>) => {
settings = { ...settings, ...updates }
})
const store: SessionSearchSettingsStore = {
getSettings: () => settings,
updateSettings
}
return { store, updateSettings, read: () => settings }
}
describe('runtime session search consent', () => {
it('writes the whole policy and hands the host before/after exactly once', async () => {
const { store, updateSettings, read } = storeWith({ enabled: false, historyDays: 30 })
const apply = vi.fn()
await new RuntimeSessionSearchSettingsController(store, apply).setEnabled(true)
expect(updateSettings).toHaveBeenCalledExactlyOnceWith(
{ aiVaultSearch: { enabled: true, historyDays: 30 } },
{ notifyListeners: true }
)
// Retention must ride along untouched; a partial write would reset it to "all history".
expect(read().aiVaultSearch).toEqual({ enabled: true, historyDays: 30 })
expect(apply).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ aiVaultSearch: { enabled: false, historyDays: 30 } }),
expect.objectContaining({ aiVaultSearch: { enabled: true, historyDays: 30 } })
)
})
it('normalizes an absent or malformed stored policy instead of writing it back', async () => {
const { store, updateSettings } = storeWith(undefined)
await new RuntimeSessionSearchSettingsController(store, null).setEnabled(true)
expect(updateSettings).toHaveBeenCalledExactlyOnceWith(
{ aiVaultSearch: { enabled: true, historyDays: null } },
{ notifyListeners: true }
)
})
it('hands the host an unchanged pair when the value did not move', async () => {
const { store, updateSettings } = storeWith({ enabled: true, historyDays: null })
const apply = vi.fn()
await new RuntimeSessionSearchSettingsController(store, apply).setEnabled(true)
// The write still happens; the host hook is what refuses to restart a live index.
expect(updateSettings).toHaveBeenCalledOnce()
const [before, after] = apply.mock.calls[0] ?? []
expect(before?.aiVaultSearch).toEqual(after?.aiVaultSearch)
})
it('refuses on a host with no settings store rather than reporting success', async () => {
await expect(
new RuntimeSessionSearchSettingsController(null, vi.fn()).setEnabled(true)
).rejects.toThrow('runtime_unavailable')
})
})
@@ -0,0 +1,36 @@
import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { RuntimeStore } from './runtime-store-contract'
/**
* How this host reaches the index it owns after the store write lands: the scanner
* child on the desktop, the in-process instance on orcad. Null on a host that owns
* none, where the write is still recorded and nothing is reconstructed.
*/
export type SessionSearchSettingsApply = (
before: Pick<GlobalSettings, 'aiVaultSearch'>,
after: Pick<GlobalSettings, 'aiVaultSearch'>
) => void
/** Only the two store members this write needs, so a caller need not own the whole runtime store. */
export type SessionSearchSettingsStore = Pick<RuntimeStore, 'getSettings' | 'updateSettings'>
/** Consent for this host's transcript index, written by a paired client rather than the local UI. */
export class RuntimeSessionSearchSettingsController {
constructor(
private readonly store: SessionSearchSettingsStore | null,
private readonly apply: SessionSearchSettingsApply | null
) {}
async setEnabled(enabled: boolean): Promise<void> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const before = this.store.getSettings()
this.store.updateSettings(
{ aiVaultSearch: { ...resolveAiVaultSearchSettings(before), enabled } },
{ notifyListeners: true }
)
this.apply?.(before, this.store.getSettings())
}
}
@@ -120,6 +120,7 @@ export type RuntimeStore = {
hostSettingOverrides?: GlobalSettings['hostSettingOverrides']
agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled']
nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions']
aiVaultSearch?: GlobalSettings['aiVaultSearch']
}
// Why: narrow to `unknown` return so test mocks can return void without
// a cast. The runtime never reads the return value — the persisted value
@@ -1,4 +1,7 @@
import { installChildSessionSearchService } from '../ai-vault-search/session-search-enablement'
import {
applySessionSearchSettingsChange,
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'
@@ -131,6 +134,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(state.store?.getSettings()) ? agentHookServer.buildPtyEnv() : {},
orchestrationEnvironmentTransport,
// Why the same function the settings IPC handler calls: a paired client's write and a
// local one must reconcile the scanner child through one path, or they can disagree.
applySessionSearchSettings: applySessionSearchSettingsChange,
skillTransactionRecovery: state.skillTransactionRecovery
})
// Both desktop and headless serve own a host-local search service.
+9
View File
@@ -33,6 +33,15 @@ export type AiVaultApi = {
) => Promise<AiVaultSearchResponse>
/** Status describes one index, so it never accepts the `all` scope. */
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
/**
* Turns indexing on or off on a paired Orca server and answers its status after the change.
* Runtime hosts only: the local index follows this desktop's own settings write, SSH hosts
* reject with `unsupported`, and a server predating the method rejects with `host-too-old`.
*/
setSearchEnabled: (
executionHostId: ExecutionHostId,
enabled: boolean
) => Promise<AiVaultSearchStatus>
/** Deletes and rebuilds this desktop's local search index. */
clearSearchIndex: () => Promise<void>
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
+6 -1
View File
@@ -1,5 +1,5 @@
import { createSessionSearchClient } from '../../shared/ai-vault-search-client'
import type { AiVaultSearchRequest } from '../../shared/ai-vault-search-types'
import type { AiVaultSearchRequest, AiVaultSearchStatus } from '../../shared/ai-vault-search-types'
import {
ALL_EXECUTION_HOSTS_SCOPE,
LOCAL_EXECUTION_HOST_ID,
@@ -42,6 +42,11 @@ export const aiVaultApi = {
searchClient(executionHostScope).searchSessions(request),
searchStatus: (executionHostScope?: ExecutionHostId) =>
searchClient(executionHostScope).searchStatus(),
setSearchEnabled: (
executionHostId: ExecutionHostId,
enabled: boolean
): Promise<AiVaultSearchStatus> =>
ipcRenderer.invoke('aiVault:setSearchEnabled', executionHostId, enabled),
clearSearchIndex: (): Promise<void> => ipcRenderer.invoke('aiVault:clearSearchIndex'),
listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args),
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) =>
@@ -39,6 +39,9 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault'
addressesOwnRuntime(executionHostScope)
? search.searchStatus()
: Promise.resolve(unavailableSessionSearchStatus()),
// Why refused and not forwarded: consent for a host's index is an operator action,
// and the browser client has no desktop settings surface to reconcile it against.
setSearchEnabled: () => Promise.reject(new Error('unsupported')),
clearSearchIndex: () =>
Promise.reject(new Error('Clearing Agent Session History is unavailable in the browser.')),
listSessions: (args?: AiVaultListArgs) => {
+3 -3
View File
@@ -29,7 +29,7 @@ export function unavailableSessionSearchStatus(): AiVaultSearchStatus {
}
// Only an explicit unknown-method refusal proves the old host lacks this surface.
function isUnknownMethod(error: unknown): boolean {
export function isUnknownSessionSearchMethod(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('code' in error)) {
return false
}
@@ -50,7 +50,7 @@ export function createSessionSearchClient(
try {
raw = await call('aiVault.searchSessions', parsed)
} catch (error) {
if (isUnknownMethod(error)) {
if (isUnknownSessionSearchMethod(error)) {
return { kind: 'unavailable', reason: 'no-service' }
}
throw error
@@ -73,7 +73,7 @@ export function createSessionSearchClient(
transport
)
} catch (error) {
if (isUnknownMethod(error)) {
if (isUnknownSessionSearchMethod(error)) {
return unavailableSessionSearchStatus()
}
throw error
+2
View File
@@ -103,6 +103,8 @@ export const AiVaultSearchResponseSchema = z.discriminatedUnion('kind', [
})
])
export const AiVaultSearchStatusRequestSchema = z.object({})
/** Consent flip for one host's index. Answered with that host's status after the change is applied. */
export const AiVaultSetSearchEnabledParamsSchema = z.object({ enabled: z.boolean() })
export const AiVaultSearchStatusSchema = z.object({
enabled: z.boolean(),
phase: z.enum(['idle', 'indexing', 'current', 'degraded', 'closed']),
+14
View File
@@ -63,3 +63,17 @@ export function sameAiVaultSearchSettings(
): boolean {
return a.enabled === b.enabled && a.historyDays === b.historyDays
}
/**
* The policy a settings write moves to, or null when nothing about it changed.
*
* Every host that owns an index closes and reconstructs on apply, so an unchanged
* value has to be filtered here rather than at the indexer.
*/
export function changedAiVaultSearchSettings(
before: { aiVaultSearch?: unknown } | null | undefined,
after: { aiVaultSearch?: unknown } | null | undefined
): AiVaultSearchSettings | null {
const next = resolveAiVaultSearchSettings(after)
return sameAiVaultSearchSettings(resolveAiVaultSearchSettings(before), next) ? null : next
}
+4 -1
View File
@@ -4,7 +4,8 @@ import type {
AiVaultSearchResponseSchema,
AiVaultSearchHitSchema,
AiVaultSearchHostOutcomeSchema,
AiVaultSearchStatusSchema
AiVaultSearchStatusSchema,
AiVaultSetSearchEnabledParamsSchema
} from './ai-vault-search-contract'
/**
@@ -23,3 +24,5 @@ export type AiVaultSearchHit = z.infer<typeof AiVaultSearchHitSchema>
export type AiVaultSearchStatus = z.infer<typeof AiVaultSearchStatusSchema>
/** Only an all-computers merge reports these; a single-host answer omits them. */
export type AiVaultSearchHostOutcome = z.infer<typeof AiVaultSearchHostOutcomeSchema>
/** Turning indexing on or off for one host; the response is that host's `AiVaultSearchStatus`. */
export type AiVaultSetSearchEnabledParams = z.infer<typeof AiVaultSetSearchEnabledParamsSchema>
+3 -1
View File
@@ -4,7 +4,8 @@ import type { z } from 'zod'
import { AgentSkillShareRequestSchema } from '../agent-skill-sharing-contract'
import {
AiVaultSearchRequestSchema,
AiVaultSearchStatusRequestSchema
AiVaultSearchStatusRequestSchema,
AiVaultSetSearchEnabledParamsSchema
} from '../ai-vault-search-contract'
import {
BrowserClientFileChannelAbortParams,
@@ -584,6 +585,7 @@ export const RPC_PARAMS_BY_METHOD = {
'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams,
'aiVault.searchSessions': AiVaultSearchRequestSchema,
'aiVault.searchStatus': AiVaultSearchStatusRequestSchema,
'aiVault.setSearchEnabled': AiVaultSetSearchEnabledParamsSchema,
'artifacts.delete': ArtifactsDeleteParams,
'artifacts.getPublishedLink': SourceRequest,
'artifacts.list': ListOptions,