From a01027697cd7cde21e645ee7c964ca2a1dbcf3e8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:50:59 -0400 Subject: [PATCH] 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 c6b9179316b356e183a3ec8e61cf01144f472d99) * 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 640c715fbd123e867518a2baf9ff58fe9efdbe8b) * fix(session-search): report a paired server without session search as host-too-old on status reads (cherry picked from commit 1463e8a4bcd136ef94b46bcff05d65d4237bcc2a) * fix(settings): let Button and Collapsible own their spacing and type --- .../session-search-enablement.ts | 12 +- .../session-search-host-registration.test.ts | 37 +++++- .../session-search-in-process-service.ts | 5 +- src/main/ipc/ai-vault-search.test.ts | 46 ++++++++ src/main/ipc/ai-vault-search.ts | 82 +++++++++++++- src/main/orcad/orcad-entry.ts | 20 +++- src/main/orcad/orcad-session-search.ts | 3 +- src/main/runtime/orca-runtime-state-fields.ts | 12 ++ src/main/runtime/rpc/errors.ts | 3 + .../rpc/methods/ai-vault-search.test.ts | 105 +++++++++++++++++- src/main/runtime/rpc/methods/ai-vault.ts | 22 +++- .../runtime-service-command-surface.ts | 5 + .../runtime-session-search-settings.test.ts | 73 ++++++++++++ .../runtime-session-search-settings.ts | 36 ++++++ src/main/runtime/runtime-store-contract.ts | 1 + .../startup/main-process-runtime-service.ts | 8 +- src/preload/api/ai-vault-api.ts | 9 ++ src/preload/api/ai-vault-bridge.ts | 7 +- .../src/web/preload-api/web-ai-vault-api.ts | 3 + src/shared/ai-vault-search-client.ts | 6 +- src/shared/ai-vault-search-contract.ts | 2 + src/shared/ai-vault-search-settings.ts | 14 +++ src/shared/ai-vault-search-types.ts | 5 +- .../rpc-params-catalog.generated.ts | 4 +- 24 files changed, 493 insertions(+), 27 deletions(-) create mode 100644 src/main/runtime/runtime-session-search-settings.test.ts create mode 100644 src/main/runtime/runtime-session-search-settings.ts diff --git a/src/main/ai-vault-search/session-search-enablement.ts b/src/main/ai-vault-search/session-search-enablement.ts index e292e4a8551..0912ec9d498 100644 --- a/src/main/ai-vault-search/session-search-enablement.ts +++ b/src/main/ai-vault-search/session-search-enablement.ts @@ -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, after: Pick ): void { - if ( - sameAiVaultSearchSettings( - resolveAiVaultSearchSettings(before), - resolveAiVaultSearchSettings(after) - ) - ) { + if (!changedAiVaultSearchSettings(before, after)) { return } if (installed) { 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 0e42ac68cde..e2a31ec4f21 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 @@ -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)') +}) diff --git a/src/main/ai-vault-search/session-search-in-process-service.ts b/src/main/ai-vault-search/session-search-in-process-service.ts index 536ad742a98..186f8f74a0b 100644 --- a/src/main/ai-vault-search/session-search-in-process-service.ts +++ b/src/main/ai-vault-search/session-search-in-process-service.ts @@ -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() diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index d1de6ea2b5c..be4028460a0 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -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') + }) }) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 44b32db50fb..75ce9fd8725 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -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 { + 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 { +async function statusByExecutionHost(scope: ParsedExecutionHost): Promise { 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 { + 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. diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index c736f4f4b4c..fb22f92c392 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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() }) diff --git a/src/main/orcad/orcad-session-search.ts b/src/main/orcad/orcad-session-search.ts index 7f5d67010a8..32d32272066 100644 --- a/src/main/orcad/orcad-session-search.ts +++ b/src/main/orcad/orcad-session-search.ts @@ -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 -}): Promise<{ dispose(): void } | null> { +}): Promise<{ apply(settings: AiVaultSearchSettings): void; dispose(): void } | null> { return installInProcessSessionSearchService({ dataRoot: args.userDataPath, roots: { executionHostId: LOCAL_EXECUTION_HOST_ID }, diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 876de03fe76..781f6075be1 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -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 + // 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, diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 5081236f09a..df246112b32 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -130,6 +130,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = 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, diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index d3a149e38ad..13c80b544b6 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 5681b3a6c02..b6a2165939a 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -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, diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 4290fadecf1..55a1d020a7d 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -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), diff --git a/src/main/runtime/runtime-session-search-settings.test.ts b/src/main/runtime/runtime-session-search-settings.test.ts new file mode 100644 index 00000000000..3dda68949ab --- /dev/null +++ b/src/main/runtime/runtime-session-search-settings.test.ts @@ -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 + +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) => { + 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') + }) +}) diff --git a/src/main/runtime/runtime-session-search-settings.ts b/src/main/runtime/runtime-session-search-settings.ts new file mode 100644 index 00000000000..445c9593450 --- /dev/null +++ b/src/main/runtime/runtime-session-search-settings.ts @@ -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, + after: Pick +) => void + +/** Only the two store members this write needs, so a caller need not own the whole runtime store. */ +export type SessionSearchSettingsStore = Pick + +/** 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 { + 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()) + } +} diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index b1471ff8efe..fa756d0f033 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -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 diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 6d7ba1fe23a..62716bbcdec 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -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. diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index cf82217ec17..34b6357c814 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -33,6 +33,15 @@ export type AiVaultApi = { ) => Promise /** Status describes one index, so it never accepts the `all` scope. */ searchStatus: (executionHostScope?: ExecutionHostId) => Promise + /** + * 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 /** Deletes and rebuilds this desktop's local search index. */ clearSearchIndex: () => Promise listSessions: (args?: AiVaultListArgs) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index 68feb382223..9089c82220c 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -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 => + ipcRenderer.invoke('aiVault:setSearchEnabled', executionHostId, enabled), clearSearchIndex: (): Promise => ipcRenderer.invoke('aiVault:clearSearchIndex'), listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index d87e88d63bc..5f257c5c12a 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -39,6 +39,9 @@ export function createWebAiVaultApi(): NonNullable['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) => { diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index ac89f694886..dd68bbd6c67 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -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 diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts index 98817ac27f9..3bcb0d44856 100644 --- a/src/shared/ai-vault-search-contract.ts +++ b/src/shared/ai-vault-search-contract.ts @@ -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']), diff --git a/src/shared/ai-vault-search-settings.ts b/src/shared/ai-vault-search-settings.ts index 8b6b8e1b07c..98b1b9ed2ce 100644 --- a/src/shared/ai-vault-search-settings.ts +++ b/src/shared/ai-vault-search-settings.ts @@ -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 +} diff --git a/src/shared/ai-vault-search-types.ts b/src/shared/ai-vault-search-types.ts index fc400ffebdc..41ecd4f67c5 100644 --- a/src/shared/ai-vault-search-types.ts +++ b/src/shared/ai-vault-search-types.ts @@ -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 export type AiVaultSearchStatus = z.infer /** Only an all-computers merge reports these; a single-host answer omits them. */ export type AiVaultSearchHostOutcome = z.infer +/** Turning indexing on or off for one host; the response is that host's `AiVaultSearchStatus`. */ +export type AiVaultSetSearchEnabledParams = z.infer diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 7824726bccd..55cdeaa5826 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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,