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

* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* 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.

(cherry picked from commit 640c715fbd)

* fix(session-search): report a paired server without session search as host-too-old on status reads

(cherry picked from commit 1463e8a4bc)

* fix(settings): let Button and Collapsible own their spacing and type
This commit is contained in:
Jinwoo Hong
2026-09-16 12:50:59 -04:00
committed by GitHub
parent 8153ec2306
commit a01027697c
24 changed files with 493 additions and 27 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()
+46
View File
@@ -124,6 +124,8 @@ describe('desktop IPC and preload search boundary', () => {
kind: 'unavailable',
reason: 'no-service'
})
// Status is the one read where "no such method" must not collapse into "off".
await expect(aiVaultApi.searchStatus('runtime:env-1')).rejects.toThrow('host-too-old')
runtimeSearch.mockRejectedValue(
Object.assign(new Error('runtime disconnected'), { code: 'connection_lost' })
)
@@ -187,4 +189,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')
})
})
+79 -3
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,
@@ -21,6 +26,7 @@ import {
toSshExecutionHostId,
type ParsedExecutionHost
} from '../../shared/execution-host'
import { redactStatusForTransport } from '../../shared/ai-vault-search-transport'
import { requestActiveSshSessionSearch } from './ssh'
import { clearSessionSearchInService } from '../ai-vault/session-scanner-service-spawn'
import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts'
@@ -42,6 +48,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 +73,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.
@@ -134,12 +185,37 @@ function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg {
}
}
function statusByExecutionHost(scope: ParsedExecutionHost): Promise<AiVaultSearchStatus> {
async function statusByExecutionHost(scope: ParsedExecutionHost): Promise<AiVaultSearchStatus> {
if (scope.kind === 'local') {
return sessionSearchServiceStatus({}, 'ipc')
}
if (scope.kind === 'runtime') {
return runtimeHostStatus(scope.environmentId)
}
const client = remoteSearchClient(scope, handlerOptions.callRuntimeSearch)
return client ? client.searchStatus() : Promise.resolve(unavailableSessionSearchStatus())
return client ? client.searchStatus() : unavailableSessionSearchStatus()
}
/**
* Not through the shared client: it answers an unknown method with `unavailable`, which
* the settings pane cannot tell from a current server that is switched off.
*/
async function runtimeHostStatus(environmentId: string): Promise<AiVaultSearchStatus> {
const call = handlerOptions.callRuntimeSearch
if (!call) {
return unavailableSessionSearchStatus()
}
try {
return redactStatusForTransport(
AiVaultSearchStatusSchema.parse(await call(environmentId, 'aiVault.searchStatus', {})),
'relay'
)
} catch (error) {
if (isUnknownSessionSearchMethod(error)) {
throw new Error(HOST_TOO_OLD_MESSAGE)
}
throw error
}
}
// Null for the local host and for a runtime environment with no injected transport.
+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
@@ -130,6 +130,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
// Why (#19334): "your archive hook failed, nothing was deleted" is a distinct decision — retry,
// waive, or skip the hook. Flattened to runtime_error a caller can only pattern-match the text.
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
// 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,