mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(session-history): add local search settings and index controls (#20582)
* feat(session-history): add local search settings and index controls * Use shared local host identifier for session index status * feat(settings): live index status, enable confirm, advanced delete * fix(settings): let Button and Collapsible own their spacing and type
This commit is contained in:
@@ -29,10 +29,12 @@ Results contain `kind: 'results'`, `hits`, `page: { cursor, hasMore }`,
|
||||
contains `route`, optional `repairedTerms`, and `scope`. Diagnostics never appear
|
||||
at the top level. Status is never attached to search results.
|
||||
|
||||
A cursor belongs to one query and one host's index generation. Query, scope,
|
||||
filters, and sorting must remain the same; page size may change. Writes that
|
||||
advance the generation can invalidate it, including retention purges. A refused
|
||||
cursor yields `{ kind: 'stale-cursor', generation, expectedGeneration? }` and the
|
||||
A cursor belongs to one query, one host's index generation, and an opaque persisted
|
||||
index incarnation. Query, scope, filters, and sorting must remain the same; page
|
||||
size may change. Writes that advance the generation can invalidate it, including
|
||||
retention purges. Clearing or rebuilding the database invalidates it even when the
|
||||
new generation counter matches. A refused cursor yields
|
||||
`{ kind: 'stale-cursor', generation, expectedGeneration? }` and the
|
||||
client discards it and issues page 1 without a cursor. Reusing that refused cursor
|
||||
continues to fail; there is no server-side cursor acknowledgement state.
|
||||
Malformed cursors and cursors for a different query yield
|
||||
@@ -131,7 +133,33 @@ uses the absent-service sentinel above. Transport failures, authentication error
|
||||
and invalid payloads remain errors. Unknown request fields are stripped for wire
|
||||
compatibility.
|
||||
|
||||
The process-local `setSessionSearchService(service | null)` registry is the only
|
||||
production seam in this PR. Tests use fake services and a real synthetic store.
|
||||
Nothing constructs an engine or indexer in production. PR 3b owns process
|
||||
lifecycle, consent/settings application, and registration of the production service.
|
||||
The process-local `setSessionSearchService(service | null)` registry connects
|
||||
these endpoints to the production service installed by PR 3b. Desktop indexing
|
||||
runs in the scanner child; orcad and the SSH relay register their own in-process
|
||||
services. Registration alone does not grant consent.
|
||||
|
||||
## Desktop index controls (PR8)
|
||||
|
||||
Settings → Agent Session History controls this desktop's persisted
|
||||
`aiVaultSearch { enabled, historyDays }` policy. It stays local even when another
|
||||
execution host is selected. Paired clients cannot grant consent or clear an index
|
||||
through this surface; SSH relay registration remains disabled without a separate
|
||||
host consent mechanism.
|
||||
|
||||
`aiVault.clearSearchIndex()` is a no-argument desktop-only preload operation over
|
||||
`aiVault:clearSearchIndex`. It addresses the local scanner child, not the selected
|
||||
remote host. The child's existing interactive request lane executes `searchClear`
|
||||
through `SessionSearchInstance.clear()`: close the indexer and database handles,
|
||||
remove the SQLite database and sidecars, then reconstruct only if consent remains
|
||||
enabled. Errors propagate to the settings pane. This operation never deletes
|
||||
original transcripts. There is no new runtime or relay method. Opaque cursors also
|
||||
carry a persistent database identity: clearing creates a new identity, so a
|
||||
pre-clear or legacy cursor returns `stale-cursor` even if the rebuilt numeric
|
||||
generation happens to match. Reopening the same database preserves its identity.
|
||||
|
||||
Disabling closes the indexer and keeps the index copy; clearing deletes the copy.
|
||||
Changing retention reuses the existing close-and-construct policy application.
|
||||
The settings pane reads status only while enabled and visible, polls only an
|
||||
observed indexing phase, and stops on completion or error. Opening the pane,
|
||||
changing policy, or pressing Refresh obtains a new observation. The indexer's own
|
||||
schedule does not depend on the pane.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type SessionSearchScope,
|
||||
type SessionSearchSourcePresence
|
||||
} from './session-search-engine-types'
|
||||
import { readIndexGeneration } from './session-search-index-generation'
|
||||
import { readIndexGeneration, readIndexIncarnation } from './session-search-index-generation'
|
||||
import {
|
||||
rankSessionHits,
|
||||
type MessageRow,
|
||||
@@ -97,6 +97,7 @@ export class SessionSearchEngine {
|
||||
const startedAt = performance.now()
|
||||
ensureSessionSearchQuerySchema(this.db)
|
||||
const generation = readIndexGeneration(this.db)
|
||||
const incarnation = readIndexIncarnation(this.db)
|
||||
const scope = request.scope ?? 'all'
|
||||
const sort = request.filters?.sort ?? 'relevance'
|
||||
// Not a bare `slice`: cutting between a surrogate pair leaves a lone half
|
||||
@@ -114,7 +115,7 @@ export class SessionSearchEngine {
|
||||
// cost a query, and the caller has to hear about it either way.
|
||||
const pageKey = sessionSearchPageKey(request)
|
||||
const offset = request.cursor
|
||||
? decodeSessionSearchCursor(request.cursor, generation, pageKey)
|
||||
? decodeSessionSearchCursor(request.cursor, generation, pageKey, incarnation)
|
||||
: 0
|
||||
|
||||
const plan = planSessionSearchQuery(split.text)
|
||||
@@ -140,7 +141,9 @@ export class SessionSearchEngine {
|
||||
},
|
||||
page: {
|
||||
hasMore,
|
||||
cursor: hasMore ? encodeSessionSearchCursor(generation, offset + limit, pageKey) : null
|
||||
cursor: hasMore
|
||||
? encodeSessionSearchCursor(generation, offset + limit, pageKey, incarnation)
|
||||
: null
|
||||
},
|
||||
truncated: {
|
||||
// Decided by retrieval, which is the only layer that knows whether a cap
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, expect, it } from 'vitest'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { SessionSearchEngine } from './session-search-engine'
|
||||
import { readIndexGeneration } from './session-search-index-generation'
|
||||
import { readIndexGeneration, readIndexIncarnation } from './session-search-index-generation'
|
||||
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
|
||||
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
|
||||
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
|
||||
@@ -225,12 +225,15 @@ it('keeps the generation across a reopen, because the bump rides its own commit'
|
||||
throw error
|
||||
})
|
||||
await indexOneTranscript(root, first)
|
||||
const indexed = readIndexGeneration(reader(path))
|
||||
const firstReader = reader(path)
|
||||
const indexed = readIndexGeneration(firstReader)
|
||||
const incarnation = readIndexIncarnation(firstReader)
|
||||
first.close()
|
||||
|
||||
const second = new SessionSearchStore(path)
|
||||
try {
|
||||
expect(readIndexGeneration(reader(path))).toBe(indexed)
|
||||
expect(readIndexIncarnation(reader(path))).toBe(incarnation)
|
||||
} finally {
|
||||
second.close()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
const GENERATION_KEY = 'index_generation'
|
||||
const INCARNATION_KEY = 'index_incarnation'
|
||||
|
||||
export const SESSION_SEARCH_GENERATION_TRIGGERS = [
|
||||
'search_generation_file_insert',
|
||||
@@ -40,3 +41,11 @@ export function readIndexGeneration(db: SyncDatabase): number {
|
||||
const parsed = row ? Number(row.value) : Number.NaN
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0
|
||||
}
|
||||
|
||||
export function readIndexIncarnation(db: SyncDatabase): string {
|
||||
const row: unknown = db.prepare('SELECT value FROM meta WHERE key = ?').get(INCARNATION_KEY)
|
||||
if (!row || typeof row !== 'object' || !('value' in row) || typeof row.value !== 'string') {
|
||||
throw new Error('Session search index has no incarnation.')
|
||||
}
|
||||
return row.value
|
||||
}
|
||||
|
||||
@@ -167,6 +167,27 @@ it('removes the database on clear and rebuilds only while consent stands', async
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a page cursor minted before clear even when the rebuilt generation matches', async () => {
|
||||
for (const id of [RECENT_SESSION_ID, ANCIENT_SESSION_ID]) {
|
||||
await writeClaudeTranscript(transcriptPath(id), [`shared clear fence ${id}`], id)
|
||||
}
|
||||
const subject = newInstance()
|
||||
subject.apply({ enabled: true, historyDays: null })
|
||||
await subject.settled()
|
||||
const first = await subject.search({ query: 'shared clear fence', limit: 1 })
|
||||
if (first.kind !== 'results' || !first.page.cursor) {
|
||||
throw new Error('expected a paged result')
|
||||
}
|
||||
|
||||
subject.clear()
|
||||
await subject.settled()
|
||||
expect(subject.status().generation).toBe(first.generation)
|
||||
expect(
|
||||
await subject.search({ query: 'shared clear fence', limit: 1, cursor: first.page.cursor })
|
||||
).toMatchObject({ kind: 'stale-cursor', generation: first.generation })
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps pagination stable when the clock crosses retention before a purge', async () => {
|
||||
for (const id of [RECENT_SESSION_ID, ANCIENT_SESSION_ID]) {
|
||||
await writeClaudeTranscript(transcriptPath(id), [`distinctive conversation ${id}`], id)
|
||||
|
||||
@@ -18,6 +18,8 @@ export class SessionSearchCursorError extends Error {
|
||||
}
|
||||
|
||||
type CursorPayload = {
|
||||
/** Database incarnation; changes when the index is rebuilt. */
|
||||
i: string
|
||||
/** Index generation. */
|
||||
g: number
|
||||
/**
|
||||
@@ -50,8 +52,13 @@ export function sessionSearchPageKey(request: SessionSearchRequest): string {
|
||||
return createHash('sha256').update(identity).digest('base64url').slice(0, 16)
|
||||
}
|
||||
|
||||
export function encodeSessionSearchCursor(generation: number, offset: number, key: string): string {
|
||||
const payload: CursorPayload = { g: generation, o: offset, k: key }
|
||||
export function encodeSessionSearchCursor(
|
||||
generation: number,
|
||||
offset: number,
|
||||
key: string,
|
||||
incarnation: string
|
||||
): string {
|
||||
const payload: CursorPayload = { i: incarnation, g: generation, o: offset, k: key }
|
||||
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url')
|
||||
}
|
||||
|
||||
@@ -63,7 +70,12 @@ export function encodeSessionSearchCursor(generation: number, offset: number, ke
|
||||
* can tell "the index moved under you, ask for page one" from "this cursor is
|
||||
* not ours" and act on the first without showing anyone an error.
|
||||
*/
|
||||
export function decodeSessionSearchCursor(cursor: string, generation: number, key: string): number {
|
||||
export function decodeSessionSearchCursor(
|
||||
cursor: string,
|
||||
generation: number,
|
||||
key: string,
|
||||
incarnation: string
|
||||
): number {
|
||||
let payload: CursorPayload
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')) as CursorPayload
|
||||
@@ -92,6 +104,10 @@ export function decodeSessionSearchCursor(cursor: string, generation: number, ke
|
||||
if (claimed !== generation) {
|
||||
throw new SessionSearchCursorError('stale-generation', generation, claimed)
|
||||
}
|
||||
// Cursors minted before incarnation fencing are stale across a possible rebuild.
|
||||
if (payload.i !== incarnation) {
|
||||
throw new SessionSearchCursorError('stale-generation', generation, claimed)
|
||||
}
|
||||
if (payload.k !== key) {
|
||||
throw new SessionSearchCursorError('different-query', generation, claimed)
|
||||
}
|
||||
|
||||
@@ -203,10 +203,18 @@ describe('a cursor is refused rather than reinterpreted', () => {
|
||||
|
||||
describe('cursor encoding', () => {
|
||||
const request: SessionSearchRequest = { query: 'needle', filters: { scopePaths: ['/a'] } }
|
||||
const incarnation = 'index-a'
|
||||
|
||||
it('round-trips an offset within its own generation and query', () => {
|
||||
const key = sessionSearchPageKey(request)
|
||||
expect(decodeSessionSearchCursor(encodeSessionSearchCursor(7, 40, key), 7, key)).toBe(40)
|
||||
expect(
|
||||
decodeSessionSearchCursor(
|
||||
encodeSessionSearchCursor(7, 40, key, incarnation),
|
||||
7,
|
||||
key,
|
||||
incarnation
|
||||
)
|
||||
).toBe(40)
|
||||
})
|
||||
|
||||
it('keys a request by what changes its ranking, and not by its page size', () => {
|
||||
@@ -225,7 +233,7 @@ describe('cursor encoding', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a negative offset', encodeSessionSearchCursor(1, -1, 'k'), 1],
|
||||
['a negative offset', encodeSessionSearchCursor(1, -1, 'k', incarnation), 1],
|
||||
['a non-integer offset', Buffer.from('{"g":1,"o":1.5,"k":"k"}').toString('base64url'), 1],
|
||||
['a payload that is not an object', Buffer.from('"nope"').toString('base64url'), undefined],
|
||||
['text that is not base64url JSON', 'zzz!!', undefined],
|
||||
@@ -246,7 +254,7 @@ describe('cursor encoding', () => {
|
||||
// wrong with the cursor, and the generation it claimed whenever that
|
||||
// survived parsing.
|
||||
try {
|
||||
decodeSessionSearchCursor(cursor, 7, 'k')
|
||||
decodeSessionSearchCursor(cursor, 7, 'k', incarnation)
|
||||
expect.unreachable('a malformed cursor is not an empty one')
|
||||
} catch (error) {
|
||||
const rejected = error as SessionSearchCursorError
|
||||
@@ -255,6 +263,15 @@ describe('cursor encoding', () => {
|
||||
expect(rejected.expectedGeneration).toBe(claimed)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats legacy and previous-incarnation cursors as stale', () => {
|
||||
const legacy = Buffer.from('{"g":7,"o":1,"k":"k"}').toString('base64url')
|
||||
for (const cursor of [legacy, encodeSessionSearchCursor(7, 1, 'k', 'index-before')]) {
|
||||
expect(() => decodeSessionSearchCursor(cursor, 7, 'k', incarnation)).toThrow(
|
||||
'stale-generation'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('the candidate limit is a tunable default, and says when it cut', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { dirname } from 'node:path'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
|
||||
@@ -131,6 +132,10 @@ function openExisting(path: string): SyncDatabase {
|
||||
db = openWithPragmas(path)
|
||||
}
|
||||
db.exec(SCHEMA_SQL)
|
||||
db.prepare('INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)').run(
|
||||
'index_incarnation',
|
||||
randomUUID()
|
||||
)
|
||||
db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run(
|
||||
'schema_version',
|
||||
String(SESSION_SEARCH_SCHEMA_VERSION)
|
||||
|
||||
@@ -26,6 +26,7 @@ export type AiVaultServiceOperation =
|
||||
| 'searchSessions'
|
||||
| 'searchStatus'
|
||||
| 'searchReconcile'
|
||||
| 'searchClear'
|
||||
|
||||
// Typed from the union so a new operation cannot be added without landing here,
|
||||
// and held as strings so recognising one costs no assertion.
|
||||
@@ -36,7 +37,8 @@ const AI_VAULT_SERVICE_OPERATIONS: ReadonlySet<string> = new Set<AiVaultServiceO
|
||||
'firstPrompt',
|
||||
'searchSessions',
|
||||
'searchStatus',
|
||||
'searchReconcile'
|
||||
'searchReconcile',
|
||||
'searchClear'
|
||||
])
|
||||
|
||||
export type AiVaultServiceSubagentRequest = {
|
||||
@@ -83,6 +85,7 @@ export type AiVaultServiceRequestBody =
|
||||
| { type: 'request'; operation: 'searchSessions'; request: AiVaultSearchRequest }
|
||||
| { type: 'request'; operation: 'searchStatus' }
|
||||
| { type: 'request'; operation: 'searchReconcile' }
|
||||
| { type: 'request'; operation: 'searchClear' }
|
||||
|
||||
export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number }
|
||||
|
||||
@@ -104,6 +107,7 @@ export type AiVaultServiceResultValue =
|
||||
| { operation: 'searchSessions'; value: AiVaultSearchResponse }
|
||||
| { operation: 'searchStatus'; value: AiVaultSearchStatus }
|
||||
| { operation: 'searchReconcile'; value: null }
|
||||
| { operation: 'searchClear'; value: null }
|
||||
|
||||
export type AiVaultServiceChildMessage =
|
||||
| { type: 'sessionSearchRoots'; id: number }
|
||||
|
||||
@@ -75,6 +75,12 @@ async function indexedSessions(term: string, expected: string[]): Promise<void>
|
||||
)
|
||||
}
|
||||
|
||||
it('refuses to clear when the child has no search instance', async () => {
|
||||
await expect(
|
||||
subject.execute({ type: 'request', id: 1, operation: 'searchClear' })
|
||||
).rejects.toThrow('Agent Session History search is not available.')
|
||||
})
|
||||
|
||||
it('refreshes a late root without rebuilding the index', async () => {
|
||||
await writeMessageGraphTranscript(openclawTranscript(spawnRoot, 'early-session'), [
|
||||
'a conversation in a root the spawn already knew'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types'
|
||||
@@ -156,6 +156,21 @@ it('discovers a new root through the parent exchange on manual reconciliation',
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the owned index and rebuilds from the transcripts still on disk', async () => {
|
||||
const transcriptPath = join(harness.claudeProjectDir, `${SESSION_ID}.jsonl`)
|
||||
expect((await searchSessions('distinctive')).kind).toBe('results')
|
||||
rmSync(transcriptPath)
|
||||
|
||||
expect(await call({ type: 'request', operation: 'searchClear' })).toEqual({
|
||||
operation: 'searchClear',
|
||||
value: null,
|
||||
type: 'result',
|
||||
id: expect.any(Number)
|
||||
})
|
||||
expect(await searchSessions('distinctive')).toMatchObject({ kind: 'results', hits: [] })
|
||||
expect(existsSync(harness.databasePath)).toBe(true)
|
||||
})
|
||||
|
||||
it('answers disabled once consent is withdrawn, without a respawn', async () => {
|
||||
emit({ type: 'sessionSearch', init: searchInit(false) })
|
||||
expect(await searchSessions('distinctive')).toEqual({ kind: 'unavailable', reason: 'disabled' })
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
|
||||
type SearchOperation = Extract<
|
||||
AiVaultServiceRequest,
|
||||
{ operation: 'searchSessions' | 'searchStatus' | 'searchReconcile' }
|
||||
{ operation: 'searchSessions' | 'searchStatus' | 'searchReconcile' | 'searchClear' }
|
||||
>
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,8 @@ export class SessionScannerServiceSearch {
|
||||
return (
|
||||
request.operation === 'searchSessions' ||
|
||||
request.operation === 'searchStatus' ||
|
||||
request.operation === 'searchReconcile'
|
||||
request.operation === 'searchReconcile' ||
|
||||
request.operation === 'searchClear'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,6 +78,13 @@ export class SessionScannerServiceSearch {
|
||||
await instance?.reconcile()
|
||||
return { operation: 'searchReconcile', value: null }
|
||||
}
|
||||
if (request.operation === 'searchClear') {
|
||||
if (!instance) {
|
||||
throw new Error('Agent Session History search is not available.')
|
||||
}
|
||||
instance.clear()
|
||||
return { operation: 'searchClear', value: null }
|
||||
}
|
||||
return {
|
||||
operation: 'searchSessions',
|
||||
value: instance
|
||||
|
||||
@@ -109,6 +109,10 @@ export function reconcileSessionSearchInService(): Promise<void> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'searchReconcile' })
|
||||
}
|
||||
|
||||
export function clearSessionSearchInService(): Promise<void> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'searchClear' })
|
||||
}
|
||||
|
||||
/** Boot and every settings change: push the policy and keep a child while the index runs. */
|
||||
export function updateSessionSearchInService(init: AiVaultSessionSearchInit): void {
|
||||
getSharedClient().updateSessionSearch(init)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers, sshSearch, runtimeSearch } = vi.hoisted(() => ({
|
||||
const { clearSearch, handlers, sshSearch, runtimeSearch } = vi.hoisted(() => ({
|
||||
clearSearch: vi.fn(),
|
||||
handlers: new Map<string, (...args: unknown[]) => Promise<unknown>>(),
|
||||
sshSearch: vi.fn(),
|
||||
runtimeSearch: vi.fn()
|
||||
}))
|
||||
vi.mock('../ai-vault/session-scanner-service-spawn', () => ({
|
||||
clearSessionSearchInService: clearSearch
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (name: string, handler: (...args: unknown[]) => Promise<unknown>) =>
|
||||
@@ -25,6 +29,7 @@ beforeEach(() => {
|
||||
handlers.clear()
|
||||
sshSearch.mockReset()
|
||||
runtimeSearch.mockReset()
|
||||
clearSearch.mockReset()
|
||||
registerAiVaultSearchHandlers({
|
||||
callRuntimeSearch: runtimeSearch
|
||||
})
|
||||
@@ -50,6 +55,13 @@ describe('desktop IPC and preload search boundary', () => {
|
||||
expect(sshSearch).not.toHaveBeenCalled()
|
||||
expect(runtimeSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
it('clears only the desktop-local child-owned index', async () => {
|
||||
clearSearch.mockResolvedValue(undefined)
|
||||
await aiVaultApi.clearSearchIndex()
|
||||
expect(clearSearch).toHaveBeenCalledOnce()
|
||||
expect(sshSearch).not.toHaveBeenCalled()
|
||||
expect(runtimeSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects malformed renderer input and uses typed unavailable', async () => {
|
||||
expect(await aiVaultApi.searchSessions({ query: 'needle' })).toEqual({
|
||||
kind: 'unavailable',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ParsedExecutionHost
|
||||
} from '../../shared/execution-host'
|
||||
import { requestActiveSshSessionSearch } from './ssh'
|
||||
import { clearSessionSearchInService } from '../ai-vault/session-scanner-service-spawn'
|
||||
|
||||
export type RuntimeSessionSearchCall = (
|
||||
environmentId: string,
|
||||
@@ -48,6 +49,7 @@ export function registerAiVaultSearchHandlers(options: AiVaultSearchHandlerOptio
|
||||
const scope = requestedSearchScope(rawScope)
|
||||
return statusByExecutionHost(scope)
|
||||
})
|
||||
ipcMain.handle('aiVault:clearSearchIndex', () => clearSessionSearchInService())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,8 @@ export type AiVaultApi = {
|
||||
) => Promise<AiVaultSearchResponse>
|
||||
/** Status describes one index, so it never accepts the `all` scope. */
|
||||
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
|
||||
/** Deletes and rebuilds this desktop's local search index. */
|
||||
clearSearchIndex: () => Promise<void>
|
||||
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
|
||||
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise<AiVaultSessionTitlesResult>
|
||||
cancelListSessions: (args: { requestToken: string }) => Promise<void>
|
||||
|
||||
@@ -33,6 +33,7 @@ export const aiVaultApi = {
|
||||
searchClient(executionHostScope).searchSessions(request),
|
||||
searchStatus: (executionHostScope?: ExecutionHostId) =>
|
||||
searchClient(executionHostScope).searchStatus(),
|
||||
clearSearchIndex: (): Promise<void> => ipcRenderer.invoke('aiVault:clearSearchIndex'),
|
||||
listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args),
|
||||
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) =>
|
||||
ipcRenderer.invoke('aiVault:resolveSessionTitles', args),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ visible: true, status: vi.fn() }))
|
||||
vi.mock('@/hooks/use-window-stream-visibility', () => ({
|
||||
useWindowStreamVisible: () => mocks.visible
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
|
||||
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
|
||||
}))
|
||||
|
||||
const current: AiVaultSearchStatus = {
|
||||
...unavailableSessionSearchStatus(),
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 12,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.visible = true
|
||||
mocks.status.mockReset().mockResolvedValue(current)
|
||||
vi.stubGlobal('api', undefined)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { aiVault: { searchStatus: mocks.status } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps polling a settled index so counts stay live between sweeps', async () => {
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledWith('local')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
mocks.status.mockResolvedValue({ ...current, filesIndexed: 30 })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 30 files indexed')
|
||||
})
|
||||
|
||||
it('reports a first scan by count and later sweeps by percentage', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 4,
|
||||
filesDue: 6,
|
||||
lastSweepCompletedAt: null
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Indexing… 4 files so far')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Turn off search to stop')
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 4,
|
||||
filesDue: 5,
|
||||
filesFailed: 1,
|
||||
lastSweepCompletedAt: 1
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Indexing · 40% · 4 of 10 files')
|
||||
})
|
||||
|
||||
it('polls a sweep faster than a settled index', async () => {
|
||||
mocks.status.mockResolvedValue({ ...current, phase: 'indexing', filesDue: 3 })
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
const started = mocks.status.mock.calls.length
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
})
|
||||
expect(mocks.status.mock.calls.length - started).toBe(3)
|
||||
})
|
||||
|
||||
it('names unreadable files while degraded and still reports progress', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'degraded',
|
||||
filesIndexed: 8,
|
||||
filesDue: 1,
|
||||
filesFailed: 1,
|
||||
degradedRoots: [{ reason: 'unreadable' }]
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('Indexing · 80% · 8 of 10 files')
|
||||
expect(status).toHaveTextContent('1 files could not be read and will be retried.')
|
||||
expect(status).toHaveTextContent('Unverified source roots: 1')
|
||||
})
|
||||
|
||||
it('calls a drained degraded index up to date', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'degraded',
|
||||
filesIndexed: 9,
|
||||
filesDue: 0,
|
||||
filesFailed: 2
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 9 files indexed')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('2 files could not be read')
|
||||
expect(screen.queryByText(/Turn off search to stop/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no refresh control now that status is live', async () => {
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not describe an absent service as an empty current index', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'not ready or the search service is unavailable'
|
||||
)
|
||||
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recovers on its own after a failed read', async () => {
|
||||
mocks.status.mockRejectedValueOnce(new Error('offline'))
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
})
|
||||
|
||||
it('handles a synchronous bridge failure without losing the poll', async () => {
|
||||
mocks.status.mockImplementationOnce(() => {
|
||||
throw new Error('bridge unavailable')
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
})
|
||||
|
||||
it('fences pending responses across disable and hiding', async () => {
|
||||
let answer: (value: AiVaultSearchStatus) => void = () => undefined
|
||||
mocks.status.mockReturnValue(
|
||||
new Promise<AiVaultSearchStatus>((resolve) => {
|
||||
answer = resolve
|
||||
})
|
||||
)
|
||||
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
view.rerender(<SessionHistoryIndexStatus enabled={false} refresh={0} />)
|
||||
await act(async () => {
|
||||
answer(current)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Search is off')
|
||||
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
|
||||
mocks.visible = false
|
||||
view.rerender(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1)
|
||||
mocks.visible = true
|
||||
mocks.status.mockResolvedValue(current)
|
||||
view.rerender(<SessionHistoryIndexStatus enabled refresh={1} />)
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not overlap slow status requests and stops polling on unmount', async () => {
|
||||
let answer: (value: AiVaultSearchStatus) => void = () => undefined
|
||||
mocks.status.mockReturnValue(
|
||||
new Promise<AiVaultSearchStatus>((resolve) => {
|
||||
answer = resolve
|
||||
})
|
||||
)
|
||||
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
answer({ ...current, phase: 'indexing', filesDue: 2 })
|
||||
})
|
||||
const beforeUnmount = mocks.status.mock.calls.length
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(beforeUnmount)
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import { useWindowStreamVisible } from '@/hooks/use-window-stream-visibility'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SettingsRow } from './SettingsFormControls'
|
||||
|
||||
const SWEEPING_POLL_MS = 2_000
|
||||
const SETTLED_POLL_MS = 10_000
|
||||
|
||||
// A pass still has files due, so counts move between polls; a settled index only changes on the next sweep.
|
||||
function isSweeping(status: AiVaultSearchStatus | null): boolean {
|
||||
if (!status?.enabled) {
|
||||
return false
|
||||
}
|
||||
return status.phase === 'indexing' || (status.phase === 'degraded' && status.filesDue > 0)
|
||||
}
|
||||
|
||||
function sweepMessage(status: AiVaultSearchStatus): string {
|
||||
if (status.lastSweepCompletedAt === null) {
|
||||
// No completed sweep yet, so the denominator is still growing and a percentage would mislead.
|
||||
return translate('sessionHistory.status.firstScan', 'Indexing… {{indexed}} files so far', {
|
||||
indexed: status.filesIndexed
|
||||
})
|
||||
}
|
||||
const total = status.filesIndexed + status.filesDue + status.filesFailed
|
||||
const percent = total > 0 ? Math.floor((status.filesIndexed / total) * 100) : 0
|
||||
return translate(
|
||||
'sessionHistory.status.progress',
|
||||
'Indexing · {{percent}}% · {{indexed}} of {{total}} files',
|
||||
{ percent, indexed: status.filesIndexed, total }
|
||||
)
|
||||
}
|
||||
|
||||
function statusMessage(status: AiVaultSearchStatus): string {
|
||||
if (!status.enabled || status.phase === 'idle' || status.phase === 'closed') {
|
||||
return translate(
|
||||
'sessionHistory.status.unavailable',
|
||||
'Index is not ready or the search service is unavailable.'
|
||||
)
|
||||
}
|
||||
if (isSweeping(status)) {
|
||||
return sweepMessage(status)
|
||||
}
|
||||
return translate('sessionHistory.status.upToDate', 'Up to date · {{indexed}} files indexed', {
|
||||
indexed: status.filesIndexed
|
||||
})
|
||||
}
|
||||
|
||||
export function SessionHistoryIndexStatus({
|
||||
enabled,
|
||||
refresh
|
||||
}: {
|
||||
enabled: boolean
|
||||
refresh: number
|
||||
}): React.JSX.Element {
|
||||
const visible = useWindowStreamVisible(0)
|
||||
const [status, setStatus] = useState<AiVaultSearchStatus | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const intervalMs = isSweeping(status) ? SWEEPING_POLL_MS : SETTLED_POLL_MS
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus(null)
|
||||
setFailed(false)
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
let inFlight = false
|
||||
async function read(): Promise<void> {
|
||||
if (inFlight || disposed) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
try {
|
||||
const next = await Promise.resolve().then(() =>
|
||||
window.api.aiVault.searchStatus(LOCAL_EXECUTION_HOST_ID)
|
||||
)
|
||||
if (!disposed) {
|
||||
setStatus(next)
|
||||
setFailed(false)
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setStatus(null)
|
||||
setFailed(true)
|
||||
}
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs })
|
||||
return () => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
}
|
||||
}, [enabled, visible, refresh, intervalMs])
|
||||
|
||||
let message = translate('sessionHistory.status.checking', 'Checking index…')
|
||||
if (!enabled) {
|
||||
message = translate(
|
||||
'sessionHistory.status.off',
|
||||
'Search is off. Any existing index copy is kept.'
|
||||
)
|
||||
} else if (failed) {
|
||||
message = translate('sessionHistory.status.error', 'Could not read index status. Retrying…')
|
||||
} else if (status) {
|
||||
message = statusMessage(status)
|
||||
}
|
||||
const live = enabled && status?.enabled === true
|
||||
return (
|
||||
<SettingsRow
|
||||
label={translate('sessionHistory.status.title', 'Index status')}
|
||||
description={
|
||||
<span role="status" className="space-y-1 block">
|
||||
<span className="block">{message}</span>
|
||||
{live && status.phase === 'degraded' && status.filesFailed > 0 ? (
|
||||
<span className="block">
|
||||
{translate(
|
||||
'sessionHistory.status.unreadable',
|
||||
'{{failed}} files could not be read and will be retried.',
|
||||
{ failed: status.filesFailed }
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{live && isSweeping(status) ? (
|
||||
<span className="block text-muted-foreground">
|
||||
{translate(
|
||||
'sessionHistory.status.stopHint',
|
||||
'Turn off search to stop. Progress is kept and resumes when you turn it back on.'
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{live && status.degradedRoots.length > 0 ? (
|
||||
<span className="block">
|
||||
{translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', {
|
||||
roots: status.degradedRoots.length
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
control={null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
|
||||
import { SessionHistorySettingsPane } from './SessionHistorySettingsPane'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ web: false, visible: true, status: vi.fn(), clear: vi.fn() }))
|
||||
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
|
||||
vi.mock('@/hooks/use-window-stream-visibility', () => ({
|
||||
useWindowStreamVisible: () => mocks.visible
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
|
||||
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
|
||||
|
||||
function pane(
|
||||
enabled = false,
|
||||
confirm = vi.fn().mockResolvedValue(true),
|
||||
save = vi.fn().mockResolvedValue(undefined),
|
||||
historyDays: number | null = null
|
||||
) {
|
||||
return render(
|
||||
<ConfirmationDialogContext.Provider value={confirm}>
|
||||
<SessionHistorySettingsPane
|
||||
settings={{
|
||||
...getDefaultSettings('/synthetic'),
|
||||
aiVaultSearch: { enabled, historyDays }
|
||||
}}
|
||||
updateSettings={save}
|
||||
/>
|
||||
</ConfirmationDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
async function openAdvanced(): Promise<void> {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Advanced/ }))
|
||||
})
|
||||
}
|
||||
const current: AiVaultSearchStatus = {
|
||||
...unavailableSessionSearchStatus(),
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 12,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.web = false
|
||||
mocks.visible = true
|
||||
mocks.status.mockReset().mockResolvedValue(current)
|
||||
mocks.clear.mockReset().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('api', undefined)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { aiVault: { searchStatus: mocks.status, clearSearchIndex: mocks.clear } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('requires opt-in and saves the existing policy without touching transcripts or polling while off', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
pane(false, confirm, save)
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByText(/Content is not redacted/)).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Start indexing agent sessions?',
|
||||
description: expect.stringContaining('content is not redacted'),
|
||||
confirmLabel: 'Start indexing'
|
||||
})
|
||||
)
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
|
||||
})
|
||||
|
||||
it('leaves search off when the indexing consent is declined', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(false, vi.fn().mockResolvedValue(false), save)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('turns search off without asking again', async () => {
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(true, confirm, save)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: false, historyDays: null } })
|
||||
})
|
||||
|
||||
it('keeps the stored retention window without offering a control for it', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(false, undefined, save, 30)
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Searchable history/)).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: 30 } })
|
||||
})
|
||||
|
||||
it('shows failed saves inline and unlocks controls', async () => {
|
||||
pane(false, undefined, vi.fn().mockRejectedValue(new Error('write failed')))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not save')
|
||||
expect(screen.getByRole('switch')).toBeEnabled()
|
||||
})
|
||||
|
||||
it('hides the delete control behind Advanced', async () => {
|
||||
pane(false)
|
||||
expect(screen.queryByRole('button', { name: 'Delete index' })).not.toBeInTheDocument()
|
||||
await openAdvanced()
|
||||
expect(screen.getByRole('button', { name: 'Delete index' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/Search stays off/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes only after confirmation, supports deleting while disabled, and reports failures', async () => {
|
||||
const confirm = vi.fn().mockResolvedValue(false)
|
||||
pane(false, confirm)
|
||||
await openAdvanced()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
|
||||
})
|
||||
expect(mocks.clear).not.toHaveBeenCalled()
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Delete this computer’s search index?',
|
||||
description: expect.stringContaining('Search stays off'),
|
||||
confirmLabel: 'Delete index'
|
||||
})
|
||||
)
|
||||
confirm.mockResolvedValue(true)
|
||||
mocks.clear.mockRejectedValue(new Error('service unavailable'))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
|
||||
})
|
||||
expect(mocks.clear).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not clear')
|
||||
})
|
||||
|
||||
it('does not execute a confirmation after navigating away', async () => {
|
||||
let accept: (value: boolean) => void = () => undefined
|
||||
const confirmation = new Promise<boolean>((resolve) => {
|
||||
accept = resolve
|
||||
})
|
||||
const view = pane(false, vi.fn().mockReturnValue(confirmation))
|
||||
await openAdvanced()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
accept(true)
|
||||
})
|
||||
expect(mocks.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves paired-client controls unsupported without local calls', async () => {
|
||||
mocks.web = true
|
||||
pane(true)
|
||||
expect(screen.getByRole('switch')).toBeDisabled()
|
||||
await openAdvanced()
|
||||
expect(screen.getByRole('button', { name: 'Delete index' })).toBeDisabled()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the last index status visible while a save is in flight', async () => {
|
||||
let finishSave: () => void = () => undefined
|
||||
const save = vi.fn().mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finishSave = resolve
|
||||
})
|
||||
)
|
||||
pane(true, vi.fn().mockResolvedValue(true), save)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
await act(async () => {
|
||||
finishSave()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import {
|
||||
AiVaultSearchSettingsSchema,
|
||||
resolveAiVaultSearchSettings
|
||||
} from '../../../../shared/ai-vault-search-settings'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SettingsRow, SettingsSwitchRow } from './SettingsFormControls'
|
||||
import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus'
|
||||
|
||||
export function SessionHistorySettingsPane({
|
||||
settings,
|
||||
updateSettings
|
||||
}: {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const policy = resolveAiVaultSearchSettings(settings)
|
||||
const isWebClient = isWebClientLocation()
|
||||
const confirm = useConfirmationDialog()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function save(updates: Partial<typeof policy>): Promise<void> {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateSettings({
|
||||
aiVaultSearch: AiVaultSearchSettingsSchema.parse({ ...policy, ...updates })
|
||||
})
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(
|
||||
translate(
|
||||
'sessionHistory.settings.saveError',
|
||||
'Could not save session search settings. Try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(): Promise<void> {
|
||||
if (policy.enabled) {
|
||||
await save({ enabled: false })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
let accepted = false
|
||||
try {
|
||||
accepted = await confirm({
|
||||
title: translate('sessionHistory.settings.enableTitle', 'Start indexing agent sessions?'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.enableConsent',
|
||||
'Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.'
|
||||
),
|
||||
confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Start indexing')
|
||||
})
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
}
|
||||
await save({ enabled: true })
|
||||
}
|
||||
|
||||
async function deleteIndex(): Promise<void> {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const accepted = await confirm({
|
||||
title: translate(
|
||||
'sessionHistory.settings.deleteTitle',
|
||||
'Delete this computer’s search index?'
|
||||
),
|
||||
description: policy.enabled
|
||||
? translate(
|
||||
'sessionHistory.settings.deleteEnabled',
|
||||
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.deleteDisabled',
|
||||
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
|
||||
),
|
||||
confirmLabel: translate('sessionHistory.settings.delete', 'Delete index'),
|
||||
confirmVariant: 'destructive'
|
||||
})
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
}
|
||||
await window.api.aiVault.clearSearchIndex()
|
||||
if (mounted.current) {
|
||||
setRefresh((value) => value + 1)
|
||||
toast.success(
|
||||
translate(
|
||||
'sessionHistory.settings.cleared',
|
||||
'Search index cleared. Original transcripts were kept.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(
|
||||
translate('sessionHistory.settings.clearError', 'Could not clear the index. Try again.')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
<SettingsSwitchRow
|
||||
label={translate('sessionHistory.settings.enable', 'Enable session history search')}
|
||||
description={
|
||||
isWebClient
|
||||
? translate(
|
||||
'sessionHistory.settings.webUnsupported',
|
||||
'Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.consent',
|
||||
'Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.'
|
||||
)
|
||||
}
|
||||
checked={policy.enabled}
|
||||
disabled={busy || isWebClient}
|
||||
onChange={() => void toggleEnabled()}
|
||||
/>
|
||||
{!isWebClient ? (
|
||||
<SessionHistoryIndexStatus enabled={policy.enabled} refresh={refresh} />
|
||||
) : null}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="xs" className="-ml-2">
|
||||
{translate('sessionHistory.settings.advanced', 'Advanced')}
|
||||
<ChevronDown
|
||||
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SettingsRow
|
||||
label={translate('sessionHistory.settings.deleteIndexCopy', 'Delete index copy')}
|
||||
description={
|
||||
policy.enabled
|
||||
? translate(
|
||||
'sessionHistory.settings.deleteEnabled',
|
||||
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.deleteDisabled',
|
||||
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
|
||||
)
|
||||
}
|
||||
control={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || isWebClient}
|
||||
onClick={() => void deleteIndex()}
|
||||
>
|
||||
{translate('sessionHistory.settings.delete', 'Delete index')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{error ? (
|
||||
<p role="alert" className="pt-3 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from './settings-capability-section-renderers'
|
||||
import {
|
||||
renderArtifactsSettingsSection,
|
||||
renderSessionHistorySettingsSection,
|
||||
renderAutomationsSettingsSection,
|
||||
renderGeneralSettingsSection,
|
||||
renderIntegrationsSettingsSection,
|
||||
@@ -127,6 +128,7 @@ export function renderSettingsPage(context: SettingsRenderContext): React.JSX.El
|
||||
{renderAutomationsSettingsSection(context)}
|
||||
{renderArtifactsSettingsSection(context)}
|
||||
{renderShareSkillsSettingsSection(context)}
|
||||
{renderSessionHistorySettingsSection(context)}
|
||||
{renderGitSettingsSection(context)}
|
||||
{renderTasksSettingsSection(context)}
|
||||
{renderTerminalSettingsSection(context)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SessionHistorySettingsPane } from './SessionHistorySettingsPane'
|
||||
import { ArtifactsSettingsPane } from './ArtifactsSettingsPane'
|
||||
import { AutomationsSettingsPane } from './AutomationsSettingsPane'
|
||||
import { GeneralPane } from './GeneralPane'
|
||||
@@ -176,3 +177,28 @@ export function renderShareSkillsSettingsSection(
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderSessionHistorySettingsSection(
|
||||
context: SettingsRenderContext
|
||||
): React.JSX.Element {
|
||||
const { model, navigation, view } = context
|
||||
return (
|
||||
<SettingsSection
|
||||
id="session-history"
|
||||
title={translate('sessionHistory.settings.title', 'Agent Session History')}
|
||||
description={translate(
|
||||
'sessionHistory.settings.description',
|
||||
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
|
||||
)}
|
||||
searchEntries={navigation.getSectionSearchEntries('session-history')}
|
||||
>
|
||||
{view.isSectionMounted('session-history') ? (
|
||||
<SessionHistorySettingsPane
|
||||
key={JSON.stringify(model.settings.aiVaultSearch)}
|
||||
settings={model.settings}
|
||||
updateSettings={model.updateSettingsOrThrow}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
BookOpen,
|
||||
CalendarClock,
|
||||
Files,
|
||||
History,
|
||||
GitBranch,
|
||||
Globe,
|
||||
ListChecks,
|
||||
@@ -68,6 +69,25 @@ export function buildWorkflowSettingsSections(
|
||||
group: 'workflows',
|
||||
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
|
||||
},
|
||||
{
|
||||
id: 'session-history',
|
||||
title: translate('sessionHistory.settings.title', 'Agent Session History'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.description',
|
||||
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
|
||||
),
|
||||
icon: History,
|
||||
searchEntries: [
|
||||
{
|
||||
title: translate('sessionHistory.settings.enable', 'Enable session history search'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.searchDescription',
|
||||
'Transcript indexing, index status and delete index.'
|
||||
)
|
||||
}
|
||||
],
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: translate(
|
||||
|
||||
@@ -17845,6 +17845,41 @@
|
||||
"finished": "finished"
|
||||
}
|
||||
},
|
||||
"sessionHistory": {
|
||||
"status": {
|
||||
"checking": "Checking index…",
|
||||
"off": "Search is off. Any existing index copy is kept.",
|
||||
"error": "Could not read index status. Retrying…",
|
||||
"unavailable": "Index is not ready or the search service is unavailable.",
|
||||
"title": "Index status",
|
||||
"firstScan": "Indexing… {{indexed}} files so far",
|
||||
"progress": "Indexing · {{percent}}% · {{indexed}} of {{total}} files",
|
||||
"upToDate": "Up to date · {{indexed}} files indexed",
|
||||
"unreadable": "{{failed}} files could not be read and will be retried.",
|
||||
"stopHint": "Turn off search to stop. Progress is kept and resumes when you turn it back on.",
|
||||
"roots": "Unverified source roots: {{roots}}"
|
||||
},
|
||||
"settings": {
|
||||
"saveError": "Could not save session search settings. Try again.",
|
||||
"enableTitle": "Start indexing agent sessions?",
|
||||
"enableConsent": "Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.",
|
||||
"enableConfirm": "Start indexing",
|
||||
"advanced": "Advanced",
|
||||
"deleteIndexCopy": "Delete index copy",
|
||||
"deleteTitle": "Delete this computer’s search index?",
|
||||
"deleteEnabled": "Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.",
|
||||
"deleteDisabled": "Remove the search index from this computer. Original transcripts are not touched. Search stays off.",
|
||||
"delete": "Delete index",
|
||||
"cleared": "Search index cleared. Original transcripts were kept.",
|
||||
"clearError": "Could not clear the index. Try again.",
|
||||
"enable": "Enable session history search",
|
||||
"webUnsupported": "Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.",
|
||||
"consent": "Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.",
|
||||
"title": "Agent Session History",
|
||||
"description": "Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.",
|
||||
"searchDescription": "Transcript indexing, index status and delete index."
|
||||
}
|
||||
},
|
||||
"aiVault": {
|
||||
"subagents": {
|
||||
"loading": "Loading subagents…",
|
||||
|
||||
@@ -39,6 +39,7 @@ const SETTINGS_NAV_TARGETS = [
|
||||
'agents',
|
||||
'orchestration',
|
||||
'artifacts',
|
||||
'session-history',
|
||||
'share-skills',
|
||||
'automations',
|
||||
'orca-account',
|
||||
|
||||
@@ -39,6 +39,8 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault'
|
||||
addressesOwnRuntime(executionHostScope)
|
||||
? search.searchStatus()
|
||||
: Promise.resolve(unavailableSessionSearchStatus()),
|
||||
clearSearchIndex: () =>
|
||||
Promise.reject(new Error('Clearing Agent Session History is unavailable in the browser.')),
|
||||
listSessions: (args?: AiVaultListArgs) => {
|
||||
const environment = requireActiveEnvironment()
|
||||
const executionHostId = toRuntimeExecutionHostId(environment.id)
|
||||
|
||||
Reference in New Issue
Block a user