mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user