mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
Add host-owned remote session search and harden indexing lifecycle
This commit is contained in:
@@ -103,6 +103,7 @@ docs/**
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/agent-session-search.md
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/ime-regression-checklist.md
|
||||
|
||||
@@ -150,6 +150,8 @@ Agents drive Orca too — script every workflow with `orca worktree create`, `sn
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
[Agent-session search across local, paired, and SSH hosts](docs/reference/agent-session-search.md)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="100%" /></picture></a>
|
||||
|
||||
@@ -10,6 +10,104 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "agent-session.remote-search-ownership",
|
||||
"title": "Remote search preserves host consent, ownership and bounded partial results",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "agent-session-history",
|
||||
"layer": "cli-runtime-relay-unit-and-process",
|
||||
"surfaces": ["agent-session search", "host index policy"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Synthetic CLI/RPC/provider contracts and real scanner processes run on macOS. Separate live checks exercised authenticated paired orcad and Linux Docker SSH; native Windows/WSL and rendered UI remain unverified.",
|
||||
"motivatingLinks": ["docs/reference/agent-session-search.md"],
|
||||
"invariant": "Targeted search and clear never fall back to an intermediate index; unknown policy and disconnected inventory never become complete empty results; only one relay process can own an index.",
|
||||
"oracle": "Owner-separated five-plus-five results, sixteen-route/three-concurrent budgets, aborted socket closure, legacy targeted-method refusal, and a second scanner unable to clear until the first exits.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/cli/session-search-all-hosts.test.ts src/cli/runtime/transport.test.ts src/cli/handlers/search.test.ts src/cli/search-interruption.test.ts src/cli/search-command-arguments.test.ts src/main/runtime/runtime-ai-vault-ssh-search.test.ts src/main/runtime/rpc/methods/ai-vault-search.test.ts src/relay/session-search-owner.test.ts src/relay/session-search-owner-progress.test.ts src/relay/session-search-owner-process.test.ts src/shared/ai-vault-search-projection.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-ai-vault-search-durability.test.ts src/main/ai-vault-search/session-search-source-presence.test.ts src/main/ai-vault-search/session-search-source-refill.test.ts src/main/ai-vault-search/session-search-refresh-lane.test.ts src/main/ai-vault/session-scanner-search-recovery.test.ts src/main/ai-vault/session-scanner-service-env.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/cli/session-search-all-hosts.test.ts",
|
||||
"src/cli/runtime/transport.test.ts",
|
||||
"src/cli/handlers/search.test.ts",
|
||||
"src/cli/search-interruption.test.ts",
|
||||
"src/cli/search-command-arguments.test.ts",
|
||||
"src/main/runtime/runtime-ai-vault-ssh-search.test.ts",
|
||||
"src/main/runtime/rpc/methods/ai-vault-search.test.ts",
|
||||
"src/relay/session-search-owner.test.ts",
|
||||
"src/relay/session-search-owner-progress.test.ts",
|
||||
"src/relay/session-search-owner-process.test.ts",
|
||||
"src/shared/ai-vault-search-projection.test.ts",
|
||||
"src/main/runtime/runtime-ai-vault-search-durability.test.ts",
|
||||
"src/main/ai-vault-search/session-search-source-presence.test.ts",
|
||||
"src/main/ai-vault-search/session-search-source-refill.test.ts",
|
||||
"src/main/ai-vault-search/session-search-refresh-lane.test.ts",
|
||||
"src/main/ai-vault/session-scanner-search-recovery.test.ts",
|
||||
"src/main/ai-vault/session-scanner-service-env.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/cli/session-search-all-hosts.test.ts",
|
||||
"assertions": [
|
||||
"returns five plus five in separate owner groups despite identical session IDs and paths",
|
||||
"queries at most sixteen routes with no more than three simultaneous host legs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/relay/session-search-owner-process.test.ts",
|
||||
"assertions": [
|
||||
"excludes a second scanner process, then releases ownership on owner crash without replaying clear"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/cli/session-search-all-hosts.test.ts src/cli/runtime/transport.test.ts src/cli/handlers/search.test.ts src/cli/search-interruption.test.ts src/cli/search-command-arguments.test.ts src/main/runtime/runtime-ai-vault-ssh-search.test.ts src/main/runtime/rpc/methods/ai-vault-search.test.ts src/relay/session-search-owner.test.ts src/relay/session-search-owner-progress.test.ts src/relay/session-search-owner-process.test.ts src/shared/ai-vault-search-projection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.51,
|
||||
"summary": "74 tests passed across eleven files, including slow-pass handoff, queued pause/resume, interruption and process-crash exclusion."
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-ai-vault-search-durability.test.ts src/main/ai-vault-search/session-search-source-presence.test.ts src/main/ai-vault-search/session-search-source-refill.test.ts src/main/ai-vault-search/session-search-refresh-lane.test.ts src/main/ai-vault/session-scanner-search-recovery.test.ts src/main/ai-vault/session-scanner-service-env.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.499,
|
||||
"summary": "28 tests passed across six files, including durable policy acknowledgement, common source validation/refill, refresh draining and corrupt scanner recovery."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "synthetic fixtures and scanner-process crash recovery"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Initial local verification; no soak claim."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Live paired-orcad immediate restart lost enabled policy before the durable flush fix and preserved it afterward. Repeated CLI filters failed before the parser change. The legacy registry is explicitly exercised for fail-closed routing. A full historical build comparison of the new surface is not claimed."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Tests bound queried routes to sixteen, simultaneous legs to three, abort listener/socket cleanup, response bytes, and per-host/inventory deadlines."
|
||||
},
|
||||
"promotionCriteria": ["Collect CI soak history and add native Windows/WSL live coverage."],
|
||||
"knownGaps": [
|
||||
"Live Docker and paired-orcad smoke checks are not part of this automatic unit gate.",
|
||||
"Rendered desktop/web host selection is follow-on scope.",
|
||||
"A different relay generation waits for the active pass or same-owner pause; ten-minute idle retirement can interrupt unfinished discovery."
|
||||
],
|
||||
"demotionRule": "Keep experimental until reproducible cross-platform and soak evidence exists."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.padded-fullscreen-redraw",
|
||||
"title": "Fullscreen redraw padding does not stall terminal delivery",
|
||||
|
||||
@@ -106,8 +106,20 @@ function buildForkedChild(entryPoint, outfile) {
|
||||
}
|
||||
|
||||
const childResults = await Promise.all([
|
||||
buildForkedChild(
|
||||
join(ROOT, 'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'),
|
||||
join(OUT_DIR, 'session-scanner-opencode-sqlite-worker-entry.js')
|
||||
),
|
||||
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
|
||||
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
|
||||
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE),
|
||||
buildForkedChild(
|
||||
join(ROOT, 'src/main/ai-vault/session-scanner-service-entry.ts'),
|
||||
join(OUT_DIR, 'session-scanner-service-entry.js')
|
||||
),
|
||||
buildForkedChild(
|
||||
join(ROOT, 'src/main/native-chat/wsl-transcript-fs-process-entry.ts'),
|
||||
join(OUT_DIR, 'wsl-transcript-fs-process-entry.js')
|
||||
)
|
||||
])
|
||||
|
||||
const result = await build({
|
||||
|
||||
@@ -116,6 +116,16 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
|
||||
// manifest check and be hashed into .version, shipping mixed-generation bytes.
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
await build({
|
||||
entryPoints: [join(ROOT, 'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'session-scanner-opencode-sqlite-worker-entry.js'),
|
||||
minify: true,
|
||||
logLevel: 'error'
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [RELAY_ENTRY],
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Agent-session search across hosts
|
||||
|
||||
```sh
|
||||
orca search --agent-session "key phrase"
|
||||
orca search --agent-session "key phrase" --host runtime:server
|
||||
orca search --agent-session "key phrase" --host ssh:target
|
||||
orca search --agent-session "key phrase" --environment server --host ssh:target
|
||||
orca search --agent-session "key phrase" --host all --json
|
||||
```
|
||||
|
||||
The default searches the addressed runtime. `all` includes that runtime, saved
|
||||
pairings on the CLI machine, and its already-connected direct SSH targets. When a
|
||||
remote environment or pairing is selected, `all` includes only that runtime and
|
||||
its connected direct SSH targets. It never recursively enumerates peers, connects
|
||||
SSH targets, deploys a relay, or enables indexing. Connection aliases are retained;
|
||||
identical paths or session IDs do not establish identical owners.
|
||||
|
||||
Results are grouped by host, with each host's ranking, query repairs, and coverage.
|
||||
`--limit` is per host (20 by default, at most 100). Five matches on each of two
|
||||
hosts return ten hits when budgets permit. Aggregate JSON includes each host's
|
||||
outcome and `partial`; a failed or excluded host is distinct from a successful
|
||||
query with no matches. The exit status is successful if any host completes its
|
||||
query, including a zero-match query. Interrupting returns status 130.
|
||||
|
||||
`--agent` and `--path` repeat. Paths are literal predicates evaluated on each
|
||||
execution host, with no implicit current-folder restriction. SSH/all require
|
||||
absolute host-native paths, including Windows drive/UNC paths. Remote/all do not
|
||||
expand `~`. A query's filters do not restrict indexing consent.
|
||||
|
||||
## Consent and index lifecycle
|
||||
|
||||
```sh
|
||||
orca search --enable --history-days 90 --host ssh:target
|
||||
orca search --index-status --host ssh:target
|
||||
orca search --pause --host ssh:target
|
||||
orca search --resume-indexing --host ssh:target
|
||||
orca search --disable --clear-index --host ssh:target
|
||||
```
|
||||
|
||||
Management always selects one host. Status cannot be combined with a query or
|
||||
mutation; contradictory flags and invalid filters are rejected before mutation.
|
||||
Clear with indexing enabled rebuilds the index. Disable plus clear removes it.
|
||||
Pause retains searchable data while stopping new indexing.
|
||||
|
||||
Paired runtimes use their existing settings, scanner child, and canonical
|
||||
`ai-vault-search/index.sqlite` under their data directory. Standalone `orcad`
|
||||
ships the same scanner and its sibling workers. Availability checks use an
|
||||
in-memory FTS5 probe, never transcript discovery. Node.js 22.13+ with `node:sqlite`
|
||||
and FTS5 is required; unsupported runtimes keep ordinary runtime operations and
|
||||
report search unavailable. Failed policy application is exposed as unapplied,
|
||||
and explicit configuration does not acknowledge success before application and
|
||||
the runtime's durable settings flush.
|
||||
|
||||
Plain SSH runs the existing search service inside the relay's existing low-priority
|
||||
scanner child. Its account-local state lives in `~/.orca/session-search-relay`,
|
||||
outside versioned relay install directories, separately from runtime profiles.
|
||||
Consent defaults off and is bound to the authenticated account's default source
|
||||
home. The scanner also includes the existing remote managed Codex home. Controller
|
||||
environment variables and arbitrary client-supplied discovery roots are not used.
|
||||
|
||||
A separate stable SQLite database holds an exclusive transaction while a relay
|
||||
owns the index. Other owners fail explicitly; SQLite releases the lock on process
|
||||
exit. Policy is atomically persisted under that lock. After five seconds the owner
|
||||
releases the index when its active requests and backfill pass finish. It does not
|
||||
abort discovery or parsing to hand off: doing so can permanently starve a slow
|
||||
source tree. Search, status and pause remain responsive through the same owner;
|
||||
a different relay generation receives an explicit busy error until the pass
|
||||
finishes or the current owner pauses. This deliberately favors a single complete
|
||||
pass over periodic teardown and rediscovery. Reacquisition rereads authoritative
|
||||
policy. Scanner retirement after ten idle minutes pauses work; a later search
|
||||
resumes it, skipping files already current in the durable index.
|
||||
|
||||
Returned files are checked on the execution host for local, paired and SSH search. Confirmed missing files are
|
||||
invalidated; unverifiable files are omitted with an explicit count. Raw resume
|
||||
commands are labeled with the execution host and working directory. Search does
|
||||
not add a cross-host resume/delete command or infer workspace identity from a
|
||||
path. Provider coverage remains that of the existing indexer, including its
|
||||
unindexed providers.
|
||||
OpenCode rows are checked individually, so deleting or archiving one session in a
|
||||
shared database does not remove the other sessions or leave the deleted hit visible.
|
||||
|
||||
## Routing, compatibility, and budgets
|
||||
|
||||
The controlling runtime forwards `aiVault.sshSearchSessions`,
|
||||
`aiVault.sshSearchIndexStatus`, and `aiVault.sshSearchConfigure` through its
|
||||
registered SSH provider's existing `requestHostRpc` and relay multiplexer.
|
||||
Distinct targeted methods ensure an older runtime rejects the request instead
|
||||
of stripping a target field and searching or clearing its own index. Existing
|
||||
runtime `executionHostId` remains a label. Aggregate search requires affirmative
|
||||
policy evidence; absent legacy policy is unknown and is excluded.
|
||||
|
||||
The CLI queries at most 16 routes with concurrency three, a 15-second host budget,
|
||||
a 30-second overall deadline, and a three-second SSH inventory budget. New hosts
|
||||
project replies to 512 KiB, snippets to 4 KiB, and the aggregate to 4 MiB, reporting
|
||||
omissions. Cancellation travels through the existing RPC, provider, relay and
|
||||
scanner cancellation paths. Already-sent mutations are not replayed after lost
|
||||
acknowledgement. Synchronous SQLite work cannot be interrupted mid-statement.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/cli/session-search-all-hosts.test.ts \
|
||||
src/main/runtime/runtime-ai-vault-ssh-search.test.ts \
|
||||
src/main/runtime/rpc/methods/ai-vault-search.test.ts \
|
||||
src/relay/session-search-owner.test.ts \
|
||||
src/relay/session-search-owner-process.test.ts \
|
||||
src/shared/ai-vault-search-projection.test.ts
|
||||
```
|
||||
|
||||
The fixtures cover owner-separated results with colliding IDs, unknown consent,
|
||||
stalled inventory, cancellation, mixed-version refusal, lease release, a real
|
||||
scanner-process crash, durable consent, clear, missing transcripts, and byte limits.
|
||||
Live validation also exercised the built CLI against an isolated authenticated
|
||||
paired `orcad`, and a registered provider through real authenticated Docker SSH,
|
||||
relay and scanner. The latter retained its relay PID and a test PTY through clear.
|
||||
Native Windows/WSL and rendered desktop/web selection UI require separate live
|
||||
coverage; this implementation adds the CLI surface.
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
stripAnsiEscapeSequences,
|
||||
TERMINAL_CONTROL_CHARACTER_PATTERN
|
||||
} from '../shared/ansi-escape-sequences'
|
||||
import { basename } from 'node:path'
|
||||
import type { AiVaultSearchIndexStatus } from '../shared/ai-vault-search-settings'
|
||||
import { aiVaultAgentLabel } from '../shared/ai-vault-types'
|
||||
import { aiVaultSearchUnindexedProviders } from '../shared/ai-vault-search-coverage'
|
||||
@@ -41,43 +40,54 @@ function relativeAge(iso: string | null, now = Date.now()): string {
|
||||
}
|
||||
|
||||
function projectLabel(hit: AiVaultSearchHit): string {
|
||||
const cwd = hit.cwd ? basename(hit.cwd) : '—'
|
||||
const cwd = hit.cwd ? (hit.cwd.replaceAll('\\', '/').split('/').findLast(Boolean) ?? '—') : '—'
|
||||
return hit.branch ? `${cwd} · ${hit.branch}` : cwd
|
||||
}
|
||||
|
||||
// Why: transcript text reaches the terminal verbatim; an OSC 52 or cursor
|
||||
// sequence inside a tool log would otherwise execute on the user's terminal.
|
||||
function terminalSafe(value: string): string {
|
||||
export function terminalSafe(value: string): string {
|
||||
return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
|
||||
}
|
||||
|
||||
function formatHit(index: number, hit: AiVaultSearchHit): string {
|
||||
function formatHit(index: number, hit: AiVaultSearchHit, owner?: string): string {
|
||||
const header = `${String(index + 1).padStart(2)}. ${terminalSafe(hit.title)}`
|
||||
const meta = `${aiVaultAgentLabel(hit.agent)} · ${terminalSafe(projectLabel(hit))} · ${relativeAge(hit.updatedAt)}`
|
||||
const evidence = hit.evidence.snippet
|
||||
? ` ${ROLE_LABEL[hit.evidence.role]} ▸ ${terminalSafe(hit.evidence.snippet).replaceAll('\n', ' ')}`
|
||||
: null
|
||||
const resume = ` resume: ${terminalSafe(hit.resumeCommand)}${hit.cwd ? ` (cwd ${terminalSafe(hit.cwd)})` : ''}`
|
||||
const resume = ` ${owner ? `run on ${terminalSafe(owner)}` : 'resume'}: ${terminalSafe(hit.resumeCommand)}${hit.cwd ? ` (cwd ${terminalSafe(hit.cwd)})` : ''}`
|
||||
return [`${header} ${meta}`, evidence, resume].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
export function formatAgentSessionSearch(
|
||||
result: AiVaultSearchResult,
|
||||
context: { query: string; cwd: string }
|
||||
context: { query: string; cwd: string; owner?: string }
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
if (result.sourceUnavailableFiles) {
|
||||
lines.push(
|
||||
`${result.sourceUnavailableFiles} source files could not be verified; their hits are omitted.`
|
||||
)
|
||||
}
|
||||
if (result.omittedHits) {
|
||||
lines.push(`${result.omittedHits} hits omitted by the response limit.`)
|
||||
}
|
||||
if (result.truncatedSnippets) {
|
||||
lines.push(`${result.truncatedSnippets} snippets shortened.`)
|
||||
}
|
||||
if (result.hits.length === 0) {
|
||||
lines.push(`No sessions match "${context.query}".`)
|
||||
lines.push(`No sessions match "${terminalSafe(context.query)}".`)
|
||||
} else {
|
||||
lines.push(...result.hits.map((hit, index) => formatHit(index, hit)), '')
|
||||
lines.push(...result.hits.map((hit, index) => formatHit(index, hit, context.owner)), '')
|
||||
}
|
||||
if (result.repairedTerms) {
|
||||
lines.push(`Searched for: ${result.repairedTerms.join(' ')}`)
|
||||
lines.push(`Searched for: ${terminalSafe(result.repairedTerms.join(' '))}`)
|
||||
}
|
||||
const { coverage } = result
|
||||
const scope = `${coverage.sessionsIndexed.toLocaleString()} sessions indexed`
|
||||
const pending =
|
||||
coverage.backfill === 'running'
|
||||
coverage.backfill !== 'complete'
|
||||
? ', still indexing older sessions'
|
||||
: coverage.filesPending > 0
|
||||
? `, ${coverage.filesPending} changed files pending`
|
||||
@@ -99,6 +109,18 @@ export function formatAgentSessionSearchEnabled(status: AiVaultSearchIndexStatus
|
||||
: `the last ${status.historyDays.toLocaleString()} days`
|
||||
return [
|
||||
`Session search is on for ${scope}.`,
|
||||
'Indexing runs in the background; searches answer from what is covered so far.'
|
||||
status.paused
|
||||
? 'Indexing is paused; existing data remains searchable.'
|
||||
: 'Indexing runs in the background; searches answer from what is covered so far.',
|
||||
`Index size: ${status.indexSizeBytes === null ? 'no index file' : `${status.indexSizeBytes} bytes`}.`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatAgentSessionSearchStatus(status: AiVaultSearchIndexStatus): string {
|
||||
if (status.available === false || status.applied === false) {
|
||||
return `Session search is unavailable: ${terminalSafe(status.reason ?? 'index policy is not applied')}. Saved policy: ${status.enabled ? 'on' : 'off'}.`
|
||||
}
|
||||
return status.enabled
|
||||
? formatAgentSessionSearchEnabled(status)
|
||||
: `Session search is off. Retention: ${status.historyDays === null ? 'all history' : `${status.historyDays} days`}. Index size: ${status.indexSizeBytes ?? 0} bytes.`
|
||||
}
|
||||
|
||||
+24
-5
@@ -24,9 +24,17 @@ export const BOOLEAN_FLAGS = CLI_BOOLEAN_FLAGS
|
||||
export const REPEATED_FLAG_SEPARATOR = '\u0000'
|
||||
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
|
||||
|
||||
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
|
||||
function setFlagValue(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string,
|
||||
value: string,
|
||||
search = false
|
||||
): void {
|
||||
const existing = flags.get(name)
|
||||
if (typeof existing === 'string' && REPEATABLE_STRING_FLAGS.has(name)) {
|
||||
if (
|
||||
typeof existing === 'string' &&
|
||||
(REPEATABLE_STRING_FLAGS.has(name) || (search && (name === 'agent' || name === 'path')))
|
||||
) {
|
||||
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
|
||||
return
|
||||
}
|
||||
@@ -51,12 +59,23 @@ export function parseArgs(argv: string[], commandPaths?: readonly string[][]): P
|
||||
// treats a `--`-leading next token as a new flag, so it can't express one.
|
||||
const equalsIndex = assignment.indexOf('=')
|
||||
if (equalsIndex !== -1) {
|
||||
setFlagValue(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
|
||||
setFlagValue(
|
||||
flags,
|
||||
assignment.slice(0, equalsIndex),
|
||||
assignment.slice(equalsIndex + 1),
|
||||
(argv[commandIndex] ?? commandPath[0]) === 'search'
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const flag = assignment
|
||||
if (BOOLEAN_FLAGS.has(flag)) {
|
||||
if (
|
||||
BOOLEAN_FLAGS.has(flag) ||
|
||||
((argv[commandIndex] ?? commandPath[0]) === 'search' &&
|
||||
['enable', 'disable', 'clear-index', 'index-status', 'pause', 'resume-indexing'].includes(
|
||||
flag
|
||||
))
|
||||
) {
|
||||
flags.set(flag, true)
|
||||
continue
|
||||
}
|
||||
@@ -71,7 +90,7 @@ export function parseArgs(argv: string[], commandPaths?: readonly string[][]): P
|
||||
flags.set(flag, true)
|
||||
continue
|
||||
}
|
||||
setFlagValue(flags, flag, next)
|
||||
setFlagValue(flags, flag, next, (argv[commandIndex] ?? commandPath[0]) === 'search')
|
||||
i += 1
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,11 @@ describe('orca search --agent-session', () => {
|
||||
it('sends the query with relevance sort by default', async () => {
|
||||
await runSearch({ 'agent-session': 'strict mode violation' })
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('aiVault.searchSessions', expect.any(Object))
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'aiVault.searchSessions',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: expect.any(Number) })
|
||||
)
|
||||
expect(searchParams()).toMatchObject({ query: 'strict mode violation', sort: 'relevance' })
|
||||
expect(searchParams().executionHostId).toBeUndefined()
|
||||
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||
@@ -157,14 +161,17 @@ describe('orca search --agent-session', () => {
|
||||
expect(searchParams().since).toBe('2026-07-31T22:00:00.000Z')
|
||||
})
|
||||
|
||||
it('rejects an ssh host because the index lives with the transcripts', async () => {
|
||||
const error = await runSearch({ 'agent-session': 'q', host: 'ssh:dev-box' }).catch(
|
||||
(caught: unknown) => caught
|
||||
it('resolves SSH labels and invokes only the targeted host method', async () => {
|
||||
callMock.mockResolvedValueOnce({
|
||||
result: { targets: [{ id: 'ssh-1', label: 'dev-box', connected: true }] }
|
||||
})
|
||||
await runSearch({ 'agent-session': 'q', host: 'ssh:dev-box' })
|
||||
expect(callMock).toHaveBeenLastCalledWith(
|
||||
'aiVault.sshSearchSessions',
|
||||
expect.objectContaining({ targetId: 'ssh-1', query: 'q' }),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect((error as RuntimeClientError).code).toBe('invalid_argument')
|
||||
expect((error as Error).message).toMatch(/runtime host/)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(callMock.mock.calls.map(([method]) => method)).not.toContain('aiVault.searchSessions')
|
||||
})
|
||||
|
||||
it('forwards a runtime host id', async () => {
|
||||
@@ -227,7 +234,11 @@ describe('orca search --agent-session with the index turned off', () => {
|
||||
|
||||
await runSearch({ enable: true })
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('aiVault.configureSessionSearch', { enabled: true })
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'aiVault.configureSessionSearch',
|
||||
{ enabled: true },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(logSpy.mock.calls[0]?.[0] as string).toContain(
|
||||
'Session search is on for the last 90 days.'
|
||||
)
|
||||
@@ -261,9 +272,13 @@ describe('orca search --agent-session with the index turned off', () => {
|
||||
|
||||
await runSearch({ enable: true, host: 'runtime:env-1' })
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('aiVault.configureSessionSearch', {
|
||||
enabled: true,
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'aiVault.configureSessionSearch',
|
||||
{
|
||||
enabled: true,
|
||||
executionHostId: 'runtime:env-1'
|
||||
},
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+133
-94
@@ -1,114 +1,153 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { printResult } from '../format'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRepeatedStringFlag
|
||||
} from '../flags'
|
||||
import { parseHostFlag } from '../execution-host-flag'
|
||||
import { RuntimeClientError } from '../runtime/types'
|
||||
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type { AiVaultSearchHit, AiVaultSearchResult } from '../../shared/ai-vault-search-types'
|
||||
import { parseSearchCommand } from '../search-command-arguments'
|
||||
import {
|
||||
formatAgentSessionSearch,
|
||||
formatAgentSessionSearchEnabled
|
||||
formatAgentSessionSearchStatus,
|
||||
terminalSafe
|
||||
} from '../agent-session-search-format'
|
||||
import { isAiVaultSearchDisabled } from '../../shared/ai-vault-search-coverage'
|
||||
import type { AiVaultSearchIndexStatus } from '../../shared/ai-vault-search-settings'
|
||||
|
||||
function parseAgents(flags: Map<string, string | boolean>): AiVaultAgent[] | undefined {
|
||||
const values = getRepeatedStringFlag(flags, 'agent')
|
||||
if (values.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const agents: AiVaultAgent[] = []
|
||||
for (const value of values) {
|
||||
const lowered = value.toLowerCase()
|
||||
if (!(AI_VAULT_AGENTS as readonly string[]).includes(lowered)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown --agent ${value}. Expected one of: ${AI_VAULT_AGENTS.join(', ')}.`
|
||||
)
|
||||
}
|
||||
agents.push(lowered as AiVaultAgent)
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
const ISO_8601 =
|
||||
/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
|
||||
|
||||
/** `~` and `~/x` are the home directory; `~other/x` is left for the host to resolve. */
|
||||
function expandHomePath(value: string): string {
|
||||
const home = process.env.HOME
|
||||
if (!home || (value !== '~' && !value.startsWith('~/'))) {
|
||||
return value
|
||||
}
|
||||
return `${home}${value.slice(1)}`
|
||||
}
|
||||
|
||||
function parseSince(flags: Map<string, string | boolean>): string | undefined {
|
||||
const value = getOptionalStringFlag(flags, 'since')
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
// Why: Date.parse also accepts `08/01/2026` and RFC 2822; the flag documents ISO 8601.
|
||||
const parsed = ISO_8601.test(value) ? Date.parse(value) : Number.NaN
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new RuntimeClientError('invalid_argument', '--since must be an ISO 8601 timestamp.')
|
||||
}
|
||||
return new Date(parsed).toISOString()
|
||||
}
|
||||
import {
|
||||
SessionSearchResultSchema,
|
||||
SessionSearchStatusSchema
|
||||
} from '../../shared/ai-vault-search-contract'
|
||||
import { listSshTargets, findSshTargetByName } from '../host-selector-alternatives'
|
||||
import { searchAllHosts } from '../session-search-all-hosts'
|
||||
import {
|
||||
searchHostMethod,
|
||||
SEARCH_ALL_TIMEOUT_MS,
|
||||
type SearchHost
|
||||
} from '../session-search-host-query'
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
|
||||
export const SEARCH_DISABLED_MESSAGE =
|
||||
'Session search is off. Enable it in Settings > Agent Session History, or run `orca search --agent-session --enable`.'
|
||||
|
||||
export const SEARCH_HANDLERS: Record<string, CommandHandler> = {
|
||||
search: async ({ client, flags, json, cwd }) => {
|
||||
const query = getOptionalStringFlag(flags, 'agent-session')
|
||||
const enable = flags.get('enable') === true
|
||||
const host = parseHostFlag(flags)
|
||||
if (host?.kind === 'ssh') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Agent session search runs on a runtime host. Use --host runtime:<environment> or omit --host.'
|
||||
)
|
||||
const command = parseSearchCommand(flags, client.isRemote)
|
||||
const controller = new AbortController()
|
||||
const deadline = Date.now() + SEARCH_ALL_TIMEOUT_MS
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Search deadline exceeded.')),
|
||||
SEARCH_ALL_TIMEOUT_MS
|
||||
)
|
||||
let interrupted = false
|
||||
const interrupt = (): void => {
|
||||
interrupted = true
|
||||
process.exitCode = 130
|
||||
controller.abort(new Error('Search interrupted.'))
|
||||
}
|
||||
const hostParams = host?.kind === 'runtime' ? { executionHostId: host.id } : {}
|
||||
if (enable) {
|
||||
const status = await client.call<AiVaultSearchIndexStatus>('aiVault.configureSessionSearch', {
|
||||
enabled: true,
|
||||
...hostParams
|
||||
})
|
||||
if (!query) {
|
||||
printResult(status, json, formatAgentSessionSearchEnabled)
|
||||
process.once('SIGINT', interrupt)
|
||||
const options = (): { signal: AbortSignal; timeoutMs: number } => ({
|
||||
signal: controller.signal,
|
||||
timeoutMs: Math.max(1, deadline - Date.now())
|
||||
})
|
||||
try {
|
||||
if (command.host === 'all') {
|
||||
const result = await searchAllHosts(client, command, controller.signal, deadline)
|
||||
printResult(
|
||||
{ id: 'search-all', ok: true, result, _meta: { runtimeId: 'client' } },
|
||||
json,
|
||||
(value) =>
|
||||
value.hosts
|
||||
.map((entry) =>
|
||||
[
|
||||
`${terminalSafe(entry.host.name)} (${terminalSafe(entry.host.selector || 'current runtime')}) — ${entry.outcome}`,
|
||||
entry.result
|
||||
? formatAgentSessionSearch(entry.result, {
|
||||
query: command.query!.query,
|
||||
cwd,
|
||||
owner: entry.host.name
|
||||
})
|
||||
: terminalSafe(entry.message ?? '')
|
||||
].join('\n')
|
||||
)
|
||||
.join('\n\n') +
|
||||
(value.omittedHosts ? `\n${value.omittedHosts} additional hosts omitted.` : '')
|
||||
)
|
||||
if (!result.hosts.some((host) => host.outcome === 'searched') && process.exitCode !== 130) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!query) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Missing --agent-session <query>. Example: orca search --agent-session "strict mode violation"'
|
||||
const host: SearchHost = {
|
||||
id: command.host?.id ?? 'local',
|
||||
name: command.host?.id ?? (client.isRemote ? 'selected runtime' : 'this runtime'),
|
||||
selector: '',
|
||||
client
|
||||
}
|
||||
if (command.host?.kind === 'ssh') {
|
||||
const targets = await listSshTargets(client, {
|
||||
strict: true,
|
||||
signal: controller.signal,
|
||||
deadline
|
||||
})
|
||||
const target = findSshTargetByName(targets, command.host.targetId)
|
||||
if (!target) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Unknown or ambiguous SSH target.')
|
||||
}
|
||||
if (target.connected !== true) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'SSH target is not known to be connected.'
|
||||
)
|
||||
}
|
||||
host.targetId = target.id
|
||||
host.name = target.label
|
||||
}
|
||||
const target = host.targetId
|
||||
? { targetId: host.targetId }
|
||||
: command.host?.kind === 'runtime'
|
||||
? { executionHostId: command.host.id }
|
||||
: {}
|
||||
const call = (operation: 'query' | 'status' | 'configure', params: object) =>
|
||||
waitForPromiseWithSignal(
|
||||
client.call(searchHostMethod(host, operation), { ...params, ...target }, options()),
|
||||
controller.signal
|
||||
)
|
||||
if (command.configure || command.status) {
|
||||
const response = await call(
|
||||
command.configure ? 'configure' : 'status',
|
||||
command.configure ?? {}
|
||||
)
|
||||
const status = SessionSearchStatusSchema.parse(response.result)
|
||||
if (command.configure && (status.available === false || status.applied === false)) {
|
||||
throw new RuntimeClientError(
|
||||
'failed_precondition',
|
||||
status.reason ?? 'Search policy is unavailable or not applied.'
|
||||
)
|
||||
}
|
||||
if (!command.query) {
|
||||
printResult({ ...response, result: status }, json, formatAgentSessionSearchStatus)
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = await call('query', command.query!)
|
||||
const result = SessionSearchResultSchema.parse(response.result)
|
||||
if (result.coverage.enabled === false) {
|
||||
throw new RuntimeClientError('failed_precondition', SEARCH_DISABLED_MESSAGE, {
|
||||
disabled: true
|
||||
})
|
||||
}
|
||||
printResult({ ...response, result }, json, (value) =>
|
||||
formatAgentSessionSearch(value, {
|
||||
query: command.query!.query,
|
||||
cwd,
|
||||
...(host.targetId || client.isRemote || host.id.startsWith('runtime:')
|
||||
? { owner: host.name }
|
||||
: {})
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if (!interrupted) {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
process.removeListener('SIGINT', interrupt)
|
||||
}
|
||||
const scopePaths = getRepeatedStringFlag(flags, 'path').map(expandHomePath)
|
||||
const result = await client.call<AiVaultSearchResult>('aiVault.searchSessions', {
|
||||
query,
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
|
||||
agents: parseAgents(flags),
|
||||
scopePaths: scopePaths.length > 0 ? scopePaths : undefined,
|
||||
since: parseSince(flags),
|
||||
sort: flags.get('newest') === true ? 'newest' : 'relevance',
|
||||
...hostParams
|
||||
})
|
||||
if (isAiVaultSearchDisabled(result.result.coverage)) {
|
||||
throw new RuntimeClientError('failed_precondition', SEARCH_DISABLED_MESSAGE, {
|
||||
disabled: true,
|
||||
nextSteps: ['orca search --agent-session --enable']
|
||||
})
|
||||
}
|
||||
printResult(result, json, (value) => formatAgentSessionSearch(value, { query, cwd }))
|
||||
}
|
||||
}
|
||||
|
||||
export type { AiVaultSearchHit }
|
||||
export type { AiVaultSearchHit } from '../../shared/ai-vault-search-types'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { mapWithConcurrency } from '../shared/map-with-concurrency'
|
||||
import { z } from 'zod'
|
||||
|
||||
export type SshTargetSummary = {
|
||||
id: string
|
||||
@@ -101,52 +103,111 @@ export function crossKindNextSteps(
|
||||
|
||||
// Why: only display identity crosses this boundary — the RPC deliberately withholds addresses
|
||||
// and credentials — and an enumeration failure must never mask the error we are explaining.
|
||||
export async function listSshTargets(client: RuntimeClient): Promise<SshTargetSummary[]> {
|
||||
export type SshInventoryOptions = { strict?: boolean; signal?: AbortSignal; deadline?: number }
|
||||
|
||||
function inventoryCallOptions(options?: SshInventoryOptions): {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
} {
|
||||
if (options?.signal?.aborted) {
|
||||
throw options.signal.reason
|
||||
}
|
||||
const remaining = options?.deadline === undefined ? undefined : options.deadline - Date.now()
|
||||
if (remaining !== undefined && remaining <= 0) {
|
||||
throw new Error('SSH inventory deadline exceeded.')
|
||||
}
|
||||
return { signal: options?.signal, timeoutMs: remaining }
|
||||
}
|
||||
|
||||
const SshTargetSummariesSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1).max(512),
|
||||
label: z.string().max(512),
|
||||
connected: z.boolean().optional(),
|
||||
connectionStatus: z.string().max(128).optional(),
|
||||
remotePlatform: z.enum(['linux', 'darwin', 'win32']).optional()
|
||||
})
|
||||
)
|
||||
.max(4096)
|
||||
|
||||
export async function listSshTargets(
|
||||
client: RuntimeClient,
|
||||
options?: SshInventoryOptions
|
||||
): Promise<SshTargetSummary[]> {
|
||||
try {
|
||||
const result = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')
|
||||
return result.result.targets
|
||||
const result = await client.call<{ targets: SshTargetSummary[] }>(
|
||||
'ssh.listTargetSummaries',
|
||||
...(options ? ([undefined, inventoryCallOptions(options)] as const) : [])
|
||||
)
|
||||
return options?.strict
|
||||
? SshTargetSummariesSchema.parse(result.result.targets)
|
||||
: result.result.targets
|
||||
} catch (error) {
|
||||
// Why: hosts predating listTargetSummaries still answer listTargets, and both are served by
|
||||
// the same summariser. Without this an old host looks like one with no SSH targets at all,
|
||||
// which would reject a target id that is actually valid there.
|
||||
if (error instanceof Error && 'code' in error && error.code === 'method_not_found') {
|
||||
try {
|
||||
const legacy = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargets')
|
||||
return await enrichLegacySshTargetStates(client, legacy.result.targets)
|
||||
} catch {
|
||||
const legacy = await client.call<{ targets: SshTargetSummary[] }>(
|
||||
'ssh.listTargets',
|
||||
...(options ? ([undefined, inventoryCallOptions(options)] as const) : [])
|
||||
)
|
||||
return await enrichLegacySshTargetStates(
|
||||
client,
|
||||
options?.strict
|
||||
? SshTargetSummariesSchema.parse(legacy.result.targets)
|
||||
: legacy.result.targets,
|
||||
options
|
||||
)
|
||||
} catch (legacyError) {
|
||||
if (options?.strict) {
|
||||
throw legacyError
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
if (options?.strict) {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichLegacySshTargetStates(
|
||||
client: RuntimeClient,
|
||||
targets: SshTargetSummary[]
|
||||
targets: SshTargetSummary[],
|
||||
options?: SshInventoryOptions
|
||||
): Promise<SshTargetSummary[]> {
|
||||
return Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const response = await client.call<{
|
||||
state: {
|
||||
status?: string
|
||||
remotePlatform?: 'linux' | 'darwin' | 'win32'
|
||||
} | null
|
||||
}>('ssh.getState', { targetId: target.id })
|
||||
const state = response.result.state
|
||||
return {
|
||||
...target,
|
||||
...(state?.status === undefined
|
||||
? {}
|
||||
: { connected: state.status === 'connected', connectionStatus: state.status }),
|
||||
...(state?.remotePlatform === undefined ? {} : { remotePlatform: state.remotePlatform })
|
||||
}
|
||||
} catch {
|
||||
return target
|
||||
return mapWithConcurrency(targets, 3, async (target) => {
|
||||
if (
|
||||
options?.signal?.aborted ||
|
||||
(options?.deadline !== undefined && Date.now() >= options.deadline)
|
||||
) {
|
||||
return { ...target, connected: undefined, connectionStatus: 'unknown' }
|
||||
}
|
||||
try {
|
||||
const response = await client.call<{
|
||||
state: { status?: string; remotePlatform?: 'linux' | 'darwin' | 'win32' } | null
|
||||
}>(
|
||||
'ssh.getState',
|
||||
{ targetId: target.id },
|
||||
...(options ? ([inventoryCallOptions(options)] as const) : [])
|
||||
)
|
||||
const state = response.result.state
|
||||
return {
|
||||
...target,
|
||||
...(state?.status !== undefined
|
||||
? { connected: state.status === 'connected', connectionStatus: state.status }
|
||||
: options?.strict
|
||||
? { connected: undefined, connectionStatus: 'unknown' }
|
||||
: {}),
|
||||
...(state?.remotePlatform ? { remotePlatform: state.remotePlatform } : {})
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return { ...target, connected: undefined, connectionStatus: 'unknown' }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: `--host ssh:<id>` was never validated, so an unknown target answered ok:true with an
|
||||
|
||||
+16
-14
@@ -126,20 +126,22 @@ export async function main(
|
||||
// retargeting a mutation to another server is the bug this flag already had.
|
||||
// An ambient pairing code cannot be resolved to an id to compare, so the
|
||||
// explicit flag simply wins there.
|
||||
const hostEnvironmentId = ignoreRemoteSelection
|
||||
? null
|
||||
: await resolveHostFlagEnvironmentId(parsed.flags, {
|
||||
// Why: only consulted when the name missed, and against this machine's own runtime —
|
||||
// SSH targets are registered there, not in the paired server we failed to find.
|
||||
listSshTargets: listSshTargetsForSuggestion,
|
||||
pairingCode: typeof pairingCode === 'string' ? pairingCode : null,
|
||||
environmentSelector:
|
||||
typeof environmentSelector === 'string'
|
||||
? { value: environmentSelector, label: '--environment' }
|
||||
: process.env.ORCA_ENVIRONMENT
|
||||
? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' }
|
||||
: null
|
||||
})
|
||||
const hostEnvironmentId =
|
||||
ignoreRemoteSelection ||
|
||||
(parsed.commandPath[0] === 'search' && parsed.flags.get('host') === 'all')
|
||||
? null
|
||||
: await resolveHostFlagEnvironmentId(parsed.flags, {
|
||||
// Why: only consulted when the name missed, and against this machine's own runtime —
|
||||
// SSH targets are registered there, not in the paired server we failed to find.
|
||||
listSshTargets: listSshTargetsForSuggestion,
|
||||
pairingCode: typeof pairingCode === 'string' ? pairingCode : null,
|
||||
environmentSelector:
|
||||
typeof environmentSelector === 'string'
|
||||
? { value: environmentSelector, label: '--environment' }
|
||||
: process.env.ORCA_ENVIRONMENT
|
||||
? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' }
|
||||
: null
|
||||
})
|
||||
// Why: --host runtime:<name> is canonicalized to the environment's id so downstream host-id
|
||||
// comparisons against stored rows still match; rewrite the flag once, here, rather than
|
||||
// resolving the name again at every consumer.
|
||||
|
||||
@@ -81,11 +81,16 @@ export class RuntimeClient {
|
||||
return this.remotePairing !== null
|
||||
}
|
||||
|
||||
get selectedEnvironment(): string | null {
|
||||
return this.environmentSelector
|
||||
}
|
||||
|
||||
async call<TResult>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: {
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
legacyTerminalPrompt?: true
|
||||
terminalPromptPreflight?: { runtimeId: string | null }
|
||||
} & RuntimeOrchestrationEnvelope
|
||||
@@ -146,7 +151,8 @@ export class RuntimeClient {
|
||||
method,
|
||||
params,
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
envelope
|
||||
envelope,
|
||||
...(options?.signal ? { signal: options.signal } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
throw recover(error, null)
|
||||
@@ -164,7 +170,14 @@ export class RuntimeClient {
|
||||
const metadata = readMetadata(this.userDataPath)
|
||||
let response
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
response = await sendRequest<TResult>(
|
||||
metadata,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
envelope,
|
||||
...(options?.signal ? [options.signal] : [])
|
||||
)
|
||||
} catch (error) {
|
||||
throw recover(error, metadata.runtimeId ?? null)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export class RemoteRuntimeCompatGate {
|
||||
params: unknown
|
||||
timeoutMs: number
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
signal?: AbortSignal
|
||||
}): Promise<RuntimeRpcResponse<TResult>> {
|
||||
if (this.checked || args.method === 'status.get') {
|
||||
return args.transport.sendWebSocketRequest<TResult>(
|
||||
@@ -40,7 +41,8 @@ export class RemoteRuntimeCompatGate {
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
args.envelope
|
||||
args.envelope,
|
||||
...(args.signal ? [args.signal] : [])
|
||||
)
|
||||
}
|
||||
return args.transport.sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
@@ -59,7 +61,8 @@ export class RemoteRuntimeCompatGate {
|
||||
})
|
||||
}
|
||||
},
|
||||
args.envelope
|
||||
args.envelope,
|
||||
...(args.signal ? [args.signal] : [])
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type Socket } from 'node:net'
|
||||
@@ -50,6 +50,78 @@ describe('runtime transport timeout validation', () => {
|
||||
// Why: these tests create Unix domain socket servers in temp directories.
|
||||
// Windows does not support Unix domain sockets in the same way.
|
||||
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
|
||||
it('cancellation closes a dispatched search socket instead of only discarding its response', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-search-cancel-'))
|
||||
const endpoint = join(directory, 'runtime.sock')
|
||||
const controller = new AbortController()
|
||||
let closed!: () => void
|
||||
const socketClosed = new Promise<void>((resolve) => {
|
||||
closed = resolve
|
||||
})
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => {
|
||||
sockets.delete(socket)
|
||||
closed()
|
||||
})
|
||||
socket.once('data', () => controller.abort(new Error('search cancelled')))
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
try {
|
||||
await expect(
|
||||
sendRequest(
|
||||
{
|
||||
runtimeId: 'test',
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'fixture',
|
||||
startedAt: 1
|
||||
},
|
||||
'aiVault.searchSessions',
|
||||
{ query: 'fixture' },
|
||||
30_000,
|
||||
undefined,
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toThrow('search cancelled')
|
||||
await socketClosed
|
||||
expect(sockets.size).toBe(0)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an oversized search frame before buffering the complete response', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-search-size-'))
|
||||
const endpoint = join(directory, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.on('error', () => undefined)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', () => socket.write(Buffer.alloc(4 * 1024 * 1024 + 1, 'a')))
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
try {
|
||||
await expect(
|
||||
sendRequest(
|
||||
{
|
||||
runtimeId: 'test',
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'fixture',
|
||||
startedAt: 1
|
||||
},
|
||||
'aiVault.searchSessions',
|
||||
{ query: 'fixture' },
|
||||
30_000
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'invalid_runtime_response' })
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { abortSignalReason, throwIfSignalAborted } from '../../shared/abort-signal-reason'
|
||||
import { createConnection } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
@@ -11,8 +12,10 @@ export async function sendRequest<TResult>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
throwIfSignalAborted(signal)
|
||||
if (!isSafeTimerDelayMs(timeoutMs)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
@@ -32,6 +35,9 @@ export async function sendRequest<TResult>(
|
||||
}
|
||||
const socket = createConnection(transport.endpoint)
|
||||
let lineSegments: string[] = []
|
||||
let lineBytes = 0
|
||||
const searchResponseLimit =
|
||||
method.startsWith('aiVault.') && /search/i.test(method) ? 4 * 1024 * 1024 : Infinity
|
||||
let settled = false
|
||||
const requestId = randomUUID()
|
||||
|
||||
@@ -40,6 +46,7 @@ export async function sendRequest<TResult>(
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
lineSegments = []
|
||||
socket.destroy()
|
||||
reject(
|
||||
@@ -57,6 +64,7 @@ export async function sendRequest<TResult>(
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
lineSegments = []
|
||||
clearTimeout(timeout)
|
||||
socket.end()
|
||||
@@ -67,6 +75,15 @@ export async function sendRequest<TResult>(
|
||||
}
|
||||
}
|
||||
|
||||
const onAbort = (): void => {
|
||||
finish({ ok: false, error: abortSignalReason(signal!) })
|
||||
socket.destroy()
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal?.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', () => {
|
||||
finish({
|
||||
@@ -99,6 +116,20 @@ export async function sendRequest<TResult>(
|
||||
let cursor = 0
|
||||
while (cursor < chunk.length && !settled) {
|
||||
const newlineIndex = chunk.indexOf('\n', cursor)
|
||||
lineBytes += Buffer.byteLength(
|
||||
chunk.slice(cursor, newlineIndex === -1 ? undefined : newlineIndex)
|
||||
)
|
||||
if (lineBytes > searchResponseLimit) {
|
||||
finish({
|
||||
ok: false,
|
||||
error: new RuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Search response exceeds the size limit.'
|
||||
)
|
||||
})
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
if (newlineIndex === -1) {
|
||||
lineSegments.push(chunk.slice(cursor))
|
||||
return
|
||||
@@ -111,6 +142,7 @@ export async function sendRequest<TResult>(
|
||||
lineSegments = []
|
||||
}
|
||||
cursor = newlineIndex + 1
|
||||
lineBytes = 0
|
||||
if (line.trim().length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -13,10 +13,18 @@ export async function sendWebSocketRequest<TResult>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
try {
|
||||
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs, envelope)
|
||||
return await sendRemoteRuntimeRequest<TResult>(
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
envelope,
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message, error.data)
|
||||
@@ -31,7 +39,8 @@ export async function sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
validateStatus: (response: RuntimeRpcResponse<RuntimeStatus>) => void,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
try {
|
||||
return await sendRemoteRuntimeRequestWithStatusPreflight<TResult>(
|
||||
@@ -40,7 +49,9 @@ export async function sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
params,
|
||||
timeoutMs,
|
||||
validateStatus,
|
||||
envelope
|
||||
envelope,
|
||||
[],
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { parseArgs, REPEATED_FLAG_SEPARATOR } from './args'
|
||||
import { parseSearchCommand } from './search-command-arguments'
|
||||
|
||||
it('preserves repeated filters through argv and validates before configuration', () => {
|
||||
const parsed = parseArgs([
|
||||
'search',
|
||||
'--agent-session',
|
||||
'needle',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--agent',
|
||||
'claude',
|
||||
'--path',
|
||||
'/one',
|
||||
'--path',
|
||||
'/two'
|
||||
])
|
||||
expect(parsed.flags.get('agent')).toBe(`codex${REPEATED_FLAG_SEPARATOR}claude`)
|
||||
expect(parseSearchCommand(parsed.flags).query).toMatchObject({
|
||||
agents: ['codex', 'claude'],
|
||||
scopePaths: ['/one', '/two']
|
||||
})
|
||||
expect(() =>
|
||||
parseSearchCommand(
|
||||
new Map<string, string | boolean>([
|
||||
['enable', true],
|
||||
['agent-session', 'needle'],
|
||||
['limit', '101']
|
||||
])
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('accepts queryless policy management and refuses aggregate mutations', () => {
|
||||
expect(
|
||||
parseSearchCommand(parseArgs(['search', '--agent-session', '--enable']).flags).configure
|
||||
).toEqual({ enabled: true })
|
||||
expect(
|
||||
parseSearchCommand(
|
||||
parseArgs(['search', '--disable', '--clear-index', '--host', 'ssh:box']).flags
|
||||
).configure
|
||||
).toEqual({ enabled: false, clearIndex: true })
|
||||
expect(() =>
|
||||
parseSearchCommand(parseArgs(['search', '--enable', '--host', 'all']).flags)
|
||||
).toThrow()
|
||||
expect(() => parseSearchCommand(parseArgs(['search', '--enable', '--disable']).flags)).toThrow()
|
||||
})
|
||||
|
||||
it('handles command discovery, equals syntax, Windows paths and host-specific scope rules', () => {
|
||||
const parsed = parseArgs(
|
||||
[
|
||||
'--json',
|
||||
'search',
|
||||
'--agent-session=needle',
|
||||
'--agent=codex',
|
||||
'--agent=claude',
|
||||
'--path=C:\\work',
|
||||
'--path=\\\\server\\share',
|
||||
'--host=all'
|
||||
],
|
||||
[['search']]
|
||||
)
|
||||
expect(parseSearchCommand(parsed.flags).query).toMatchObject({
|
||||
agents: ['codex', 'claude'],
|
||||
scopePaths: ['C:\\work', '\\\\server\\share']
|
||||
})
|
||||
for (const args of [
|
||||
['--enable', '--path=/somewhere'],
|
||||
['--enable', '--agent-session=needle', '--newest=false'],
|
||||
['--agent-session=needle', '--host=ssh:box', '--path=relative'],
|
||||
['--agent-session=needle', '--host=all', '--path=~/private'],
|
||||
['--index-status', '--agent-session=needle']
|
||||
]) {
|
||||
expect(() => parseSearchCommand(parseArgs(['search', ...args], [['search']]).flags)).toThrow()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../shared/ai-vault-types'
|
||||
import {
|
||||
SessionSearchConfigureSchema,
|
||||
SessionSearchQuerySchema,
|
||||
type SessionSearchConfigure
|
||||
} from '../shared/ai-vault-search-contract'
|
||||
import type { AiVaultSearchArgs } from '../shared/ai-vault-search-types'
|
||||
import { isRuntimePathAbsolute } from '../shared/cross-platform-path'
|
||||
import { parseHostFlag } from './execution-host-flag'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRepeatedStringFlag
|
||||
} from './flags'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
|
||||
export type SearchCommand = {
|
||||
query?: AiVaultSearchArgs
|
||||
configure?: SessionSearchConfigure
|
||||
status: boolean
|
||||
host: ReturnType<typeof parseHostFlag> | 'all'
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new RuntimeClientError('invalid_argument', message)
|
||||
}
|
||||
|
||||
export function parseSearchCommand(
|
||||
flags: Map<string, string | boolean>,
|
||||
remote = false
|
||||
): SearchCommand {
|
||||
const host = flags.get('host') === 'all' ? 'all' : parseHostFlag(flags)
|
||||
const configure: SessionSearchConfigure = {}
|
||||
for (const flag of [
|
||||
'enable',
|
||||
'disable',
|
||||
'clear-index',
|
||||
'index-status',
|
||||
'pause',
|
||||
'resume-indexing',
|
||||
'newest'
|
||||
]) {
|
||||
if (flags.has(flag) && flags.get(flag) !== true) {
|
||||
invalid(`--${flag} does not take a value.`)
|
||||
}
|
||||
}
|
||||
if (flags.get('enable') && flags.get('disable')) {
|
||||
invalid('Use either --enable or --disable.')
|
||||
}
|
||||
if (flags.get('pause') && flags.get('resume-indexing')) {
|
||||
invalid('Use either --pause or --resume-indexing.')
|
||||
}
|
||||
if (flags.get('enable')) {
|
||||
configure.enabled = true
|
||||
}
|
||||
if (flags.get('disable')) {
|
||||
configure.enabled = false
|
||||
}
|
||||
if (flags.get('pause')) {
|
||||
configure.paused = true
|
||||
}
|
||||
if (flags.get('resume-indexing')) {
|
||||
configure.paused = false
|
||||
}
|
||||
if (flags.get('clear-index')) {
|
||||
configure.clearIndex = true
|
||||
}
|
||||
const history = getOptionalStringFlag(flags, 'history-days')
|
||||
if (history !== undefined) {
|
||||
configure.historyDays = history === 'all' ? null : Number(history)
|
||||
}
|
||||
const config = SessionSearchConfigureSchema.safeParse(configure)
|
||||
if (!config.success) {
|
||||
invalid('Invalid search policy: history-days must be 1..3650 or all.')
|
||||
}
|
||||
const mutation = Object.keys(configure).length > 0
|
||||
const status = flags.get('index-status') === true
|
||||
if (host === 'all' && (mutation || status)) {
|
||||
invalid('Select one host to manage its search index.')
|
||||
}
|
||||
const raw = flags.get('agent-session')
|
||||
const query = raw === true && mutation ? undefined : getOptionalStringFlag(flags, 'agent-session')
|
||||
if (status && (mutation || query)) {
|
||||
invalid('Use --index-status without a query or policy change.')
|
||||
}
|
||||
if (!query && !mutation && !status) {
|
||||
invalid('Missing --agent-session <query>.')
|
||||
}
|
||||
if (!query && ['agent', 'path', 'since', 'limit', 'newest'].some((flag) => flags.has(flag))) {
|
||||
invalid(
|
||||
'Search filters require --agent-session <query>; they do not restrict which sources are indexed.'
|
||||
)
|
||||
}
|
||||
const agents = getRepeatedStringFlag(flags, 'agent').map((value) => {
|
||||
const agent = value.toLowerCase() as AiVaultAgent
|
||||
if (!AI_VAULT_AGENTS.includes(agent)) {
|
||||
invalid(`Unknown --agent ${value}.`)
|
||||
}
|
||||
return agent
|
||||
})
|
||||
const paths = getRepeatedStringFlag(flags, 'path').map((path) => {
|
||||
if (
|
||||
path.startsWith('~') &&
|
||||
(remote || host === 'all' || host?.kind === 'ssh' || host?.kind === 'runtime')
|
||||
) {
|
||||
invalid('Use an absolute path on the execution host instead of ~.')
|
||||
}
|
||||
const expanded =
|
||||
path === '~' ? homedir() : path.startsWith('~/') ? join(homedir(), path.slice(2)) : path
|
||||
if ((host === 'all' || host?.kind === 'ssh') && !isRuntimePathAbsolute(expanded)) {
|
||||
invalid('--path must be absolute on the execution host.')
|
||||
}
|
||||
return expanded
|
||||
})
|
||||
const since = getOptionalStringFlag(flags, 'since')
|
||||
if (
|
||||
since &&
|
||||
(!/^\d{4}-\d{2}-\d{2}(?:[T ].*)?$/.test(since) || !Number.isFinite(Date.parse(since)))
|
||||
) {
|
||||
invalid('--since must be an ISO 8601 timestamp.')
|
||||
}
|
||||
const args = {
|
||||
query: query ?? 'policy validation',
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
|
||||
agents: agents.length ? [...new Set(agents)] : undefined,
|
||||
scopePaths: paths.length ? paths : undefined,
|
||||
since: since ? new Date(since).toISOString() : undefined,
|
||||
sort: flags.get('newest') === true ? 'newest' : 'relevance'
|
||||
}
|
||||
const validated = SessionSearchQuerySchema.safeParse(args)
|
||||
if (!validated.success) {
|
||||
invalid(validated.error.issues[0]?.message ?? 'Invalid search query.')
|
||||
}
|
||||
return {
|
||||
host,
|
||||
status,
|
||||
configure: mutation ? configure : undefined,
|
||||
query: query ? validated.data : undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
const call = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./runtime-client', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
RuntimeClient: class {
|
||||
isRemote = false
|
||||
call = call
|
||||
}
|
||||
}))
|
||||
|
||||
import { main } from './index'
|
||||
|
||||
it('preserves Ctrl-C exit status through the single-host CLI entrypoint', async () => {
|
||||
const previous = process.exitCode
|
||||
const listeners = process.listenerCount('SIGINT')
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
call.mockImplementation(() => {
|
||||
queueMicrotask(() => process.emit('SIGINT'))
|
||||
return new Promise(() => {})
|
||||
})
|
||||
try {
|
||||
process.exitCode = 0
|
||||
await main(['search', '--agent-session', 'fixture', '--json'])
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
expect(process.exitCode).toBe(130)
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners)
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.exitCode = previous
|
||||
log.mockRestore()
|
||||
error.mockRestore()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { searchAllHosts } from './session-search-all-hosts'
|
||||
import { querySearchHost } from './session-search-host-query'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import type { AiVaultSearchResult } from '../shared/ai-vault-search-types'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
const query = { host: 'all' as const, status: false, query: { query: 'shared phrase' } }
|
||||
const status = { enabled: true, historyDays: null, indexSizeBytes: 42 }
|
||||
|
||||
it('distinguishes a missing SSH route from unknown legacy runtime policy without querying either', async () => {
|
||||
const legacy = client('legacy')
|
||||
vi.mocked(legacy.call).mockRejectedValue(
|
||||
Object.assign(new Error('Method not found'), { code: 'method_not_found' })
|
||||
)
|
||||
for (const targetId of [undefined, 'ssh-one']) {
|
||||
const value = await querySearchHost(
|
||||
{ id: 'legacy', name: 'Legacy', selector: 'legacy', client: legacy, targetId },
|
||||
query,
|
||||
true,
|
||||
new AbortController().signal
|
||||
)
|
||||
expect(value.outcome).toBe(targetId ? 'unsupported' : 'policy-unknown')
|
||||
}
|
||||
expect(vi.mocked(legacy.call).mock.calls.map(([method]) => method)).toEqual([
|
||||
'aiVault.searchIndexStatus',
|
||||
'aiVault.sshSearchIndexStatus'
|
||||
])
|
||||
})
|
||||
|
||||
function result(owner: string): AiVaultSearchResult {
|
||||
return {
|
||||
route: 'phrase',
|
||||
durationMs: 1,
|
||||
coverage: {
|
||||
enabled: true,
|
||||
sessionsIndexed: 5,
|
||||
messagesIndexed: 5,
|
||||
providers: [],
|
||||
backfill: 'complete',
|
||||
filesPending: 0,
|
||||
lastIndexedAt: null
|
||||
},
|
||||
hits: Array.from({ length: 5 }, (_, i) => ({
|
||||
agent: 'claude',
|
||||
sessionId: String(i),
|
||||
title: owner,
|
||||
filePath: `/same/${i}.jsonl`,
|
||||
codexHome: null,
|
||||
cwd: '/same',
|
||||
branch: null,
|
||||
updatedAt: null,
|
||||
messageCount: 1,
|
||||
resumeCommand: `claude --resume ${i}`,
|
||||
score: i,
|
||||
evidence: { role: 'user', timestamp: null, snippet: 'shared phrase' }
|
||||
}))
|
||||
}
|
||||
}
|
||||
function client(owner: string, remote = false) {
|
||||
return {
|
||||
isRemote: remote,
|
||||
call: vi.fn(async (method: string) => ({
|
||||
result: method.endsWith('Status') ? status : result(owner)
|
||||
}))
|
||||
} as unknown as RuntimeClient
|
||||
}
|
||||
|
||||
it('queries at most sixteen routes with no more than three simultaneous host legs', async () => {
|
||||
vi.useFakeTimers()
|
||||
let active = 0,
|
||||
maximum = 0,
|
||||
calls = 0
|
||||
let release!: () => void
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const local = client('owner')
|
||||
vi.mocked(local.call).mockImplementation(async (method) => {
|
||||
if (method.endsWith('Status')) {
|
||||
return { result: status } as never
|
||||
}
|
||||
active++
|
||||
calls++
|
||||
maximum = Math.max(maximum, active)
|
||||
await barrier
|
||||
active--
|
||||
return { result: result('owner') } as never
|
||||
})
|
||||
const createClient = vi.fn(() => local)
|
||||
const pending = searchAllHosts(local, query, new AbortController().signal, Date.now() + 30_000, {
|
||||
listEnvironments: () =>
|
||||
Array.from({ length: 20 }, (_, i) => ({ id: `env-${i}`, name: `Host ${i}` })),
|
||||
listSshTargets: async () => [],
|
||||
createClient
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(active).toBe(3)
|
||||
release()
|
||||
const value = await pending
|
||||
expect(maximum).toBe(3)
|
||||
expect(calls).toBe(16)
|
||||
expect(createClient).toHaveBeenCalledTimes(15)
|
||||
expect(value.omittedHosts).toBe(5)
|
||||
})
|
||||
|
||||
it('returns five plus five in separate owner groups despite identical session IDs and paths', async () => {
|
||||
const local = client('local')
|
||||
const ssh = { id: 'ssh-one', label: 'ssh-one', connected: true }
|
||||
const value = await searchAllHosts(
|
||||
local,
|
||||
query,
|
||||
new AbortController().signal,
|
||||
Date.now() + 30_000,
|
||||
{
|
||||
listEnvironments: () => [],
|
||||
createClient: () => {
|
||||
throw new Error('unexpected pairing')
|
||||
},
|
||||
listSshTargets: async () => [ssh]
|
||||
}
|
||||
)
|
||||
expect(value.hosts.map((host) => [host.host.id, host.outcome, host.result?.hits.length])).toEqual(
|
||||
[
|
||||
['local', 'searched', 5],
|
||||
['ssh:ssh-one', 'searched', 5]
|
||||
]
|
||||
)
|
||||
expect(value.partial).toBe(false)
|
||||
expect(local.call).toHaveBeenCalledWith(
|
||||
'aiVault.sshSearchSessions',
|
||||
{ query: 'shared phrase', targetId: 'ssh-one' },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not query a legacy host with unknown consent and does not enumerate pairings from a remote context', async () => {
|
||||
const remote = client('B', true)
|
||||
vi.mocked(remote.call).mockResolvedValue({ result: {} } as never)
|
||||
const pairings = vi.fn(() => [])
|
||||
const value = await searchAllHosts(
|
||||
remote,
|
||||
query,
|
||||
new AbortController().signal,
|
||||
Date.now() + 30_000,
|
||||
{ listEnvironments: pairings, createClient: () => remote, listSshTargets: async () => [] }
|
||||
)
|
||||
expect(value.hosts[0]?.outcome).toBe('policy-unknown')
|
||||
expect(remote.call).toHaveBeenCalledTimes(1)
|
||||
expect(pairings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds stalled inventory independently so known host results still complete', async () => {
|
||||
vi.useFakeTimers()
|
||||
const local = client('local')
|
||||
let signal: AbortSignal | undefined
|
||||
const pending = searchAllHosts(local, query, new AbortController().signal, Date.now() + 30_000, {
|
||||
listEnvironments: () => [],
|
||||
createClient: () => local,
|
||||
listSshTargets: async (_client, options) => {
|
||||
signal = options?.signal
|
||||
return new Promise(() => {})
|
||||
}
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
const value = await pending
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(value.hosts.map((host) => host.outcome)).toEqual(['searched', 'failed'])
|
||||
expect(value.partial).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels a stalled host call and releases its timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const local = client('local')
|
||||
const controller = new AbortController()
|
||||
let signal: AbortSignal | undefined
|
||||
vi.mocked(local.call).mockImplementation(async (_method, _params, options) => {
|
||||
signal = options?.signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const pending = searchAllHosts(local, query, controller.signal, Date.now() + 30_000, {
|
||||
listEnvironments: () => [],
|
||||
createClient: () => local,
|
||||
listSshTargets: async () => []
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
controller.abort()
|
||||
expect((await pending).hosts[0]?.outcome).toBe('unavailable')
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mapWithConcurrency } from '../shared/map-with-concurrency'
|
||||
import { toSshExecutionHostId, toRuntimeExecutionHostId } from '../shared/execution-host'
|
||||
import { waitForPromiseWithSignal } from '../shared/abort-signal-reason'
|
||||
import { getDefaultUserDataPath, RuntimeClient } from './runtime-client'
|
||||
import { listEnvironments } from './runtime/environments'
|
||||
import { listSshTargets } from './host-selector-alternatives'
|
||||
import { quoteCliCommandArgument } from './shell-command-quote'
|
||||
import type { SearchCommand } from './search-command-arguments'
|
||||
import {
|
||||
querySearchHost,
|
||||
SEARCH_ALL_HOST_LIMIT,
|
||||
SEARCH_HOST_TIMEOUT_MS,
|
||||
type SearchHost,
|
||||
type SearchHostResult
|
||||
} from './session-search-host-query'
|
||||
|
||||
export type AllHostSearchResult = {
|
||||
hosts: SearchHostResult[]
|
||||
partial: boolean
|
||||
omittedHosts: number
|
||||
}
|
||||
|
||||
export async function searchAllHosts(
|
||||
client: RuntimeClient,
|
||||
command: SearchCommand,
|
||||
signal: AbortSignal,
|
||||
deadline: number,
|
||||
dependencies: {
|
||||
listEnvironments: (path: string) => { id: string; name: string }[]
|
||||
listSshTargets: typeof listSshTargets
|
||||
createClient: (id: string) => RuntimeClient
|
||||
} = {
|
||||
listEnvironments,
|
||||
listSshTargets,
|
||||
createClient: (id: string) => new RuntimeClient(undefined, undefined, null, id)
|
||||
}
|
||||
): Promise<AllHostSearchResult> {
|
||||
const hosts: SearchHost[] = [
|
||||
{
|
||||
id: 'local',
|
||||
name: client.isRemote ? 'Selected runtime' : 'This runtime',
|
||||
selector: '',
|
||||
client
|
||||
}
|
||||
]
|
||||
const skipped: SearchHostResult[] = []
|
||||
const runtimeContext = client.isRemote
|
||||
? client.selectedEnvironment
|
||||
? `--environment ${quoteCliCommandArgument(client.selectedEnvironment)}`
|
||||
: 'in the same paired-runtime context'
|
||||
: ''
|
||||
let omittedHosts = 0
|
||||
if (!client.isRemote) {
|
||||
try {
|
||||
const environments = dependencies.listEnvironments(getDefaultUserDataPath())
|
||||
for (const environment of environments.slice(0, SEARCH_ALL_HOST_LIMIT - 1)) {
|
||||
const host = {
|
||||
id: toRuntimeExecutionHostId(environment.id),
|
||||
name: environment.name,
|
||||
selector: `--host ${toRuntimeExecutionHostId(environment.id)}`
|
||||
}
|
||||
try {
|
||||
hosts.push({ ...host, client: dependencies.createClient(environment.id) })
|
||||
} catch {
|
||||
skipped.push({
|
||||
host,
|
||||
outcome: 'unavailable',
|
||||
message: 'Could not resolve the saved pairing.'
|
||||
})
|
||||
}
|
||||
}
|
||||
omittedHosts += Math.max(0, environments.length - SEARCH_ALL_HOST_LIMIT + 1)
|
||||
} catch {
|
||||
skipped.push({
|
||||
host: { id: 'pairing-inventory', name: 'Paired servers', selector: '' },
|
||||
outcome: 'failed',
|
||||
message: 'Could not read the pairing inventory.'
|
||||
})
|
||||
}
|
||||
}
|
||||
const inventoryController = new AbortController()
|
||||
const abortInventory = (): void => inventoryController.abort(signal.reason)
|
||||
signal.addEventListener('abort', abortInventory, { once: true })
|
||||
if (signal.aborted) {
|
||||
abortInventory()
|
||||
}
|
||||
const inventoryDeadline = Math.min(deadline, Date.now() + 3_000)
|
||||
const inventoryTimer = setTimeout(
|
||||
() => inventoryController.abort(new Error('SSH inventory deadline exceeded.')),
|
||||
Math.max(1, inventoryDeadline - Date.now())
|
||||
)
|
||||
try {
|
||||
const targets = await waitForPromiseWithSignal(
|
||||
dependencies.listSshTargets(client, {
|
||||
strict: true,
|
||||
signal: inventoryController.signal,
|
||||
deadline: inventoryDeadline
|
||||
}),
|
||||
inventoryController.signal
|
||||
)
|
||||
for (const target of targets.slice(0, SEARCH_ALL_HOST_LIMIT)) {
|
||||
const host = {
|
||||
id: toSshExecutionHostId(target.id),
|
||||
name: target.label,
|
||||
selector: `${runtimeContext ? `${runtimeContext} ` : ''}--host ${toSshExecutionHostId(target.id)}`
|
||||
}
|
||||
if (target.connected === true && hosts.length < SEARCH_ALL_HOST_LIMIT) {
|
||||
hosts.push({ ...host, client, targetId: target.id })
|
||||
} else {
|
||||
skipped.push({
|
||||
host,
|
||||
outcome: target.connected === true ? 'omitted' : 'unavailable',
|
||||
message:
|
||||
target.connected === true
|
||||
? 'Host limit reached.'
|
||||
: 'SSH target is not known to be connected.'
|
||||
})
|
||||
}
|
||||
}
|
||||
omittedHosts += Math.max(0, targets.length - SEARCH_ALL_HOST_LIMIT)
|
||||
} catch {
|
||||
skipped.push({
|
||||
host: { id: 'ssh-inventory', name: 'SSH targets', selector: '' },
|
||||
outcome: 'failed',
|
||||
message: 'Could not read SSH inventory.'
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(inventoryTimer)
|
||||
signal.removeEventListener('abort', abortInventory)
|
||||
}
|
||||
let bytesRemaining = 4 * 1024 * 1024 - 128 * 1024
|
||||
const results = await mapWithConcurrency(hosts, 3, async (host): Promise<SearchHostResult> => {
|
||||
if (signal.aborted || Date.now() >= deadline) {
|
||||
return {
|
||||
host: { id: host.id, name: host.name, selector: host.selector },
|
||||
outcome: 'omitted',
|
||||
message: 'Overall search deadline exceeded.'
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Search host deadline exceeded.')),
|
||||
Math.min(SEARCH_HOST_TIMEOUT_MS, deadline - Date.now())
|
||||
)
|
||||
const abort = (): void => controller.abort(signal.reason)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
try {
|
||||
const result = await querySearchHost(host, command, true, controller.signal)
|
||||
const bytes = Buffer.byteLength(JSON.stringify(result))
|
||||
if (bytes > bytesRemaining) {
|
||||
return {
|
||||
host: result.host,
|
||||
outcome: 'omitted',
|
||||
message: 'Aggregate response limit reached.'
|
||||
}
|
||||
}
|
||||
bytesRemaining -= bytes
|
||||
return result
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
})
|
||||
const combined = [...results, ...skipped]
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify({ hosts: combined, partial: true, omittedHosts })) >
|
||||
4 * 1024 * 1024
|
||||
) {
|
||||
return {
|
||||
hosts: [
|
||||
{
|
||||
host: { id: 'inventory', name: 'Host inventory', selector: '' },
|
||||
outcome: 'failed',
|
||||
message: 'Host metadata exceeds the aggregate response limit.'
|
||||
}
|
||||
],
|
||||
partial: true,
|
||||
omittedHosts: omittedHosts + combined.length
|
||||
}
|
||||
}
|
||||
return {
|
||||
hosts: combined,
|
||||
partial:
|
||||
omittedHosts > 0 ||
|
||||
combined.some(
|
||||
(host) =>
|
||||
host.outcome !== 'searched' ||
|
||||
host.result?.omittedHits ||
|
||||
host.result?.sourceUnavailableFiles ||
|
||||
host.result?.coverage.providers.some(
|
||||
(provider) =>
|
||||
(provider.parseFailures ?? 0) > 0 ||
|
||||
(provider.scanIssues ?? 0) > 0 ||
|
||||
((provider.filesDiscovered ?? 0) > 0 && provider.sessionsIndexed === 0)
|
||||
) ||
|
||||
host.result?.coverage.backfill !== 'complete' ||
|
||||
host.result?.coverage.filesPending
|
||||
),
|
||||
omittedHosts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import {
|
||||
SessionSearchResultSchema,
|
||||
SessionSearchStatusSchema
|
||||
} from '../shared/ai-vault-search-contract'
|
||||
import type { AiVaultSearchResult } from '../shared/ai-vault-search-types'
|
||||
import type { SearchCommand } from './search-command-arguments'
|
||||
import { waitForPromiseWithSignal } from '../shared/abort-signal-reason'
|
||||
|
||||
export type SearchHost = {
|
||||
id: string
|
||||
name: string
|
||||
selector: string
|
||||
client: RuntimeClient
|
||||
targetId?: string
|
||||
}
|
||||
export type SearchHostResult = {
|
||||
host: Pick<SearchHost, 'id' | 'name' | 'selector'> & { runtimeId?: string }
|
||||
outcome:
|
||||
| 'searched'
|
||||
| 'disabled'
|
||||
| 'unsupported'
|
||||
| 'unavailable'
|
||||
| 'policy-unknown'
|
||||
| 'failed'
|
||||
| 'omitted'
|
||||
result?: AiVaultSearchResult
|
||||
message?: string
|
||||
}
|
||||
export const SEARCH_HOST_TIMEOUT_MS = 15_000
|
||||
export const SEARCH_ALL_TIMEOUT_MS = 30_000
|
||||
export const SEARCH_ALL_HOST_LIMIT = 16
|
||||
|
||||
export function searchHostMethod(
|
||||
host: Pick<SearchHost, 'targetId'>,
|
||||
operation: 'query' | 'status' | 'configure'
|
||||
): string {
|
||||
if (host.targetId) {
|
||||
return `aiVault.sshSearch${{ query: 'Sessions', status: 'IndexStatus', configure: 'Configure' }[operation]}`
|
||||
}
|
||||
return {
|
||||
query: 'aiVault.searchSessions',
|
||||
status: 'aiVault.searchIndexStatus',
|
||||
configure: 'aiVault.configureSessionSearch'
|
||||
}[operation]
|
||||
}
|
||||
|
||||
export async function querySearchHost(
|
||||
host: SearchHost,
|
||||
command: SearchCommand,
|
||||
aggregate: boolean,
|
||||
signal: AbortSignal
|
||||
): Promise<SearchHostResult> {
|
||||
const identity: SearchHostResult['host'] = {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
selector: host.selector
|
||||
}
|
||||
const deadline = Date.now() + SEARCH_HOST_TIMEOUT_MS
|
||||
const call = async (operation: 'query' | 'status', args: object = {}): Promise<unknown> => {
|
||||
const remaining = deadline - Date.now()
|
||||
if (remaining <= 0) {
|
||||
throw new Error('Search host deadline exceeded.')
|
||||
}
|
||||
const response = await waitForPromiseWithSignal(
|
||||
host.client.call(
|
||||
searchHostMethod(host, operation),
|
||||
{
|
||||
...args,
|
||||
...(host.targetId ? { targetId: host.targetId } : {})
|
||||
},
|
||||
{ timeoutMs: remaining, signal }
|
||||
),
|
||||
signal
|
||||
)
|
||||
if (response._meta?.runtimeId) {
|
||||
identity.runtimeId = response._meta.runtimeId.slice(0, 512)
|
||||
}
|
||||
return response.result
|
||||
}
|
||||
try {
|
||||
if (aggregate) {
|
||||
let raw
|
||||
try {
|
||||
raw = await call('status')
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'method_not_found') {
|
||||
return {
|
||||
host: identity,
|
||||
outcome: host.targetId ? 'unsupported' : 'policy-unknown',
|
||||
message: host.targetId
|
||||
? 'This runtime does not provide the SSH search route.'
|
||||
: 'This host cannot report its indexing policy.'
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
!raw ||
|
||||
typeof raw !== 'object' ||
|
||||
!('enabled' in raw) ||
|
||||
typeof raw.enabled !== 'boolean'
|
||||
) {
|
||||
return {
|
||||
host: identity,
|
||||
outcome: 'policy-unknown',
|
||||
message: 'This host did not report its indexing policy.'
|
||||
}
|
||||
}
|
||||
const status = SessionSearchStatusSchema.parse(raw)
|
||||
if (status.available === false) {
|
||||
return { host: identity, outcome: 'unsupported', message: status.reason }
|
||||
}
|
||||
if (status.applied === false) {
|
||||
return {
|
||||
host: identity,
|
||||
outcome: 'policy-unknown',
|
||||
message: status.reason ?? 'Index policy is not applied.'
|
||||
}
|
||||
}
|
||||
if (!status.enabled) {
|
||||
return { host: identity, outcome: 'disabled' }
|
||||
}
|
||||
}
|
||||
const result = SessionSearchResultSchema.parse(await call('query', command.query))
|
||||
return {
|
||||
host: identity,
|
||||
outcome: result.coverage.enabled === false ? 'disabled' : 'searched',
|
||||
result
|
||||
}
|
||||
} catch (error) {
|
||||
const code = errorCode(error)
|
||||
const outcome =
|
||||
code === 'method_not_found'
|
||||
? 'unsupported'
|
||||
: code === 'invalid_runtime_response' ||
|
||||
(error instanceof Error && error.name === 'ZodError')
|
||||
? 'failed'
|
||||
: 'unavailable'
|
||||
return {
|
||||
host: identity,
|
||||
outcome,
|
||||
message: (error instanceof Error ? error.message : 'Search failed.').slice(0, 2048)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function errorCode(error: unknown): unknown {
|
||||
return error && typeof error === 'object' && 'code' in error ? error.code : undefined
|
||||
}
|
||||
+20
-4
@@ -3,7 +3,7 @@ import { GLOBAL_FLAGS, type CommandSpec } from '../args'
|
||||
export const SEARCH_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['search'],
|
||||
summary: 'Search the full text of every local coding-agent session',
|
||||
summary: 'Search the full text of coding-agent sessions on local and remote hosts',
|
||||
usage:
|
||||
'orca search --agent-session "<query>" [--limit <n>] [--agent <agent>] [--path <dir>] [--since <iso>] [--newest] [--host <host>] [--json]\n orca search --agent-session --enable [--host <host>]',
|
||||
allowedFlags: [
|
||||
@@ -15,7 +15,13 @@ export const SEARCH_COMMAND_SPECS: CommandSpec[] = [
|
||||
'since',
|
||||
'newest',
|
||||
'host',
|
||||
'enable'
|
||||
'enable',
|
||||
'disable',
|
||||
'clear-index',
|
||||
'index-status',
|
||||
'history-days',
|
||||
'pause',
|
||||
'resume-indexing'
|
||||
],
|
||||
notes: [
|
||||
'Searches what you typed, what the agent said, the commands it ran, and the first 3 KB of each tool output across Claude Code, Codex, Cursor, Gemini, OpenCode, and the other agents Orca scans.',
|
||||
@@ -23,13 +29,23 @@ export const SEARCH_COMMAND_SPECS: CommandSpec[] = [
|
||||
'--agent-session takes the query. --agent and --path may repeat. --since takes an ISO timestamp. --newest sorts by session time instead of relevance.',
|
||||
'Transcript search is off until you turn it on. Enable it in Settings > Agent Session History, or run `orca search --agent-session --enable`.',
|
||||
'The index builds in the background once enabled; the result reports how many sessions are covered so far.',
|
||||
'--host runtime:<environment> searches that host; the index always lives with the transcripts.'
|
||||
'--host runtime:<environment> selects a paired server; --host ssh:<target> selects a connected SSH host. Use --environment B --host ssh:<target> for SSH controlled by B. The index stays on the execution host.',
|
||||
'--host all queries this runtime, locally saved pairings and its connected SSH targets. With a remote environment selected, it queries only that runtime and its direct connected SSH targets. It never connects SSH or enables indexing.',
|
||||
'--limit is per host (default 20, maximum 100). Results are grouped by host; failures and incomplete coverage are reported. Aliased connections may return the same sessions.',
|
||||
'--path is a literal host-native predicate, with no implicit current-directory scope. SSH/all require absolute paths; use quoted Windows paths where appropriate. Remote/all do not expand ~.',
|
||||
'Index management requires one host: --enable, --disable, --history-days <1..3650|all>, --pause, --resume-indexing, --clear-index, or --index-status. Status cannot be combined with a query or mutation.',
|
||||
'Plain SSH uses the account’s default agent homes and Node.js 22.13+ with FTS5. Background indexing yields between bounded windows and pauses when the scanner retires after 10 idle minutes; a later search resumes it.'
|
||||
],
|
||||
examples: [
|
||||
'orca search --agent-session "strict mode violation getByRole"',
|
||||
'orca search --agent-session resolveTerminalPath --agent claude --newest',
|
||||
'orca search --agent-session "kernel panic" --path ~/orca --since 2026-08-01T00:00:00Z --json',
|
||||
'orca search --agent-session --enable'
|
||||
'orca search --agent-session --enable',
|
||||
'orca search --agent-session "kernel panic" --host all',
|
||||
'orca search --enable --host ssh:build-server',
|
||||
'orca search --index-status --host ssh:build-server',
|
||||
'orca search --disable --clear-index --host runtime:build-server',
|
||||
'orca search --agent-session "kernel panic" --environment B --host ssh:C'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { SessionSearchStore } from './session-search-store'
|
||||
import { parseSearchCandidates } from './session-search-parse-candidates'
|
||||
import { sessionCandidate } from './session-search-transcript-fixtures'
|
||||
import { stagedWriteUpdate } from './session-search-staged-write-fixtures'
|
||||
import { registerSessionSearchIndexSink } from '../ai-vault/session-search-capture'
|
||||
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
|
||||
import * as sourceRead from '../native-chat/wsl-transcript-fs-access'
|
||||
|
||||
it('preserves published content and cursor when a whole-JSON refresh is canceled', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ss-cancel-publish-'))
|
||||
const store = new SessionSearchStore(join(root, 'index.sqlite'))
|
||||
const held = Promise.withResolvers<void>()
|
||||
const reached = Promise.withResolvers<void>()
|
||||
let parsing: Promise<unknown> | undefined
|
||||
try {
|
||||
resetSessionParseCacheForTests()
|
||||
registerSessionSearchIndexSink(store)
|
||||
const path = join(root, 'gemini.json')
|
||||
const document = {
|
||||
sessionId: 'cancel-session',
|
||||
messages: [{ type: 'user', content: 'oldneedle' }]
|
||||
}
|
||||
await writeFile(path, JSON.stringify(document))
|
||||
await parseSearchCandidates(store, [await sessionCandidate('gemini', path)])
|
||||
const before = store.indexedFile(path, null)
|
||||
document.messages.push({ type: 'gemini', content: 'newneedle after refresh' })
|
||||
await writeFile(path, JSON.stringify(document))
|
||||
const candidate = await sessionCandidate('gemini', path)
|
||||
const read = sourceRead.wslGatedReadFile
|
||||
vi.spyOn(sourceRead, 'wslGatedReadFile').mockImplementationOnce(async (...args) => {
|
||||
const text = await read(...args)
|
||||
reached.resolve()
|
||||
await held.promise
|
||||
return text
|
||||
})
|
||||
const controller = new AbortController()
|
||||
parsing = parseSearchCandidates(store, [candidate], controller.signal).catch((error) => error)
|
||||
await reached.promise
|
||||
controller.abort()
|
||||
held.resolve()
|
||||
expect(await parsing).toMatchObject({ name: 'AbortError' })
|
||||
expect(store.search({ query: 'oldneedle' }).hits).toHaveLength(1)
|
||||
expect(store.search({ query: 'newneedle' }).hits).toHaveLength(0)
|
||||
expect(store.indexedFile(path, null)).toEqual(before)
|
||||
expect(store.failures).toBe(0)
|
||||
resetSessionParseCacheForTests()
|
||||
await parseSearchCandidates(store, [candidate])
|
||||
expect(store.search({ query: 'oldneedle' }).hits).toHaveLength(1)
|
||||
expect(store.search({ query: 'newneedle' }).hits).toHaveLength(1)
|
||||
} finally {
|
||||
held.resolve()
|
||||
await parsing
|
||||
vi.restoreAllMocks()
|
||||
registerSessionSearchIndexSink(null)
|
||||
store.close()
|
||||
resetSessionParseCacheForTests()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('checks cancellation again before publishing fully consumed staged messages', async () => {
|
||||
const store = new SessionSearchStore(':memory:')
|
||||
const original = stagedWriteUpdate('oldneedle', 1)
|
||||
const replacement = stagedWriteUpdate('newneedle', 1)
|
||||
const result = Promise.withResolvers<{
|
||||
session: typeof replacement.session
|
||||
byteOffset: number
|
||||
}>()
|
||||
const consumed = Promise.withResolvers<void>()
|
||||
const controller = new AbortController()
|
||||
let pending: Promise<void> | undefined
|
||||
try {
|
||||
await store.apply(original)
|
||||
pending = store.apply({
|
||||
candidate: replacement.candidate,
|
||||
mode: 'replace',
|
||||
previousByteOffset: 0,
|
||||
signal: controller.signal,
|
||||
result: result.promise,
|
||||
messages: (async function* () {
|
||||
yield { role: 'user' as const, text: 'newneedle', timestamp: null }
|
||||
consumed.resolve()
|
||||
})()
|
||||
})
|
||||
await consumed.promise
|
||||
controller.abort()
|
||||
result.resolve({ session: replacement.session, byteOffset: 99 })
|
||||
await pending
|
||||
expect(store.search({ query: 'oldneedle' }).hits).toHaveLength(1)
|
||||
expect(store.search({ query: 'newneedle' }).hits).toHaveLength(0)
|
||||
expect(store.indexedFile(original.candidate.file.path, null)?.byteOffset).toBe(1)
|
||||
expect(store.failures).toBe(0)
|
||||
} finally {
|
||||
result.resolve({ session: null, byteOffset: 0 })
|
||||
await pending
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import Database from '../sqlite/sync-database'
|
||||
|
||||
let cached: { available: boolean; reason?: string } | undefined
|
||||
|
||||
/** Probe FTS without opening a transcript index or discovering sources. */
|
||||
export function sessionSearchCapability(): { available: boolean; reason?: string } {
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
let database: Database | undefined
|
||||
try {
|
||||
database = new Database(':memory:')
|
||||
database.exec('CREATE VIRTUAL TABLE probe USING fts5(text)')
|
||||
cached = { available: true }
|
||||
} catch {
|
||||
cached = {
|
||||
available: false,
|
||||
reason: 'Session search requires Node.js with node:sqlite and FTS5 (Node 22.13 or newer).'
|
||||
}
|
||||
} finally {
|
||||
database?.close()
|
||||
}
|
||||
return cached
|
||||
}
|
||||
@@ -28,7 +28,7 @@ it('clears persisted index before a scanner has been started', async () => {
|
||||
installAiVaultSearchSettingsSource(() => ({
|
||||
aiVaultSearch: { enabled: false, historyDays: null }
|
||||
}))
|
||||
await clearAiVaultSearchIndex()
|
||||
await clearAiVaultSearchIndex(async () => undefined)
|
||||
expect(existsSync(path)).toBe(false)
|
||||
expect(existsSync(`${path}-wal`)).toBe(false)
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { SessionSearchStore } from './session-search-store'
|
||||
import { parseSearchCandidates } from './session-search-parse-candidates'
|
||||
import {
|
||||
userRecord,
|
||||
assistantRecord,
|
||||
sessionCandidate,
|
||||
codexRolloutLines,
|
||||
CODEX_SESSION_ID,
|
||||
CODEX_ROLLOUT_FILE
|
||||
} from './session-search-transcript-fixtures'
|
||||
import { resetCodexSessionIndexTitleCacheForTests } from '../ai-vault/session-scanner-codex-title-index'
|
||||
import {
|
||||
parseAgentSessionFileCached,
|
||||
resetSessionParseCacheForTests,
|
||||
seedSessionParseCache
|
||||
} from '../ai-vault/session-scanner-parse-cache'
|
||||
import { registerSessionSearchIndexSink } from '../ai-vault/session-search-capture'
|
||||
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
|
||||
|
||||
vi.mock('./session-search-backfill-pacing', () => ({ pauseBackfill: async () => {} }))
|
||||
let directory: string | undefined
|
||||
let store: SessionSearchStore | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
registerSessionSearchIndexSink(null)
|
||||
store?.close()
|
||||
resetSessionParseCacheForTests()
|
||||
resetCodexSessionIndexTitleCacheForTests()
|
||||
if (directory) {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
directory = await mkdtemp(join(tmpdir(), 'search-durable-reuse-'))
|
||||
const path = join(directory, 'session.jsonl')
|
||||
await writeFile(
|
||||
path,
|
||||
`${userRecord(0, 'ordinary title')}\n${assistantRecord(1, 'durableneedle')}\n`
|
||||
)
|
||||
const candidate = await candidateAt(path)
|
||||
const databasePath = join(directory, 'index.sqlite')
|
||||
store = new SessionSearchStore(databasePath)
|
||||
registerSessionSearchIndexSink(store)
|
||||
await parseSearchCandidates(store, [candidate])
|
||||
store.close()
|
||||
resetSessionParseCacheForTests()
|
||||
store = new SessionSearchStore(databasePath)
|
||||
registerSessionSearchIndexSink(store)
|
||||
return { candidate, store }
|
||||
}
|
||||
|
||||
async function candidateAt(path: string): Promise<SessionFileCandidate> {
|
||||
const file = await stat(path)
|
||||
return {
|
||||
agent: 'claude',
|
||||
codexHome: null,
|
||||
file: {
|
||||
path,
|
||||
mtimeMs: file.mtimeMs,
|
||||
modifiedAt: file.mtime.toISOString(),
|
||||
sizeBytes: file.size,
|
||||
dev: file.dev,
|
||||
ino: file.ino
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('reuses an unchanged reopened index without a preview cache or replacement write', async () => {
|
||||
const { candidate, store } = await fixture()
|
||||
const apply = vi.spyOn(store, 'apply')
|
||||
const before = store.db
|
||||
.prepare('SELECT session_row_id FROM files WHERE path=?')
|
||||
.get(candidate.file.path)
|
||||
await parseSearchCandidates(store, [candidate])
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(
|
||||
store.db.prepare('SELECT session_row_id FROM files WHERE path=?').get(candidate.file.path)
|
||||
).toEqual(before)
|
||||
expect(store.search({ query: 'durableneedle' }).hits).toHaveLength(1)
|
||||
// Listing still needs its preview, even when backfill can reuse the index.
|
||||
expect(
|
||||
(await parseAgentSessionFileCached(candidate, process.platform))?.previewMessages.length
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not skip a changed or atomically replaced transcript', async () => {
|
||||
const { candidate, store } = await fixture()
|
||||
const apply = vi.spyOn(store, 'apply')
|
||||
await writeFile(candidate.file.path, `${userRecord(0, 'newlongerneedle')}\n`)
|
||||
await parseSearchCandidates(store, [await candidateAt(candidate.file.path)])
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
resetSessionParseCacheForTests()
|
||||
const replacement = await candidateAt(candidate.file.path)
|
||||
replacement.file.ino = (replacement.file.ino ?? 0) + 1
|
||||
await parseSearchCandidates(store, [replacement])
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps durable reuse beyond the 4096-entry preview cache', async () => {
|
||||
store = new SessionSearchStore(':memory:')
|
||||
registerSessionSearchIndexSink(store)
|
||||
const now = Date.now()
|
||||
const candidates: SessionFileCandidate[] = Array.from({ length: 4097 }, (_, i) => ({
|
||||
agent: 'claude',
|
||||
codexHome: null,
|
||||
file: {
|
||||
path: `isolated-${i}`,
|
||||
mtimeMs: now,
|
||||
modifiedAt: new Date(now).toISOString(),
|
||||
sizeBytes: 1,
|
||||
dev: 1,
|
||||
ino: i + 1
|
||||
}
|
||||
}))
|
||||
store.db.exec('BEGIN')
|
||||
const insert = store.db.prepare(
|
||||
'INSERT INTO files(path,dev,ino,mtime_ms,size_bytes,byte_offset) VALUES(?,?,?,?,?,?)'
|
||||
)
|
||||
for (const { file } of candidates) {
|
||||
insert.run(file.path, file.dev!, file.ino!, now, 1, 1)
|
||||
}
|
||||
store.db.exec('COMMIT')
|
||||
seedSessionParseCache(
|
||||
candidates.map(({ file }) => [
|
||||
file.path,
|
||||
{ mtimeMs: now, sizeBytes: 1, platform: process.platform, session: null }
|
||||
])
|
||||
)
|
||||
const apply = vi.spyOn(store, 'apply')
|
||||
await parseSearchCandidates(store, candidates)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(store.coverage().providers.every((provider) => !provider.parseFailures)).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes external Codex titles on cold reuse without replacing transcript rows', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'search-durable-title-'))
|
||||
const path = join(directory, CODEX_ROLLOUT_FILE)
|
||||
await writeFile(path, `${codexRolloutLines(['echo'], 'output', 'titleneedle').join('\n')}\n`)
|
||||
const candidate = await sessionCandidate('codex', path, directory)
|
||||
const databasePath = join(directory, 'index.sqlite')
|
||||
store = new SessionSearchStore(databasePath)
|
||||
registerSessionSearchIndexSink(store)
|
||||
await parseSearchCandidates(store, [candidate])
|
||||
const before = store.db.prepare('SELECT id FROM sessions WHERE index_ready = 1').all()
|
||||
store.close()
|
||||
resetSessionParseCacheForTests()
|
||||
resetCodexSessionIndexTitleCacheForTests()
|
||||
store = new SessionSearchStore(databasePath)
|
||||
registerSessionSearchIndexSink(store)
|
||||
await writeFile(
|
||||
join(directory, 'session_index.jsonl'),
|
||||
`${JSON.stringify({
|
||||
id: CODEX_SESSION_ID,
|
||||
thread_name: 'Renamed durable title'
|
||||
})}\n`
|
||||
)
|
||||
const apply = vi.spyOn(store, 'apply')
|
||||
await parseSearchCandidates(store, [candidate])
|
||||
expect(store.search({ query: 'titleneedle' }).hits[0].title).toBe('Renamed durable title')
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(store.db.prepare('SELECT id FROM sessions WHERE index_ready = 1').all()).toEqual(before)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyAiVaultSearchSettings,
|
||||
clearAiVaultSearchIndex,
|
||||
installAiVaultSearchSettingsSource,
|
||||
readAiVaultSearchIndexStatus
|
||||
} from './session-search-enablement'
|
||||
@@ -19,6 +20,10 @@ vi.mock('../ai-vault/cached-session-list', () => ({
|
||||
configureAiVaultSearch: (...args: unknown[]) => configureAiVaultSearch(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/session-scanner-service-entry-path', () => ({
|
||||
getAiVaultServiceEntryPath: () => process.execPath
|
||||
}))
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -67,10 +72,10 @@ describe('session search policy source', () => {
|
||||
})
|
||||
|
||||
describe('applyAiVaultSearchSettings', () => {
|
||||
it('does nothing before the index path is known', async () => {
|
||||
it('refuses to acknowledge a policy change before the index path is known', async () => {
|
||||
await expect(
|
||||
applyAiVaultSearchSettings({ aiVaultSearch: { enabled: true, historyDays: null } })
|
||||
).resolves.toBeNull()
|
||||
).rejects.toThrow('not initialized')
|
||||
expect(configureAiVaultSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -94,6 +99,19 @@ describe('applyAiVaultSearchSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports failed application rather than claiming the saved policy is effective', async () => {
|
||||
initSessionSearchPaths(await makeUserDataDir())
|
||||
configureAiVaultSearch.mockRejectedValueOnce(new Error('scanner unavailable'))
|
||||
await expect(
|
||||
applyAiVaultSearchSettings(
|
||||
{ aiVaultSearch: { enabled: false, historyDays: null } },
|
||||
{ clearIndex: true }
|
||||
)
|
||||
).rejects.toThrow('scanner unavailable')
|
||||
expect(readAiVaultSearchIndexStatus().applied).toBe(false)
|
||||
await applyAiVaultSearchSettings({ aiVaultSearch: { enabled: false, historyDays: null } })
|
||||
})
|
||||
|
||||
describe('readAiVaultSearchIndexStatus', () => {
|
||||
it('reports no size when the database file is absent', async () => {
|
||||
initSessionSearchPaths(await makeUserDataDir())
|
||||
@@ -132,3 +150,66 @@ it('preserves the paused preference in scanner initialization and configuration'
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('retains failed durability in status until a complete transition succeeds', async () => {
|
||||
initSessionSearchPaths(await makeUserDataDir())
|
||||
const settings = { aiVaultSearch: { enabled: true, historyDays: null } }
|
||||
installAiVaultSearchSettingsSource(() => settings)
|
||||
await expect(
|
||||
applyAiVaultSearchSettings(settings, {
|
||||
persist: async () => {
|
||||
throw new Error('disk full')
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('disk full')
|
||||
expect(readAiVaultSearchIndexStatus()).toMatchObject({ enabled: true, applied: false })
|
||||
expect(readAiVaultSearchIndexStatus().reason).toContain('persistence')
|
||||
await applyAiVaultSearchSettings(settings, { persist: async () => undefined })
|
||||
expect(readAiVaultSearchIndexStatus()).toMatchObject({ applied: true })
|
||||
})
|
||||
|
||||
it('does not mark a newer queued policy applied when an older flush completes', async () => {
|
||||
initSessionSearchPaths(await makeUserDataDir())
|
||||
let releaseFirst!: () => void
|
||||
const first = applyAiVaultSearchSettings(
|
||||
{ aiVaultSearch: { enabled: true, historyDays: null } },
|
||||
{
|
||||
persist: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() => expect(releaseFirst).toBeTypeOf('function'))
|
||||
let releaseSecond!: () => void
|
||||
const second = applyAiVaultSearchSettings(
|
||||
{ aiVaultSearch: { enabled: false, historyDays: null } },
|
||||
{
|
||||
persist: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseSecond = resolve
|
||||
})
|
||||
}
|
||||
)
|
||||
releaseFirst()
|
||||
await first
|
||||
expect(readAiVaultSearchIndexStatus().applied).toBe(false)
|
||||
await vi.waitFor(() => expect(releaseSecond).toBeTypeOf('function'))
|
||||
releaseSecond()
|
||||
await second
|
||||
expect(readAiVaultSearchIndexStatus().applied).toBe(true)
|
||||
})
|
||||
|
||||
it('requires desktop clear to retry a failed policy flush before reporting applied', async () => {
|
||||
initSessionSearchPaths(await makeUserDataDir())
|
||||
const settings = { aiVaultSearch: { enabled: true, historyDays: null } }
|
||||
installAiVaultSearchSettingsSource(() => settings)
|
||||
const persist = vi.fn().mockRejectedValue(new Error('disk full'))
|
||||
await expect(applyAiVaultSearchSettings(settings, { persist })).rejects.toThrow('disk full')
|
||||
await expect(clearAiVaultSearchIndex(persist)).rejects.toThrow('disk full')
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
expect(readAiVaultSearchIndexStatus().applied).toBe(false)
|
||||
persist.mockResolvedValue(undefined)
|
||||
await clearAiVaultSearchIndex(persist)
|
||||
expect(readAiVaultSearchIndexStatus().applied).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { getAiVaultServiceEntryPath } from '../ai-vault/session-scanner-service-entry-path'
|
||||
import { sessionSearchCapability } from './session-search-capability'
|
||||
import type { AiVaultSearchCoverage } from '../../shared/ai-vault-search-types'
|
||||
import {
|
||||
resolveAiVaultSearchSettings,
|
||||
@@ -28,35 +30,64 @@ export function installAiVaultSearchSettingsSource(source: SettingsSource | null
|
||||
*/
|
||||
export function applyAiVaultSearchSettings(
|
||||
settings: Pick<GlobalSettings, 'aiVaultSearch'>,
|
||||
options: { clearIndex?: boolean } = {}
|
||||
options: { clearIndex?: boolean; persist?: () => void | Promise<void> } = {}
|
||||
): Promise<AiVaultSearchCoverage | null> {
|
||||
const databasePath = getSessionSearchDatabasePath()
|
||||
if (!databasePath) {
|
||||
return Promise.resolve(null)
|
||||
return Promise.reject(new Error('Session search paths are not initialized.'))
|
||||
}
|
||||
const policy: AiVaultSearchSettings = resolveAiVaultSearchSettings(settings)
|
||||
// Why: each call resolves scan roots before it reaches the scanner, so two
|
||||
// overlapping toggles could land out of order; apply them one after another.
|
||||
const generation = ++applyGeneration
|
||||
policyApplied = false
|
||||
const applied = applyChain
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
configureAiVaultSearch({ databasePath, ...policy }, { clearIndex: options.clearIndex })
|
||||
)
|
||||
.then(async () => {
|
||||
const result = await configureAiVaultSearch(
|
||||
{ databasePath, ...policy },
|
||||
{ clearIndex: options.clearIndex }
|
||||
)
|
||||
await options.persist?.()
|
||||
if (generation === applyGeneration) {
|
||||
policyApplied = true
|
||||
}
|
||||
return result
|
||||
})
|
||||
applyChain = applied
|
||||
return applied
|
||||
}
|
||||
|
||||
let applyChain: Promise<unknown> = Promise.resolve()
|
||||
let policyApplied = true
|
||||
let applyGeneration = 0
|
||||
|
||||
export function readAiVaultSearchIndexStatus(): AiVaultSearchIndexStatus {
|
||||
return { ...getSessionSearchPolicy(), indexSizeBytes: readAiVaultSearchIndexSizeBytes() }
|
||||
const capability = sessionSearchCapability()
|
||||
const available =
|
||||
capability.available &&
|
||||
!!getSessionSearchDatabasePath() &&
|
||||
existsSync(getAiVaultServiceEntryPath())
|
||||
return {
|
||||
...getSessionSearchPolicy(),
|
||||
indexSizeBytes: readAiVaultSearchIndexSizeBytes(),
|
||||
available,
|
||||
applied: available && policyApplied,
|
||||
...(!available
|
||||
? { reason: capability.reason ?? 'Session search service is not installed or initialized.' }
|
||||
: !policyApplied
|
||||
? { reason: 'Index policy application or persistence failed or is pending.' }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes the database and its sidecars, then rebuilds if consent still stands. */
|
||||
export function clearAiVaultSearchIndex(): Promise<AiVaultSearchCoverage | null> {
|
||||
export function clearAiVaultSearchIndex(
|
||||
persist: () => void | Promise<void>
|
||||
): Promise<AiVaultSearchCoverage | null> {
|
||||
return applyAiVaultSearchSettings(
|
||||
{ aiVaultSearch: getSessionSearchPolicy() },
|
||||
{ clearIndex: true }
|
||||
{ clearIndex: true, persist }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ export { chunkMessageText } from './session-search-message-rows'
|
||||
export const SEARCH_WRITE_ROWS_PER_STEP = 128
|
||||
export const SEARCH_WRITE_CHARS_PER_STEP = 256 * 1024
|
||||
|
||||
export type SessionSearchMetadata = Pick<
|
||||
AiVaultSession,
|
||||
'sessionId' | 'filePath' | 'title' | 'cwd' | 'branch' | 'updatedAt'
|
||||
>
|
||||
|
||||
type FileRow = {
|
||||
dev: number | null
|
||||
ino: number | null
|
||||
@@ -53,16 +58,27 @@ export class SessionSearchIndexWriter {
|
||||
return { byteOffset: row.byte_offset, mtimeMs: row.mtime_ms, sizeBytes: row.size_bytes }
|
||||
}
|
||||
|
||||
updateMetadata(path: string, session: AiVaultSession): void {
|
||||
indexedMetadata(path: string): SessionSearchMetadata | null {
|
||||
return (
|
||||
(this.db
|
||||
.prepare(`SELECT session_id AS sessionId, file_path AS filePath,
|
||||
title, cwd, branch, updated_at AS updatedAt FROM sessions
|
||||
WHERE id = (SELECT session_row_id FROM files WHERE path = ?)`)
|
||||
.get(path) as SessionSearchMetadata | undefined) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
updateMetadata(path: string, session: SessionSearchMetadata): void {
|
||||
this.db
|
||||
.prepare(`UPDATE sessions SET title = ?, cwd = ?, cwd_key = ?, branch = ?
|
||||
WHERE id = (SELECT session_row_id FROM files WHERE path = ?)`)
|
||||
WHERE id = (SELECT session_row_id FROM files WHERE path = ?) AND session_id = ?`)
|
||||
.run(
|
||||
redactSessionSearchText(session.title),
|
||||
session.cwd,
|
||||
session.cwd ? sessionSearchPathKey(session.cwd, session.filePath) : null,
|
||||
session.branch,
|
||||
path
|
||||
path,
|
||||
session.sessionId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { it, expect, vi } from 'vitest'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { SessionSearchStore } from './session-search-store'
|
||||
import { parseSearchCandidates } from './session-search-parse-candidates'
|
||||
import { registerSessionSearchIndexSink } from '../ai-vault/session-search-capture'
|
||||
import {
|
||||
resetSessionParseCacheForTests,
|
||||
parseAgentSessionFileCached
|
||||
} from '../ai-vault/session-scanner-parse-cache'
|
||||
import {
|
||||
sessionCandidate,
|
||||
codexRolloutLines,
|
||||
CODEX_SESSION_ID,
|
||||
CODEX_ROLLOUT_FILE
|
||||
} from './session-search-transcript-fixtures'
|
||||
import { resetCodexSessionIndexTitleCacheForTests } from '../ai-vault/session-scanner-codex-title-index'
|
||||
import * as sourceRead from '../native-chat/wsl-transcript-fs-access'
|
||||
import * as metadataReader from '../ai-vault/session-scanner-codex-cached-metadata'
|
||||
|
||||
it.each(['same', 'different'])(
|
||||
'checks delayed cold metadata refresh against a newer %s Codex session',
|
||||
async (identity) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'search-metadata-race-'))
|
||||
const store = new SessionSearchStore(join(root, 'index.sqlite'))
|
||||
let release = () => {}
|
||||
let refreshing: Promise<void> | undefined
|
||||
let parsing: Promise<unknown> | undefined
|
||||
try {
|
||||
resetSessionParseCacheForTests()
|
||||
resetCodexSessionIndexTitleCacheForTests()
|
||||
registerSessionSearchIndexSink(store)
|
||||
const path = join(root, CODEX_ROLLOUT_FILE)
|
||||
const initial = `${codexRolloutLines(['echo'], 'output', 'raceneedle').join('\n')}\n`
|
||||
await writeFile(path, initial)
|
||||
const candidate = await sessionCandidate('codex', path, root)
|
||||
await parseSearchCandidates(store, [candidate])
|
||||
resetSessionParseCacheForTests()
|
||||
await writeFile(
|
||||
join(root, 'session_index.jsonl'),
|
||||
`${JSON.stringify({ id: CODEX_SESSION_ID, thread_name: 'renamed race title' })}\n`
|
||||
)
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let entered = () => {}
|
||||
const reached = new Promise<void>((resolve) => {
|
||||
entered = resolve
|
||||
})
|
||||
const original = metadataReader.refreshCachedCodexMetadata
|
||||
vi.spyOn(metadataReader, 'refreshCachedCodexMetadata').mockImplementationOnce(
|
||||
async (candidate, metadata) => {
|
||||
const updated = await original(candidate, metadata)
|
||||
entered()
|
||||
await held
|
||||
return updated
|
||||
}
|
||||
)
|
||||
refreshing = parseSearchCandidates(store, [candidate])
|
||||
await reached
|
||||
await writeFile(
|
||||
path,
|
||||
initial
|
||||
.replace('/repo/app', '/repo/new-workspace')
|
||||
.replace('"branch":"main"', '"branch":"new-branch"')
|
||||
.replace(
|
||||
CODEX_SESSION_ID,
|
||||
identity === 'same' ? CODEX_SESSION_ID : '019f0000-2222-7222-8333-444444444444'
|
||||
)
|
||||
)
|
||||
const current = await sessionCandidate('codex', path, root)
|
||||
const readStream = vi.spyOn(sourceRead, 'openTranscriptReadStream')
|
||||
parsing = parseAgentSessionFileCached(current, process.platform)
|
||||
await Promise.resolve()
|
||||
expect(readStream).not.toHaveBeenCalled()
|
||||
expect(store.indexedMetadata(path)!.cwd).toBe('/repo/app')
|
||||
release()
|
||||
await Promise.all([refreshing, parsing])
|
||||
const afterRelease = store.indexedMetadata(path)!
|
||||
expect(afterRelease.cwd).toBe('/repo/new-workspace')
|
||||
expect(afterRelease.branch).toBe('new-branch')
|
||||
} finally {
|
||||
release()
|
||||
await Promise.allSettled([refreshing, parsing])
|
||||
vi.restoreAllMocks()
|
||||
registerSessionSearchIndexSink(null)
|
||||
store.close()
|
||||
resetSessionParseCacheForTests()
|
||||
resetCodexSessionIndexTitleCacheForTests()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
|
||||
import type { OpenCodeSqliteParentMessage } from '../ai-vault/session-scanner-opencode-sqlite-worker-protocol'
|
||||
import { it, expect, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { OpenCodeSqliteWorkerClient } from '../ai-vault/session-scanner-opencode-sqlite-worker-client'
|
||||
import { OpenCodeWorkerSearchCapture } from '../ai-vault/session-search-opencode-worker-capture'
|
||||
import {
|
||||
withSessionSearchIndexRequired,
|
||||
withStreamingSessionSearchCapture
|
||||
} from '../ai-vault/session-search-capture'
|
||||
import { parseOpenCodeSqliteSession } from '../ai-vault/session-scanner-opencode-sqlite'
|
||||
import {
|
||||
applyOpenCodeSqliteSchema,
|
||||
insertOpenCodeMessage,
|
||||
insertOpenCodePart,
|
||||
insertOpenCodeSession
|
||||
} from '../ai-vault/session-scanner-opencode-sqlite-fixtures'
|
||||
import { isolatedScanRoots } from '../ai-vault/session-scanner-test-fixtures'
|
||||
import { SessionSearchService } from './session-search-service'
|
||||
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
|
||||
const injected = vi.hoisted(() => ({
|
||||
client: null as OpenCodeSqliteWorkerClient | null,
|
||||
candidate: null as SessionFileCandidate | null
|
||||
}))
|
||||
vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', () => ({
|
||||
parseOpenCodeSqliteSessionViaWorker: (args: Parameters<OpenCodeSqliteWorkerClient['parse']>[0]) =>
|
||||
injected.client!.parse(args)
|
||||
}))
|
||||
vi.mock('../ai-vault/session-scanner-source-discovery', () => ({
|
||||
discoverAiVaultSessionSources: async () => []
|
||||
}))
|
||||
vi.mock('../ai-vault/session-scanner-candidates', () => ({
|
||||
sessionCandidatesFromDiscoveries: async () => [injected.candidate]
|
||||
}))
|
||||
class LoopbackWorker extends EventEmitter {
|
||||
capture: OpenCodeWorkerSearchCapture | null = null
|
||||
ack: Extract<OpenCodeSqliteParentMessage, { kind: 'captureAck' }> | null = null
|
||||
batches = 0
|
||||
produced = 0
|
||||
terminated = false
|
||||
controller = new AbortController()
|
||||
finished: Promise<void> = Promise.resolve()
|
||||
unref() {}
|
||||
async terminate() {
|
||||
this.terminated = true
|
||||
// Model thread termination by unwinding the real SQLite producer at its suspended checkpoint.
|
||||
this.controller.abort()
|
||||
if (this.ack) {
|
||||
this.releaseAck()
|
||||
}
|
||||
await this.finished
|
||||
return 0
|
||||
}
|
||||
postMessage(request: OpenCodeSqliteParentMessage) {
|
||||
if (request.kind === 'captureAck') {
|
||||
this.ack = request
|
||||
return
|
||||
}
|
||||
if (request.kind !== 'parse') {
|
||||
throw new Error('Expected parse')
|
||||
}
|
||||
this.capture = new OpenCodeWorkerSearchCapture(request.id, (batch) => {
|
||||
this.batches++
|
||||
setImmediate(() => this.emit('message', structuredClone(batch)))
|
||||
})
|
||||
const capture = this.capture
|
||||
const source = {
|
||||
push: (message: Parameters<typeof capture.push>[0]) => {
|
||||
this.produced++
|
||||
capture.push(message)
|
||||
},
|
||||
checkpoint: () => capture.checkpoint()
|
||||
}
|
||||
this.finished = withSessionSearchIndexRequired(
|
||||
() => withStreamingSessionSearchCapture(source, () => parseOpenCodeSqliteSession(request)),
|
||||
this.controller.signal
|
||||
)
|
||||
.then(async (session) => {
|
||||
await capture.flush()
|
||||
this.emit('message', { id: request.id, ok: true, value: { session } })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!this.terminated) {
|
||||
this.emit('error', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
releaseAck() {
|
||||
const ack = this.ack
|
||||
this.ack = null
|
||||
this.capture!.acknowledge(ack!.batch)
|
||||
}
|
||||
}
|
||||
it('pauses an OpenCode pass at the outstanding batch without draining or publishing the source', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-capture-cancel-'))
|
||||
const dbPath = join(root, 'opencode.db'),
|
||||
id = 'ses_cancelproof'
|
||||
const db = new Database(dbPath)
|
||||
applyOpenCodeSqliteSchema(db)
|
||||
const now = Date.now()
|
||||
insertOpenCodeSession(db, { id, timeCreated: now, timeUpdated: now })
|
||||
insertOpenCodeMessage(db, { id: 'm', sessionId: id, role: 'user', timeCreated: now })
|
||||
db.exec('BEGIN')
|
||||
for (let n = 0; n < 40; n++) {
|
||||
insertOpenCodePart(db, {
|
||||
id: `p${n}`,
|
||||
sessionId: id,
|
||||
messageId: 'm',
|
||||
timeCreated: now + n,
|
||||
text: 'x'.repeat(190000)
|
||||
})
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
db.close()
|
||||
const worker = new LoopbackWorker()
|
||||
injected.client = new OpenCodeSqliteWorkerClient({
|
||||
workerFactory: () => worker as unknown as Worker
|
||||
})
|
||||
injected.candidate = {
|
||||
agent: 'opencode',
|
||||
codexHome: null,
|
||||
file: { path: `${dbPath}#${id}`, mtimeMs: now, modifiedAt: new Date(now).toISOString() }
|
||||
}
|
||||
resetSessionParseCacheForTests()
|
||||
const service = new SessionSearchService({
|
||||
databasePath: join(root, 'index.sqlite'),
|
||||
enabled: true,
|
||||
historyDays: null
|
||||
})
|
||||
const roots = isolatedScanRoots(root)
|
||||
try {
|
||||
const pass = service.ensureBackfill(roots)
|
||||
await vi.waitFor(() => expect(worker.ack).not.toBeNull())
|
||||
const atPause = worker.produced
|
||||
const pause = service.configure({ enabled: true, paused: true, historyDays: null }, roots)
|
||||
await pause
|
||||
await pass
|
||||
await worker.finished
|
||||
expect(atPause).toBe(2)
|
||||
expect(worker.produced).toBe(atPause)
|
||||
expect(worker.batches).toBe(1)
|
||||
expect(worker.terminated).toBe(true)
|
||||
expect(service.coverage().sessionsIndexed).toBe(0)
|
||||
} finally {
|
||||
await service.close()
|
||||
resetSessionParseCacheForTests()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import { inSessionParseFileLane } from '../ai-vault/session-parse-file-lane'
|
||||
import { refreshCachedCodexMetadata } from '../ai-vault/session-scanner-codex-cached-metadata'
|
||||
import { getSessionParseCacheEntry } from '../ai-vault/session-parse-cache-store'
|
||||
import { fileIdentity } from '../ai-vault/session-scanner-resume-point'
|
||||
import { withCursorChatMetaScan } from '../ai-vault/session-scanner-cursor-chat-meta'
|
||||
import { withSessionSearchIndexRequired } from '../ai-vault/session-search-capture'
|
||||
import {
|
||||
isSessionSearchFileCurrent,
|
||||
withSessionSearchIndexRequired
|
||||
} from '../ai-vault/session-search-capture'
|
||||
import {
|
||||
createSessionParseStats,
|
||||
parseAgentSessionFileCached
|
||||
@@ -27,8 +34,39 @@ export async function parseSearchCandidates(
|
||||
const failures = store.failures
|
||||
let failed = false
|
||||
try {
|
||||
await parseAgentSessionFileCached(candidate, process.platform, stats)
|
||||
// Retain cached metadata refreshes, but a cold index-only pass needs no preview parse.
|
||||
if (
|
||||
getSessionParseCacheEntry(candidate.file.path) ||
|
||||
!isSessionSearchFileCurrent(
|
||||
store.indexedFile(candidate.file.path, fileIdentity(candidate.file)),
|
||||
candidate.file
|
||||
)
|
||||
) {
|
||||
await parseAgentSessionFileCached(candidate, process.platform, stats)
|
||||
} else if (candidate.agent === 'codex') {
|
||||
await inSessionParseFileLane(candidate.file.path, async () => {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (
|
||||
!store.acceptsCandidate(candidate) ||
|
||||
!isSessionSearchFileCurrent(
|
||||
store.indexedFile(candidate.file.path, fileIdentity(candidate.file)),
|
||||
candidate.file
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const metadata = store.indexedMetadata(candidate.file.path)
|
||||
if (metadata) {
|
||||
const refreshed = await refreshCachedCodexMetadata(candidate, metadata)
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (refreshed !== metadata) {
|
||||
store.updateMetadata(candidate, refreshed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
failed = true
|
||||
store.recordParseFailure(candidate.agent)
|
||||
console.warn(
|
||||
@@ -47,6 +85,6 @@ export async function parseSearchCandidates(
|
||||
await pauseBackfill(signal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, signal)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join } from 'node:path'
|
||||
import type { AiVaultSessionSearchInit } from '../ai-vault/session-scanner-service-protocol'
|
||||
import { getSessionSearchPolicy } from './session-search-policy'
|
||||
import { sessionSearchCapability } from './session-search-capability'
|
||||
|
||||
// Why: like the parse cache, the index path is captured once at the composition
|
||||
// root from the canonical userData dir; every export is a no-op until then.
|
||||
@@ -16,7 +17,13 @@ export function getSessionSearchDatabasePath(): string | null {
|
||||
|
||||
/** Read fresh on every spawn so a consent change reaches a restarted child. */
|
||||
export function getSessionSearchInitOptions(): AiVaultSessionSearchInit | null {
|
||||
return databasePath ? { databasePath, ...getSessionSearchPolicy() } : null
|
||||
return databasePath
|
||||
? {
|
||||
databasePath,
|
||||
...getSessionSearchPolicy(),
|
||||
...(!sessionSearchCapability().available ? { enabled: false } : {})
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export function resetSessionSearchPathsForTests(): void {
|
||||
|
||||
@@ -70,3 +70,56 @@ it('does not start already-cancelled work and retries failures', async () => {
|
||||
await expect(lane.run({}, refresh)).rejects.toThrow('scan failure')
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drains abandoned work even when a same-root replacement finishes first', async () => {
|
||||
const lane = new SessionSearchRefreshLane()
|
||||
const started = barrier()
|
||||
const cleanup = barrier()
|
||||
const controller = new AbortController()
|
||||
const abandoned = lane.run(
|
||||
{},
|
||||
async () => {
|
||||
started.release()
|
||||
await cleanup.promise
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
await started.promise
|
||||
controller.abort()
|
||||
await expect(abandoned).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await lane.run({}, async () => {})
|
||||
let drained = false
|
||||
const draining = lane.drain().then(() => {
|
||||
drained = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(drained).toBe(false)
|
||||
cleanup.release()
|
||||
await draining
|
||||
expect(drained).toBe(true)
|
||||
})
|
||||
|
||||
it('drains work removed by explicit cancellation, including rejected cleanup', async () => {
|
||||
const lane = new SessionSearchRefreshLane()
|
||||
const started = barrier()
|
||||
const cleanup = barrier()
|
||||
const running = lane
|
||||
.run({}, async () => {
|
||||
started.release()
|
||||
await cleanup.promise
|
||||
throw new Error('cleanup failure')
|
||||
})
|
||||
.catch((error) => error)
|
||||
await started.promise
|
||||
lane.cancel()
|
||||
let drained = false
|
||||
const draining = lane.drain().then(() => {
|
||||
drained = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(drained).toBe(false)
|
||||
cleanup.release()
|
||||
expect(await running).toMatchObject({ message: 'cleanup failure' })
|
||||
await draining
|
||||
await lane.run({}, async () => {})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ type Refresh = { controller: AbortController; promise: Promise<void>; users: num
|
||||
/** Share concurrent query refreshes, never completed filesystem snapshots. */
|
||||
export class SessionSearchRefreshLane {
|
||||
private readonly runs = new Map<string, Refresh>()
|
||||
private readonly outstanding = new Set<Refresh>()
|
||||
|
||||
async run(
|
||||
roots: SessionSearchScanRoots,
|
||||
@@ -29,11 +30,13 @@ export class SessionSearchRefreshLane {
|
||||
return refresh(controller.signal)
|
||||
})
|
||||
.finally(() => {
|
||||
this.outstanding.delete(current)
|
||||
if (this.runs.get(key) === current) {
|
||||
this.runs.delete(key)
|
||||
}
|
||||
})
|
||||
this.runs.set(key, run)
|
||||
this.outstanding.add(run)
|
||||
}
|
||||
run.users++
|
||||
try {
|
||||
@@ -55,6 +58,12 @@ export class SessionSearchRefreshLane {
|
||||
}
|
||||
this.runs.clear()
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
const pending = [...this.outstanding].map((run) => run.promise)
|
||||
this.cancel()
|
||||
await Promise.allSettled(pending)
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverRecentSearchFiles(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mkdtemp, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import {
|
||||
SESSION_SEARCH_SCHEMA_VERSION,
|
||||
openSessionSearchDatabase,
|
||||
@@ -84,3 +84,18 @@ describe('openSessionSearchDatabase', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the SQLite handle when corrupt data fails initialization', async () => {
|
||||
const path = await tempDatabasePath()
|
||||
await writeFile(path, 'not a SQLite database')
|
||||
const close = vi.spyOn(SyncDatabase.prototype, 'close')
|
||||
try {
|
||||
expect(() => openSessionSearchDatabase(path)).toThrow()
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
close.mockRestore()
|
||||
}
|
||||
removeSessionSearchDatabase(path)
|
||||
const recovered = openSessionSearchDatabase(path)
|
||||
recovered.close()
|
||||
})
|
||||
|
||||
@@ -102,14 +102,19 @@ export function openSessionSearchDatabase(path: string): SyncDatabase {
|
||||
|
||||
function openWithPragmas(path: string): SyncDatabase {
|
||||
const db = new SyncDatabase(path)
|
||||
// Why: only takes effect on an empty file; it is what lets a purge hand pages
|
||||
// back in bounded steps instead of a full VACUUM. Set before any table exists.
|
||||
db.pragma('auto_vacuum = INCREMENTAL')
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('synchronous = NORMAL')
|
||||
db.pragma('journal_size_limit = 8388608')
|
||||
db.pragma('busy_timeout = 5000')
|
||||
return db
|
||||
try {
|
||||
// Why: only takes effect on an empty file; it is what lets a purge hand pages
|
||||
// back in bounded steps instead of a full VACUUM. Set before any table exists.
|
||||
db.pragma('auto_vacuum = INCREMENTAL')
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('synchronous = NORMAL')
|
||||
db.pragma('journal_size_limit = 8388608')
|
||||
db.pragma('busy_timeout = 5000')
|
||||
return db
|
||||
} catch (error) {
|
||||
db.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSessionSearchDatabase(path: string): void {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { searchPresentSessionSources } from './session-search-source-presence'
|
||||
import { SessionSearchRefreshLane, discoverRecentSearchFiles } from './session-search-refresh-lane'
|
||||
import { recordSearchDiscovered } from './session-search-discovered-counts'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
@@ -87,7 +88,12 @@ export class SessionSearchService {
|
||||
)
|
||||
}
|
||||
void backfill
|
||||
return store.search(args)
|
||||
return await searchPresentSessionSources(
|
||||
args,
|
||||
(query) => store.search(query),
|
||||
(paths) => this.invalidate(paths),
|
||||
signal
|
||||
)
|
||||
} finally {
|
||||
this.searchesInFlight -= 1
|
||||
this.releaseBackfill?.()
|
||||
@@ -207,6 +213,10 @@ export class SessionSearchService {
|
||||
this.closeStore()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.stop({ drainRefreshes: true })
|
||||
}
|
||||
|
||||
private open(): SessionSearchStore {
|
||||
mkdirSync(dirname(this.databasePath), { recursive: true })
|
||||
this.store = new SessionSearchStore(this.databasePath)
|
||||
@@ -218,9 +228,11 @@ export class SessionSearchService {
|
||||
}
|
||||
|
||||
/** Waits for the aborted backfill so its last parse cannot write to a closed store. */
|
||||
private async stop(options: { keepStore?: boolean } = {}): Promise<void> {
|
||||
private async stop(
|
||||
options: { keepStore?: boolean; drainRefreshes?: boolean } = {}
|
||||
): Promise<void> {
|
||||
this.stopping = true
|
||||
this.refreshLane.cancel()
|
||||
const refreshes = options.drainRefreshes ? this.refreshLane.drain() : this.refreshLane.cancel()
|
||||
this.store?.setAcceptingWrites(false)
|
||||
try {
|
||||
this.backfillController?.abort()
|
||||
@@ -229,6 +241,7 @@ export class SessionSearchService {
|
||||
if (run) {
|
||||
await run.catch(() => undefined)
|
||||
}
|
||||
await refreshes
|
||||
if (!options.keepStore) {
|
||||
this.closeStore()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { searchPresentSessionSources } from './session-search-source-presence'
|
||||
import type { AiVaultSearchHit, AiVaultSearchResult } from '../../shared/ai-vault-search-types'
|
||||
|
||||
function searchSources(result: AiVaultSearchResult, invalidate: (paths: string[]) => void) {
|
||||
return searchPresentSessionSources({ query: 'fixture', limit: 100 }, () => result, invalidate)
|
||||
}
|
||||
|
||||
it('checks individual OpenCode identities when several sessions share one database file', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-search-source-'))
|
||||
try {
|
||||
const filePath = join(root, 'opencode.db')
|
||||
const database = new Database(filePath)
|
||||
database.exec(
|
||||
"CREATE TABLE session(id TEXT PRIMARY KEY, time_archived INTEGER); INSERT INTO session VALUES ('live',NULL),('archived',123)"
|
||||
)
|
||||
database.close()
|
||||
const hit = (sessionId: string): AiVaultSearchHit => ({
|
||||
agent: 'opencode',
|
||||
filePath,
|
||||
sessionId,
|
||||
title: 'synthetic',
|
||||
cwd: root,
|
||||
codexHome: null,
|
||||
branch: null,
|
||||
updatedAt: null,
|
||||
messageCount: 1,
|
||||
resumeCommand: 'opencode',
|
||||
score: 0,
|
||||
evidence: { role: 'user', timestamp: null, snippet: 'owned fixture' }
|
||||
})
|
||||
const result: AiVaultSearchResult = {
|
||||
hits: [hit('live'), hit('removed'), hit('archived')],
|
||||
route: 'phrase',
|
||||
durationMs: 0,
|
||||
coverage: {
|
||||
sessionsIndexed: 3,
|
||||
messagesIndexed: 3,
|
||||
providers: [],
|
||||
backfill: 'complete',
|
||||
filesPending: 0,
|
||||
lastIndexedAt: null
|
||||
}
|
||||
}
|
||||
const invalidate = vi.fn()
|
||||
expect((await searchSources(result, invalidate)).hits.map((value) => value.sessionId)).toEqual([
|
||||
'live'
|
||||
])
|
||||
expect(invalidate.mock.calls).toEqual([[[`${filePath}#removed`]], [[`${filePath}#archived`]]])
|
||||
await rm(filePath)
|
||||
expect((await searchSources(result, invalidate)).hits).toEqual([])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('omits unreadable sources without treating them as confirmed deletion', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-search-unreadable-'))
|
||||
try {
|
||||
const filePath = join(root, 'opencode.db')
|
||||
const database = new Database(filePath)
|
||||
database.exec('CREATE TABLE unexpected_schema(id TEXT)')
|
||||
database.close()
|
||||
const invalidate = vi.fn()
|
||||
const result = await searchSources(
|
||||
{
|
||||
hits: [{ agent: 'opencode', filePath, sessionId: 'fixture' } as AiVaultSearchHit],
|
||||
coverage: {}
|
||||
} as AiVaultSearchResult,
|
||||
invalidate
|
||||
)
|
||||
expect(result).toMatchObject({ hits: [], sourceUnavailableFiles: 1 })
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
AI_VAULT_SEARCH_LIMIT_DEFAULT,
|
||||
AI_VAULT_SEARCH_LIMIT_MAX,
|
||||
type AiVaultSearchArgs,
|
||||
type AiVaultSearchHit,
|
||||
type AiVaultSearchResult
|
||||
} from '../../shared/ai-vault-search-types'
|
||||
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import {
|
||||
buildOpenCodeSqliteCandidatePath,
|
||||
splitOpenCodeSqliteCandidate
|
||||
} from '../ai-vault/session-scanner-opencode-sqlite-paths'
|
||||
import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open'
|
||||
import { columnExists } from '../opencode-usage/schema-helpers'
|
||||
|
||||
const MAX_PRESENCE_QUERIES = 4
|
||||
|
||||
type Presence = 'present' | 'missing' | 'unverifiable'
|
||||
type Invalidate = (paths: string[]) => void
|
||||
|
||||
function candidatePath(hit: AiVaultSearchHit): string {
|
||||
const sqlite = buildOpenCodeSqliteCandidatePath(hit.filePath, hit.sessionId)
|
||||
return hit.agent === 'opencode' && splitOpenCodeSqliteCandidate(sqlite) ? sqlite : hit.filePath
|
||||
}
|
||||
|
||||
async function checkSearchSources(
|
||||
hits: AiVaultSearchHit[],
|
||||
known: Map<string, Presence>,
|
||||
unavailable: Set<string>,
|
||||
invalidate: Invalidate,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const byPath = new Map<string, AiVaultSearchHit[]>()
|
||||
for (const hit of hits) {
|
||||
if (!known.has(candidatePath(hit))) {
|
||||
const group = byPath.get(hit.filePath) ?? []
|
||||
group.push(hit)
|
||||
byPath.set(hit.filePath, group)
|
||||
}
|
||||
}
|
||||
await mapWithConcurrency([...byPath], 4, async ([path, group]) => {
|
||||
throwIfSignalAborted(signal)
|
||||
const outcomes = new Map<string, Presence>()
|
||||
try {
|
||||
await wslGatedStat(path, 'scan', signal)
|
||||
throwIfSignalAborted(signal)
|
||||
const sqlite = group.filter((hit) => candidatePath(hit) !== path)
|
||||
if (sqlite.length) {
|
||||
// Windows cannot reliably lock SQLite on a WSL share; never open it on this thread.
|
||||
if (isWslUncPath(path)) {
|
||||
for (const hit of sqlite) {
|
||||
outcomes.set(candidatePath(hit), 'unverifiable')
|
||||
}
|
||||
} else {
|
||||
readOpenCodeDatabase({
|
||||
dbPath: path,
|
||||
read: (db) => {
|
||||
const live = db.prepare(
|
||||
`SELECT id FROM session WHERE id = ?${columnExists(db, 'session', 'time_archived') ? ' AND time_archived IS NULL' : ''}`
|
||||
)
|
||||
for (const hit of sqlite) {
|
||||
outcomes.set(candidatePath(hit), live.get(hit.sessionId) ? 'present' : 'missing')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const hit of group) {
|
||||
if (candidatePath(hit) === path) {
|
||||
outcomes.set(path, 'present')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throwIfSignalAborted(signal)
|
||||
const missing =
|
||||
!isWslUncPath(path) &&
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
outcomes.clear()
|
||||
for (const hit of group) {
|
||||
outcomes.set(candidatePath(hit), missing ? 'missing' : 'unverifiable')
|
||||
}
|
||||
}
|
||||
throwIfSignalAborted(signal)
|
||||
for (const [key, presence] of outcomes) {
|
||||
known.set(key, presence)
|
||||
if (presence === 'missing') {
|
||||
invalidate([key])
|
||||
}
|
||||
if (presence === 'unverifiable') {
|
||||
unavailable.add(path)
|
||||
}
|
||||
}
|
||||
})
|
||||
throwIfSignalAborted(signal)
|
||||
}
|
||||
|
||||
/** Refill limited results after filtering, without deleting unreadable sources or unbounded scans. */
|
||||
export async function searchPresentSessionSources(
|
||||
args: AiVaultSearchArgs,
|
||||
search: (args: AiVaultSearchArgs) => AiVaultSearchResult,
|
||||
invalidate: Invalidate,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSearchResult> {
|
||||
const wanted = Math.min(
|
||||
AI_VAULT_SEARCH_LIMIT_MAX,
|
||||
Math.max(1, Number.isInteger(args.limit) ? args.limit! : AI_VAULT_SEARCH_LIMIT_DEFAULT)
|
||||
)
|
||||
const known = new Map<string, Presence>()
|
||||
const unavailable = new Set<string>()
|
||||
let limit = wanted
|
||||
let durationMs = 0
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
throwIfSignalAborted(signal)
|
||||
const result = search({ ...args, limit })
|
||||
durationMs += result.durationMs
|
||||
await checkSearchSources(result.hits, known, unavailable, invalidate, signal)
|
||||
const hits = result.hits.filter((hit) => known.get(candidatePath(hit)) === 'present')
|
||||
const missing = result.hits.some((hit) => known.get(candidatePath(hit)) === 'missing')
|
||||
const exhausted = result.hits.length < limit && !missing
|
||||
const budgetExhausted =
|
||||
attempt === MAX_PRESENCE_QUERIES || (limit === AI_VAULT_SEARCH_LIMIT_MAX && !missing)
|
||||
if (hits.length >= wanted || exhausted || budgetExhausted) {
|
||||
const omitted =
|
||||
budgetExhausted && !exhausted && hits.length < wanted
|
||||
? Math.max(1, result.hits.length - hits.length)
|
||||
: 0
|
||||
return {
|
||||
...result,
|
||||
hits: hits.slice(0, wanted),
|
||||
durationMs,
|
||||
...(unavailable.size ? { sourceUnavailableFiles: unavailable.size } : {}),
|
||||
...(omitted ? { omittedHits: (result.omittedHits ?? 0) + omitted } : {})
|
||||
}
|
||||
}
|
||||
limit = Math.min(AI_VAULT_SEARCH_LIMIT_MAX, limit * 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as transcriptFs from '../native-chat/wsl-transcript-fs-access'
|
||||
import { SessionSearchStore } from './session-search-store'
|
||||
import { stagedWriteUpdate } from './session-search-staged-write-fixtures'
|
||||
import { searchPresentSessionSources } from './session-search-source-presence'
|
||||
import type { AiVaultSearchArgs, AiVaultSearchResult } from '../../shared/ai-vault-search-types'
|
||||
|
||||
const cleanup: (() => Promise<void>)[] = []
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
for (const dispose of cleanup.splice(0)) {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ss-presence-refill-'))
|
||||
const store = new SessionSearchStore(join(root, 'index.sqlite'))
|
||||
cleanup.push(async () => {
|
||||
store.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
for (const name of ['first', 'second']) {
|
||||
const path = join(root, `${name}.jsonl`)
|
||||
await writeFile(path, 'synthetic fixture')
|
||||
const update = stagedWriteUpdate(`refillneedle ${name}`, 1)
|
||||
update.candidate.file.path = path
|
||||
update.session = { ...update.session!, filePath: path, sessionId: name }
|
||||
await store.apply(update)
|
||||
}
|
||||
const search = vi.fn((args: AiVaultSearchArgs) => store.search(args))
|
||||
const invalidate = vi.fn((paths: string[]) => paths.forEach((path) => store.removeFile(path)))
|
||||
return { root, store, search, invalidate }
|
||||
}
|
||||
|
||||
it('refills a deleted top hit in the first query with a real index', async () => {
|
||||
const { store, search, invalidate } = await fixture()
|
||||
const args = { query: 'refillneedle', limit: 1 }
|
||||
const top = store.search(args).hits[0]
|
||||
await rm(top.filePath)
|
||||
const result = await searchPresentSessionSources(args, search, invalidate)
|
||||
expect(result.hits).toHaveLength(1)
|
||||
expect(result.hits[0].filePath).not.toBe(top.filePath)
|
||||
expect(invalidate).toHaveBeenCalledWith([top.filePath])
|
||||
expect(store.search({ query: 'refillneedle' }).hits).toHaveLength(1)
|
||||
expect(result.omittedHits).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refills past an unreadable top hit without deleting it or recounting it', async () => {
|
||||
const { store, search, invalidate } = await fixture()
|
||||
const args = { query: 'refillneedle', limit: 1 }
|
||||
const top = store.search(args).hits[0]
|
||||
const stat = transcriptFs.wslGatedStat
|
||||
const probe = vi
|
||||
.spyOn(transcriptFs, 'wslGatedStat')
|
||||
.mockImplementation((path, priority, signal) => {
|
||||
if (path === top.filePath) {
|
||||
return Promise.reject(Object.assign(new Error('denied'), { code: 'EACCES' }))
|
||||
}
|
||||
return stat(path, priority, signal)
|
||||
})
|
||||
const result = await searchPresentSessionSources(args, search, invalidate)
|
||||
expect(result.hits).toHaveLength(1)
|
||||
expect(result.hits[0].filePath).not.toBe(top.filePath)
|
||||
expect(result.sourceUnavailableFiles).toBe(1)
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
expect(store.search({ query: 'refillneedle' }).hits).toHaveLength(2)
|
||||
expect(probe.mock.calls.filter(([path]) => path === top.filePath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bounds refill and reports omissions when unavailable hits consume the budget', async () => {
|
||||
const { store } = await fixture()
|
||||
const base = store.search({ query: 'refillneedle' })
|
||||
const source = base.hits[0]
|
||||
const hits = Array.from({ length: 100 }, (_, i) => ({
|
||||
...source,
|
||||
filePath: join(source.filePath, String(i))
|
||||
}))
|
||||
vi.spyOn(transcriptFs, 'wslGatedStat').mockRejectedValue(
|
||||
Object.assign(new Error('denied'), { code: 'EACCES' })
|
||||
)
|
||||
const search = vi.fn((args: AiVaultSearchArgs) => ({ ...base, hits: hits.slice(0, args.limit) }))
|
||||
const invalidate = vi.fn()
|
||||
const result = await searchPresentSessionSources(
|
||||
{ query: 'refillneedle', limit: 1 },
|
||||
search,
|
||||
invalidate
|
||||
)
|
||||
expect(search).toHaveBeenCalledTimes(4)
|
||||
expect(search.mock.calls.map(([args]) => args.limit)).toEqual([1, 2, 4, 8])
|
||||
expect(result).toMatchObject({ hits: [], omittedHits: 8, sourceUnavailableFiles: 8 })
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invalidate a WSL source when its share reports ENOENT', async () => {
|
||||
const { store } = await fixture()
|
||||
const base = store.search({ query: 'refillneedle', limit: 1 })
|
||||
const filePath = String.raw`\\wsl.localhost\Ubuntu\home\fixture\missing.jsonl`
|
||||
const result: AiVaultSearchResult = { ...base, hits: [{ ...base.hits[0], filePath }] }
|
||||
vi.spyOn(transcriptFs, 'wslGatedStat').mockRejectedValue(
|
||||
Object.assign(new Error('share offline'), { code: 'ENOENT' })
|
||||
)
|
||||
const invalidate = vi.fn()
|
||||
expect(
|
||||
await searchPresentSessionSources({ query: 'refillneedle' }, () => result, invalidate)
|
||||
).toMatchObject({ hits: [], sourceUnavailableFiles: 1 })
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invalidate or refill after cancellation during a presence probe', async () => {
|
||||
const { search, invalidate } = await fixture()
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const controller = new AbortController()
|
||||
vi.spyOn(transcriptFs, 'wslGatedStat').mockImplementation(async () => {
|
||||
await held
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
})
|
||||
const pending = searchPresentSessionSources(
|
||||
{ query: 'refillneedle', limit: 1 },
|
||||
search,
|
||||
invalidate,
|
||||
controller.signal
|
||||
)
|
||||
controller.abort()
|
||||
release()
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
expect(search).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('validates sources through the common service, including a paused restart', async () => {
|
||||
const { SessionSearchService } = await import('./session-search-service')
|
||||
const { isolatedScanRoots } = await import('../ai-vault/session-scanner-test-fixtures')
|
||||
const { userRecord } = await import('./session-search-transcript-fixtures')
|
||||
const { mkdir } = await import('node:fs/promises')
|
||||
const root = await mkdtemp(join(tmpdir(), 'ss-service-presence-'))
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(roots.claudeProjectsDir, { recursive: true })
|
||||
for (const name of ['first', 'second']) {
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, `${name}.jsonl`),
|
||||
`${userRecord(0, `servicepresenceneedle ${name}`)}\n`
|
||||
)
|
||||
}
|
||||
const databasePath = join(root, 'index.sqlite')
|
||||
let service = new SessionSearchService({ databasePath, enabled: true, historyDays: null })
|
||||
try {
|
||||
await service.ensureBackfill(roots)
|
||||
const args = { query: 'servicepresenceneedle', limit: 1, refresh: false }
|
||||
const first = await service.search(args, roots)
|
||||
expect(first.hits).toHaveLength(1)
|
||||
await rm(first.hits[0].filePath)
|
||||
const next = await service.search(args, roots)
|
||||
expect(next.hits).toHaveLength(1)
|
||||
expect(next.hits[0].filePath).not.toBe(first.hits[0].filePath)
|
||||
await service.close()
|
||||
await rm(next.hits[0].filePath)
|
||||
service = new SessionSearchService({
|
||||
databasePath,
|
||||
enabled: true,
|
||||
paused: true,
|
||||
historyDays: null
|
||||
})
|
||||
expect((await service.search(args, roots)).hits).toHaveLength(0)
|
||||
expect(service.coverage().indexing?.phase).toBe('paused')
|
||||
} finally {
|
||||
await service.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { SessionSearchIndexingProgress } from './session-search-indexing-progres
|
||||
import { SessionSearchMaintenance } from './session-search-maintenance'
|
||||
import { recoverSearchWrites } from './session-search-write-recovery'
|
||||
import { deleteExpiredSearchFiles } from './session-search-retention-delete'
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import { aiVaultSearchHistoryCutoffMs } from '../../shared/ai-vault-search-settings'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import type {
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
SessionSearchIndexWrite
|
||||
} from '../ai-vault/session-search-capture'
|
||||
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
|
||||
import { SessionSearchIndexWriter } from './session-search-index-writer'
|
||||
import { SessionSearchIndexWriter, type SessionSearchMetadata } from './session-search-index-writer'
|
||||
import { SessionSearchQuery } from './session-search-query'
|
||||
import { openSessionSearchDatabase } from './session-search-schema'
|
||||
|
||||
@@ -96,7 +96,11 @@ export class SessionSearchStore implements SessionSearchIndexSink {
|
||||
)
|
||||
}
|
||||
|
||||
updateMetadata(candidate: SessionFileCandidate, session: AiVaultSession): void {
|
||||
indexedMetadata(path: string): SessionSearchMetadata | null {
|
||||
return this.writer.indexedMetadata(path)
|
||||
}
|
||||
|
||||
updateMetadata(candidate: SessionFileCandidate, session: SessionSearchMetadata): void {
|
||||
if (!this.acceptsCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
@@ -117,7 +121,10 @@ export class SessionSearchStore implements SessionSearchIndexSink {
|
||||
try {
|
||||
const applied = await this.writer.apply(
|
||||
update,
|
||||
() => epoch === this.writeEpoch && this.acceptsCandidate(update.candidate),
|
||||
() =>
|
||||
!update.signal?.aborted &&
|
||||
epoch === this.writeEpoch &&
|
||||
this.acceptsCandidate(update.candidate),
|
||||
undefined,
|
||||
() => !this.closed
|
||||
)
|
||||
@@ -132,6 +139,9 @@ export class SessionSearchStore implements SessionSearchIndexSink {
|
||||
this.lastIndexedAt = new Date().toISOString()
|
||||
} catch (error) {
|
||||
this.markStale(update.candidate)
|
||||
if (update.signal?.aborted) {
|
||||
return
|
||||
}
|
||||
this.applyFailures += 1
|
||||
this.indexing.writeFailed()
|
||||
this.onError(error)
|
||||
|
||||
@@ -6,10 +6,9 @@ import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
|
||||
|
||||
// Cache hits skip the parser entirely, so the same lazily-written Codex metadata
|
||||
// the parser folds in at finalize has to be re-applied to the restored session.
|
||||
export async function refreshCachedCodexMetadata(
|
||||
candidate: SessionFileCandidate,
|
||||
session: AiVaultSession
|
||||
): Promise<AiVaultSession> {
|
||||
export async function refreshCachedCodexMetadata<
|
||||
T extends Pick<AiVaultSession, 'sessionId' | 'title' | 'cwd' | 'branch' | 'updatedAt'>
|
||||
>(candidate: SessionFileCandidate, session: T): Promise<T> {
|
||||
const refreshed = await refreshCodexTitleFromIndex(session, (sessionId) =>
|
||||
readCodexSessionIndexTitle(candidate.file.path, candidate.codexHome, sessionId)
|
||||
)
|
||||
|
||||
@@ -10,10 +10,9 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
* `refreshCachedCodexMetadata`) and `remote-session-parse-cache.ts` (relay
|
||||
* provider, whose reader lives in `remote-session-scanner-codex-index.ts`).
|
||||
*/
|
||||
export async function refreshCodexTitleFromIndex(
|
||||
session: AiVaultSession,
|
||||
readIndexedTitle: (sessionId: string) => Promise<string | null>
|
||||
): Promise<AiVaultSession> {
|
||||
export async function refreshCodexTitleFromIndex<
|
||||
T extends Pick<AiVaultSession, 'sessionId' | 'title'>
|
||||
>(session: T, readIndexedTitle: (sessionId: string) => Promise<string | null>): Promise<T> {
|
||||
const title = await readIndexedTitle(session.sessionId)
|
||||
return title && title !== session.title ? { ...session, title } : session
|
||||
}
|
||||
|
||||
@@ -103,3 +103,16 @@ function unreadableShareAdvice(dbPath: string): string {
|
||||
? 'Windows cannot open SQLite databases over the \\\\wsl.localhost share, so this history has to be read from inside the distro.'
|
||||
: 'Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.'
|
||||
}
|
||||
|
||||
/** Keeps the read handle alive across capture backpressure. */
|
||||
export async function readOpenCodeDatabaseAsync<T>(args: {
|
||||
dbPath: string
|
||||
read: (db: SyncDatabase) => Promise<T>
|
||||
}): Promise<T> {
|
||||
const db = openOpenCodeDatabaseReadonly(args.dbPath)
|
||||
try {
|
||||
return await args.read(db)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,20 @@ import {
|
||||
} from './session-scanner-opencode-sqlite-worker-client'
|
||||
import type {
|
||||
OpenCodeSqliteParseValue,
|
||||
OpenCodeSqliteWorkerRequest,
|
||||
OpenCodeSqliteParentMessage,
|
||||
OpenCodeSqliteWorkerResponse
|
||||
} from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import { isSessionSearchCaptureActive, withSessionSearchCapture } from './session-search-capture'
|
||||
import {
|
||||
isSessionSearchCaptureActive,
|
||||
withSessionSearchCapture,
|
||||
withStreamingSessionSearchCapture
|
||||
} from './session-search-capture'
|
||||
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
||||
// A worker_threads stand-in the tests drive directly: it records posted requests
|
||||
// and lets a test emit message/error/exit without a built worker bundle.
|
||||
class FakeWorker {
|
||||
postedRequests: OpenCodeSqliteWorkerRequest[] = []
|
||||
postedRequests: OpenCodeSqliteParentMessage[] = []
|
||||
terminated = false
|
||||
unrefed = false
|
||||
private listeners = new Map<string, Set<(arg?: unknown) => void>>()
|
||||
@@ -48,7 +52,7 @@ class FakeWorker {
|
||||
return 1
|
||||
}
|
||||
|
||||
postMessage(request: OpenCodeSqliteWorkerRequest): void {
|
||||
postMessage(request: OpenCodeSqliteParentMessage): void {
|
||||
this.postedRequests.push(request)
|
||||
}
|
||||
|
||||
@@ -71,7 +75,7 @@ class FakeWorker {
|
||||
// The lifecycle cases only care which call settles, so they tag the session
|
||||
// with a sentinel and let the protocol shape carry it.
|
||||
function parseValue(sessionId: string): OpenCodeSqliteParseValue {
|
||||
return { session: sessionId as unknown as AiVaultSession, messages: [] }
|
||||
return { session: sessionId as unknown as AiVaultSession }
|
||||
}
|
||||
|
||||
function makeFactory(workers: FakeWorker[]): () => Worker {
|
||||
@@ -101,7 +105,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
|
||||
worker!.emit('message', {
|
||||
id: worker!.lastId(),
|
||||
ok: true,
|
||||
value: { session: { sessionId: 'a' }, messages: [] }
|
||||
value: { session: { sessionId: 'a' } }
|
||||
} satisfies OpenCodeSqliteWorkerResponse)
|
||||
|
||||
await expect(parsePromise).resolves.toEqual({ sessionId: 'a' })
|
||||
@@ -380,11 +384,17 @@ describe('OpenCodeSqliteWorkerClient search capture', () => {
|
||||
worker.emit('message', {
|
||||
id: worker.lastId(),
|
||||
ok: true,
|
||||
value: {
|
||||
session: { sessionId: 'a' },
|
||||
messages: [{ role: 'user', text: 'ballast tanks', timestamp: null }]
|
||||
}
|
||||
captureBatch: 1,
|
||||
value: [{ role: 'user', text: 'ballast tanks', timestamp: null }]
|
||||
} satisfies OpenCodeSqliteWorkerResponse)
|
||||
await vi.waitFor(() =>
|
||||
expect(worker.postedRequests.at(-1)).toMatchObject({ kind: 'captureAck', batch: 1 })
|
||||
)
|
||||
worker.emit('message', {
|
||||
id: worker.lastId(),
|
||||
ok: true,
|
||||
value: { session: { sessionId: 'a' } }
|
||||
})
|
||||
return parsePromise
|
||||
})
|
||||
|
||||
@@ -403,9 +413,87 @@ describe('OpenCodeSqliteWorkerClient search capture', () => {
|
||||
worker.emit('message', {
|
||||
id: worker.lastId(),
|
||||
ok: true,
|
||||
value: { session: null, messages: [] }
|
||||
value: { session: null }
|
||||
} satisfies OpenCodeSqliteWorkerResponse)
|
||||
|
||||
await expect(parsePromise).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('acknowledges capture only after the caller channel drains, including across async scopes', async () => {
|
||||
const workers: FakeWorker[] = []
|
||||
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
||||
let release!: () => void
|
||||
const drained = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const push = vi.fn()
|
||||
const checkpoint = vi.fn(() => drained)
|
||||
const parsed = withStreamingSessionSearchCapture({ push, checkpoint }, () =>
|
||||
client.parse({ dbPath: '/db', sessionId: 'a', platform: process.platform })
|
||||
)
|
||||
const worker = workers[0]!
|
||||
worker.emit('message', {
|
||||
id: worker.lastId(),
|
||||
ok: true,
|
||||
captureBatch: 1,
|
||||
value: [{ role: 'user', text: 'late marker', timestamp: null }]
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(push).toHaveBeenCalledOnce()
|
||||
expect(checkpoint).toHaveBeenCalledOnce()
|
||||
expect(worker.postedRequests).toHaveLength(1)
|
||||
release()
|
||||
await vi.waitFor(() =>
|
||||
expect(worker.postedRequests.at(-1)).toMatchObject({ kind: 'captureAck', batch: 1 })
|
||||
)
|
||||
worker.emit('message', { id: worker.lastId(), ok: true, value: { session: { sessionId: 'a' } } })
|
||||
expect(await parsed).toEqual({ sessionId: 'a' })
|
||||
})
|
||||
|
||||
it('rejects the parse and retires its worker when the capture consumer fails', async () => {
|
||||
const workers: FakeWorker[] = []
|
||||
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
||||
const parsed = withStreamingSessionSearchCapture(
|
||||
{
|
||||
push() {},
|
||||
checkpoint: async () => {
|
||||
throw new Error('capture stopped')
|
||||
}
|
||||
},
|
||||
() => client.parse({ dbPath: '/db', sessionId: 'a', platform: process.platform })
|
||||
)
|
||||
const rejected = expect(parsed).rejects.toThrow('capture stopped')
|
||||
workers[0]!.emit('message', { id: workers[0]!.lastId(), ok: true, captureBatch: 1, value: [] })
|
||||
await rejected
|
||||
expect(workers[0]!.terminated).toBe(true)
|
||||
expect(workers[0]!.postedRequests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('suspends the producer timeout during backpressure and restores it after acknowledgement', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const workers: FakeWorker[] = []
|
||||
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
||||
let release!: () => void
|
||||
const drained = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const parsed = withStreamingSessionSearchCapture({ push() {}, checkpoint: () => drained }, () =>
|
||||
client.parse({ dbPath: '/db', sessionId: 'a', platform: process.platform })
|
||||
)
|
||||
const failure = expect(parsed).rejects.toThrow('timed out')
|
||||
const worker = workers[0]!
|
||||
worker.emit('message', { id: worker.lastId(), ok: true, captureBatch: 1, value: [] })
|
||||
await vi.advanceTimersByTimeAsync(PARSE_TIMEOUT_MS + 1)
|
||||
expect(worker.terminated).toBe(false)
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(worker.postedRequests.at(-1)).toMatchObject({ kind: 'captureAck', batch: 1 })
|
||||
await vi.advanceTimersByTimeAsync(PARSE_TIMEOUT_MS)
|
||||
await failure
|
||||
expect(worker.terminated).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
OpenCodeSqliteListRequest,
|
||||
OpenCodeSqliteListValue,
|
||||
OpenCodeSqliteParseRequest,
|
||||
OpenCodeSqliteRequestBody,
|
||||
OpenCodeSqliteParseValue,
|
||||
OpenCodeSqliteWorkerRequest,
|
||||
OpenCodeSqliteWorkerResponse
|
||||
} from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import { captureSessionSearchMessage, isSessionSearchCaptureActive } from './session-search-capture'
|
||||
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
|
||||
import { isSessionSearchCaptureActive } from './session-search-capture'
|
||||
import {
|
||||
bindOpenCodeCaptureConsumer,
|
||||
bindOpenCodeCaptureCancellation,
|
||||
receiveOpenCodeCaptureBatch,
|
||||
type OpenCodePendingCall as PendingCall
|
||||
} from './session-search-opencode-worker-receiver'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
@@ -29,20 +34,6 @@ export const MAX_CONSECUTIVE_DEATHS = 3
|
||||
|
||||
export type WorkerFactory = () => Worker
|
||||
|
||||
// Omit<union, 'id'> collapses to the shared keys, so omit each member and let
|
||||
// the client stamp the correlation id.
|
||||
type OpenCodeSqliteRequestBody =
|
||||
| Omit<OpenCodeSqliteListRequest, 'id'>
|
||||
| Omit<OpenCodeSqliteParseRequest, 'id'>
|
||||
|
||||
type PendingCall = {
|
||||
request: OpenCodeSqliteWorkerRequest
|
||||
timeoutMs: number
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
}
|
||||
|
||||
// Distinguishes "no worker available at all" from a timeout or crash so callers
|
||||
// can surface a precise issue while keeping synchronous SQLite off the main thread.
|
||||
class OpenCodeSqliteWorkerUnavailableError extends Error {}
|
||||
@@ -141,12 +132,9 @@ export class OpenCodeSqliteWorkerClient {
|
||||
platform: args.platform,
|
||||
capture
|
||||
},
|
||||
PARSE_TIMEOUT_MS
|
||||
PARSE_TIMEOUT_MS,
|
||||
capture ? bindOpenCodeCaptureConsumer() : undefined
|
||||
)) as OpenCodeSqliteParseValue | null
|
||||
// Replay on this thread: the capture scope the index reads lives here.
|
||||
for (const message of value?.messages ?? []) {
|
||||
captureSessionSearchMessage(message)
|
||||
}
|
||||
return value?.session ?? null
|
||||
} catch (err) {
|
||||
if (err instanceof OpenCodeSqliteWorkerUnavailableError) {
|
||||
@@ -157,7 +145,11 @@ export class OpenCodeSqliteWorkerClient {
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(request: OpenCodeSqliteRequestBody, timeoutMs: number): Promise<unknown> {
|
||||
private dispatch(
|
||||
request: OpenCodeSqliteRequestBody,
|
||||
timeoutMs: number,
|
||||
capture?: PendingCall['capture']
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.nextId++
|
||||
// A fresh burst from full idle starts a new scan: clear any death count
|
||||
@@ -165,17 +157,27 @@ export class OpenCodeSqliteWorkerClient {
|
||||
if (!this.active && this.queue.length === 0) {
|
||||
this.consecutiveDeaths = 0
|
||||
}
|
||||
this.queue.push({
|
||||
request: { ...request, id } as OpenCodeSqliteWorkerRequest,
|
||||
const call: PendingCall = {
|
||||
request: { ...request, id } as PendingCall['request'],
|
||||
timeoutMs,
|
||||
resolve,
|
||||
reject,
|
||||
timer: null
|
||||
})
|
||||
...bindOpenCodeCaptureCancellation(resolve, reject, () => this.cancel(call)),
|
||||
timer: null,
|
||||
capture
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
private cancel(call: PendingCall): void {
|
||||
if (this.active === call) {
|
||||
this.destroyWorker()
|
||||
}
|
||||
this.queue = this.queue.filter((pending) => pending !== call)
|
||||
this.settle(call, () => call.reject(createAiVaultScanCancelledError()))
|
||||
this.afterSettle()
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.active || this.queue.length === 0) {
|
||||
return
|
||||
@@ -236,6 +238,17 @@ export class OpenCodeSqliteWorkerClient {
|
||||
if (!call || call.request.id !== response.id) {
|
||||
return
|
||||
}
|
||||
if (response.ok && response.captureBatch !== undefined) {
|
||||
receiveOpenCodeCaptureBatch({
|
||||
call,
|
||||
response,
|
||||
worker: this.worker,
|
||||
isActive: () => this.active === call,
|
||||
onTimeout: () => this.onTimeout(call),
|
||||
onError: (error) => this.onWorkerFault(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
this.consecutiveDeaths = 0
|
||||
if (response.ok) {
|
||||
this.settle(call, () => call.resolve(response.value))
|
||||
|
||||
@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import { withStreamingSessionSearchCapture } from './session-search-capture'
|
||||
import {
|
||||
applyOpenCodeSqliteSchema,
|
||||
insertOpenCodeMessage,
|
||||
@@ -11,24 +13,30 @@ import {
|
||||
} from './session-scanner-opencode-sqlite-fixtures'
|
||||
import type {
|
||||
OpenCodeSqliteParseValue,
|
||||
OpenCodeSqliteWorkerRequest,
|
||||
OpenCodeSqliteParentMessage,
|
||||
OpenCodeSqliteWorkerResponse
|
||||
} from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
|
||||
// A parent-port stand-in: the entry registers on it at import time, so the test
|
||||
// drives the worker loop without spawning a thread.
|
||||
const posted: OpenCodeSqliteWorkerResponse[] = []
|
||||
let handler: ((request: OpenCodeSqliteWorkerRequest) => void) | null = null
|
||||
let acknowledge = true
|
||||
let handler: ((request: OpenCodeSqliteParentMessage) => void) | null = null
|
||||
|
||||
vi.mock('node:worker_threads', () => ({
|
||||
parentPort: {
|
||||
on(event: string, listener: (request: OpenCodeSqliteWorkerRequest) => void) {
|
||||
on(event: string, listener: (request: OpenCodeSqliteParentMessage) => void) {
|
||||
if (event === 'message') {
|
||||
handler = listener
|
||||
}
|
||||
},
|
||||
postMessage(response: OpenCodeSqliteWorkerResponse) {
|
||||
posted.push(response)
|
||||
if (acknowledge && response.ok && response.captureBatch !== undefined) {
|
||||
queueMicrotask(() =>
|
||||
handler?.({ id: response.id, kind: 'captureAck', batch: response.captureBatch! })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -40,6 +48,7 @@ let tempDirs: string[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
posted.length = 0
|
||||
acknowledge = true
|
||||
await import('./session-scanner-opencode-sqlite-worker-entry')
|
||||
})
|
||||
|
||||
@@ -80,8 +89,10 @@ function createDbWithOneTurn(): string {
|
||||
|
||||
async function parseOnWorker(dbPath: string, capture: boolean): Promise<OpenCodeSqliteParseValue> {
|
||||
handler?.({ id: 1, kind: 'parse', dbPath, sessionId: SESSION_ID, platform: 'darwin', capture })
|
||||
await vi.waitFor(() => expect(posted).toHaveLength(1))
|
||||
const response = posted[0]!
|
||||
await vi.waitFor(() =>
|
||||
expect(posted.some((reply) => !reply.ok || reply.captureBatch === undefined)).toBe(true)
|
||||
)
|
||||
const response = posted.at(-1)!
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error)
|
||||
}
|
||||
@@ -93,7 +104,11 @@ describe('OpenCode SQLite worker entry', () => {
|
||||
const value = await parseOnWorker(createDbWithOneTurn(), true)
|
||||
|
||||
expect(value.session?.sessionId).toBe(SESSION_ID)
|
||||
expect(value.messages).toEqual([
|
||||
expect(
|
||||
posted
|
||||
.filter((reply) => reply.ok && reply.captureBatch !== undefined)
|
||||
.flatMap((reply) => (reply.ok ? reply.value : []))
|
||||
).toEqual([
|
||||
{ role: 'user', text: 'recalibrate the ballast pump', timestamp: expect.any(String) }
|
||||
])
|
||||
})
|
||||
@@ -102,6 +117,80 @@ describe('OpenCode SQLite worker entry', () => {
|
||||
const value = await parseOnWorker(createDbWithOneTurn(), false)
|
||||
|
||||
expect(value.session?.sessionId).toBe(SESSION_ID)
|
||||
expect(value.messages).toEqual([])
|
||||
expect(
|
||||
posted
|
||||
.filter((reply) => reply.ok && reply.captureBatch !== undefined)
|
||||
.flatMap((reply) => (reply.ok ? reply.value : []))
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for downstream acknowledgement between bounded batches without dropping the tail', async () => {
|
||||
const dbPath = createDbWithOneTurn()
|
||||
const db = new Database(dbPath)
|
||||
const count = 256
|
||||
const text = 'bounded history '.repeat(4096)
|
||||
db.exec('BEGIN')
|
||||
for (let i = 0; i < count; i++) {
|
||||
insertOpenCodePart(db, {
|
||||
id: `part_${i}`,
|
||||
messageId: 'msg_1',
|
||||
sessionId: SESSION_ID,
|
||||
timeCreated: CREATED_MS + 600 + i,
|
||||
text: `${i} ${text}`
|
||||
})
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
db.close()
|
||||
acknowledge = false
|
||||
handler?.({
|
||||
id: 2,
|
||||
kind: 'parse',
|
||||
dbPath,
|
||||
sessionId: SESSION_ID,
|
||||
platform: process.platform,
|
||||
capture: true
|
||||
})
|
||||
await vi.waitFor(() => expect(posted).toHaveLength(1))
|
||||
const first = posted[0]!
|
||||
expect(first).toMatchObject({ ok: true, captureBatch: 1 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(posted).toHaveLength(1)
|
||||
acknowledge = true
|
||||
handler?.({ id: 2, kind: 'captureAck', batch: 1 })
|
||||
await vi.waitFor(() =>
|
||||
expect(posted.at(-1)).toMatchObject({ ok: true, value: { session: { sessionId: SESSION_ID } } })
|
||||
)
|
||||
const batches = posted.flatMap((reply) =>
|
||||
reply.ok && reply.captureBatch !== undefined ? [reply.value as { text: string }[]] : []
|
||||
)
|
||||
expect(batches.length).toBeGreaterThan(2)
|
||||
expect(batches.flat()).toHaveLength(count + 1)
|
||||
expect(batches.flat().at(-1)?.text).toBe(`${count - 1} ${text}`.trim())
|
||||
for (const batch of batches) {
|
||||
expect(batch.length).toBeLessThanOrEqual(129)
|
||||
expect(batch.reduce((sum, message) => sum + message.text.length, 0)).toBeLessThan(512 * 1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('closes its source database if downstream capture fails while the cursor is suspended', async () => {
|
||||
const dbPath = createDbWithOneTurn()
|
||||
const close = vi.spyOn(Database.prototype, 'close')
|
||||
try {
|
||||
await expect(
|
||||
withStreamingSessionSearchCapture(
|
||||
{
|
||||
push() {},
|
||||
checkpoint: async () => {
|
||||
throw new Error('downstream failed')
|
||||
}
|
||||
},
|
||||
() =>
|
||||
parseOpenCodeSqliteSession({ dbPath, sessionId: SESSION_ID, platform: process.platform })
|
||||
)
|
||||
).rejects.toThrow('downstream failed')
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
close.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OpenCodeWorkerSearchCapture } from './session-search-opencode-worker-capture'
|
||||
import { parentPort } from 'node:worker_threads'
|
||||
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
|
||||
@@ -5,9 +6,10 @@ import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import type {
|
||||
OpenCodeSqliteParseValue,
|
||||
OpenCodeSqliteWorkerRequest,
|
||||
OpenCodeSqliteParentMessage,
|
||||
OpenCodeSqliteWorkerResponse
|
||||
} from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import { withSessionSearchCapture } from './session-search-capture'
|
||||
import { withStreamingSessionSearchCapture } from './session-search-capture'
|
||||
|
||||
// Why (#8864): OpenCode SQLite reads use synchronous node:sqlite. Running them
|
||||
// on this worker thread keeps the multi-GB-DB scan off the Electron main-process
|
||||
@@ -18,6 +20,7 @@ if (!parentPort) {
|
||||
throw new Error('OpenCode SQLite worker must run with a parent port.')
|
||||
}
|
||||
const port = parentPort
|
||||
const captures = new Map<number, OpenCodeWorkerSearchCapture>()
|
||||
|
||||
async function handleRequest(
|
||||
request: OpenCodeSqliteWorkerRequest
|
||||
@@ -38,8 +41,7 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// The parsers emit index rows into a capture scope, and that scope cannot span
|
||||
// threads; capture here and hand the rows back with the session.
|
||||
// The final reply follows all acknowledged capture batches.
|
||||
async function parseSession(
|
||||
request: Extract<OpenCodeSqliteWorkerRequest, { kind: 'parse' }>
|
||||
): Promise<OpenCodeSqliteParseValue> {
|
||||
@@ -50,13 +52,24 @@ async function parseSession(
|
||||
platform: request.platform
|
||||
})
|
||||
if (!request.capture) {
|
||||
return { session: await parse(), messages: [] }
|
||||
return { session: await parse() }
|
||||
}
|
||||
const capture = new OpenCodeWorkerSearchCapture(request.id, (batch) => port.postMessage(batch))
|
||||
captures.set(request.id, capture)
|
||||
try {
|
||||
const session = await withStreamingSessionSearchCapture(capture, parse)
|
||||
await capture.flush()
|
||||
return { session }
|
||||
} finally {
|
||||
captures.delete(request.id)
|
||||
}
|
||||
const captured = await withSessionSearchCapture(parse)
|
||||
return { session: captured.value, messages: captured.messages }
|
||||
}
|
||||
|
||||
port.on('message', (request: OpenCodeSqliteWorkerRequest) => {
|
||||
port.on('message', (request: OpenCodeSqliteParentMessage) => {
|
||||
if (request.kind === 'captureAck') {
|
||||
captures.get(request.id)?.acknowledge(request.batch)
|
||||
return
|
||||
}
|
||||
void handleRequest(request).then((response) => {
|
||||
try {
|
||||
port.postMessage(response)
|
||||
|
||||
@@ -20,7 +20,7 @@ export type OpenCodeSqliteParseRequest = {
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
// Set when the caller parses inside a search-capture scope: AsyncLocalStorage
|
||||
// does not cross threads, so the worker has to collect the rows and ship them.
|
||||
// does not cross threads, so the worker sends acknowledged capture batches.
|
||||
capture?: boolean
|
||||
}
|
||||
|
||||
@@ -34,13 +34,24 @@ export type OpenCodeSqliteListValue = {
|
||||
issues: AiVaultScanIssue[]
|
||||
}
|
||||
|
||||
// The parse leg returns the session plus the index rows captured while parsing
|
||||
// it; `messages` is empty unless the request asked for capture.
|
||||
// Final metadata follows acknowledgement of every capture batch.
|
||||
export type OpenCodeSqliteParseValue = {
|
||||
session: AiVaultSession | null
|
||||
messages: SessionSearchCapturedMessage[]
|
||||
}
|
||||
|
||||
export type OpenCodeSqliteWorkerResponse =
|
||||
| { id: number; ok: true; value: unknown }
|
||||
| { id: number; ok: true; value: unknown; captureBatch?: number }
|
||||
| { id: number; ok: false; error: string }
|
||||
|
||||
export type OpenCodeSqliteCaptureAck = { id: number; kind: 'captureAck'; batch: number }
|
||||
export type OpenCodeSqliteParentMessage = OpenCodeSqliteWorkerRequest | OpenCodeSqliteCaptureAck
|
||||
export type OpenCodeSqliteCaptureBatch = {
|
||||
id: number
|
||||
ok: true
|
||||
captureBatch: number
|
||||
value: SessionSearchCapturedMessage[]
|
||||
}
|
||||
|
||||
export type OpenCodeSqliteRequestBody =
|
||||
| Omit<OpenCodeSqliteListRequest, 'id'>
|
||||
| Omit<OpenCodeSqliteParseRequest, 'id'>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
normalizeFullFirstUserPromptText,
|
||||
shouldCaptureFullFirstUserPrompt
|
||||
} from './session-scanner-first-user-prompt'
|
||||
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
|
||||
import { readOpenCodeDatabaseAsync } from './session-scanner-opencode-sqlite-open'
|
||||
import { normalizeTitleText } from './session-scanner-values'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
@@ -251,19 +251,19 @@ export async function parseOpenCodeSqliteSession(args: {
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}): Promise<AiVaultSession | null> {
|
||||
return readOpenCodeDatabase({
|
||||
return readOpenCodeDatabaseAsync({
|
||||
dbPath: args.dbPath,
|
||||
read: (db) => readSession({ db, ...args })
|
||||
})
|
||||
}
|
||||
|
||||
// Extracted so the open wrapper owns the handle's lifetime.
|
||||
function readSession(args: {
|
||||
async function readSession(args: {
|
||||
db: SyncDatabase
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}): AiVaultSession | null {
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const { db, dbPath, sessionId, platform } = args
|
||||
if (!canReadOpenCodeSessions(db)) {
|
||||
return null
|
||||
@@ -299,7 +299,7 @@ function readSession(args: {
|
||||
|
||||
const previewSql = buildPreviewQuery(db)
|
||||
if (previewSql) {
|
||||
captureOpenCodeSession(db, sessionId)
|
||||
await captureOpenCodeSession(db, sessionId)
|
||||
// Why: SQL already dropped anything older than the newest-N window, so the
|
||||
// accumulator never shifts and cannot detect the truncation itself. Ask for
|
||||
// one extra row so an exactly-full window is not mistaken for a trimmed one.
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './session-scanner-resume-point'
|
||||
import {
|
||||
getSessionSearchIndexMode,
|
||||
isSessionSearchFileCurrent,
|
||||
getSessionSearchIndexSink,
|
||||
withoutSessionSearchCapture,
|
||||
type SessionSearchIndexSink
|
||||
@@ -134,13 +135,7 @@ async function parseCachedInLane(
|
||||
const registeredSink = getSessionSearchIndexSink()
|
||||
const sink = registeredSink?.acceptsCandidate?.(candidate) === false ? null : registeredSink
|
||||
const indexed = sink ? sink.indexedFile(file.path, fileIdentity(file)) : null
|
||||
const indexCurrent =
|
||||
sink === null ||
|
||||
(indexed !== null &&
|
||||
indexed.mtimeMs === file.mtimeMs &&
|
||||
(indexed.sizeBytes === null ||
|
||||
file.sizeBytes === undefined ||
|
||||
indexed.sizeBytes === file.sizeBytes))
|
||||
const indexCurrent = sink === null || isSessionSearchFileCurrent(indexed, file)
|
||||
// In `required` mode a file the index has not caught up on is never
|
||||
// "unchanged"; in `opportunistic` mode it is reused and handed to the backfill.
|
||||
const indexRequired = sink !== null && getSessionSearchIndexMode() === 'required'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mkdtemp, rm, writeFile, access } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol'
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
|
||||
vi.mock('./session-scanner', () => ({ scanAiVaultSessions: vi.fn() }))
|
||||
vi.mock('./session-parse-cache-persistence', () => ({
|
||||
flushSessionParseCachePersist: vi.fn(async () => undefined),
|
||||
initSessionParseCachePersistence: vi.fn()
|
||||
}))
|
||||
|
||||
it('clears a corrupt index after enabled scanner initialization failed', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'ss-recovery-'))
|
||||
const databasePath = join(directory, 'index.sqlite')
|
||||
await writeFile(databasePath, 'corrupt fixture')
|
||||
const previousSend = process.send
|
||||
const events = ['message', 'disconnect', 'SIGTERM'] as const
|
||||
const previousListeners = new Map(events.map((event) => [event, process.listeners(event)]))
|
||||
const sent: { type: string; id?: number; value?: unknown }[] = []
|
||||
process.send = ((message: (typeof sent)[number]) => {
|
||||
sent.push(message)
|
||||
return true
|
||||
}) as typeof process.send
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const emit = (value: unknown): void => {
|
||||
process.emit('message', value as never, undefined as never)
|
||||
}
|
||||
try {
|
||||
await import('./session-scanner-service-entry')
|
||||
emit({
|
||||
type: 'init',
|
||||
protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
sessionSearch: { databasePath, enabled: true, paused: true, historyDays: null }
|
||||
})
|
||||
expect(errors).toHaveBeenCalled()
|
||||
expect(sent).toContainEqual(expect.objectContaining({ type: 'ready' }))
|
||||
emit({
|
||||
type: 'request',
|
||||
id: 1,
|
||||
operation: 'searchConfigure',
|
||||
request: {
|
||||
init: { databasePath, enabled: false, historyDays: null },
|
||||
roots: isolatedScanRoots(directory),
|
||||
clearIndex: true
|
||||
}
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(sent).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'result',
|
||||
id: 1,
|
||||
value: expect.objectContaining({ enabled: false })
|
||||
})
|
||||
)
|
||||
)
|
||||
await expect(access(databasePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
for (const event of events) {
|
||||
for (const listener of process.listeners(event)) {
|
||||
if (!previousListeners.get(event)!.includes(listener)) {
|
||||
process.removeListener(event, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
process.send = previousSend
|
||||
errors.mockRestore()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -18,7 +18,7 @@ export function resolveAiVaultServiceEntryPath(
|
||||
): string {
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
const adjacentEntry = join(basePath, 'session-scanner-service-entry.js')
|
||||
if (!isPackaged && pathExists(adjacentEntry)) {
|
||||
if (!appPath.endsWith('app.asar') && pathExists(adjacentEntry)) {
|
||||
return adjacentEntry
|
||||
}
|
||||
return join(basePath, 'out', 'main', 'session-scanner-service-entry.js')
|
||||
|
||||
@@ -103,6 +103,7 @@ async function executeRequest(request: AiVaultServiceRequest): Promise<AiVaultSe
|
||||
}
|
||||
}
|
||||
if (request.operation === 'searchConfigure') {
|
||||
sessionSearch ??= new SessionSearchService({ ...request.request.init, enabled: false })
|
||||
return {
|
||||
operation: 'searchConfigure',
|
||||
value: await requireSessionSearch().configure(request.request.init, request.request.roots, {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// What Node and libuv need to start and resolve a home, temp dir and locale.
|
||||
// Exported for sibling plain-node forks (the WSL transcript fs process).
|
||||
export const RUNTIME_ENV_ALLOWLIST = [
|
||||
'ORCA_BACKGROUND_LAUNCH',
|
||||
'PATH',
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
|
||||
@@ -109,13 +109,18 @@ export function readAiVaultSearchCoverageInService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Null when no child has ever been started: a consent change has nothing to
|
||||
* reach, and the next spawn reads the new policy from its init payload.
|
||||
* Explicit enabling starts backfill; disabling an absent writer needs no child.
|
||||
*/
|
||||
export function configureAiVaultSearchInService(
|
||||
request: AiVaultServiceSearchConfigureRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSearchCoverage> | null {
|
||||
if (request.init.enabled) {
|
||||
return getSharedClient().request(
|
||||
{ type: 'request', operation: 'searchConfigure', request },
|
||||
signal
|
||||
)
|
||||
}
|
||||
if (!sharedClient && request.clearIndex) {
|
||||
removeSessionSearchDatabase(request.init.databasePath)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
// Why: the parsers already fold every provider's transcript into one
|
||||
// accumulator. Instead of a second reader per format, a parse runs inside a
|
||||
@@ -17,6 +18,7 @@ export type SessionSearchCapturedMessage = {
|
||||
}
|
||||
|
||||
export type SessionSearchIndexUpdate = {
|
||||
signal?: AbortSignal
|
||||
candidate: SessionFileCandidate
|
||||
/** Null when the parser rejected the file (e.g. a Codex worker transcript): drop its rows. */
|
||||
session: AiVaultSession | null
|
||||
@@ -73,15 +75,25 @@ type CaptureScope = {
|
||||
} | null
|
||||
|
||||
const captureStorage = new AsyncLocalStorage<CaptureScope>()
|
||||
const indexModeStorage = new AsyncLocalStorage<SessionSearchIndexMode>()
|
||||
const indexModeStorage = new AsyncLocalStorage<{
|
||||
mode: SessionSearchIndexMode
|
||||
signal?: AbortSignal
|
||||
}>()
|
||||
let sink: SessionSearchIndexSink | null = null
|
||||
|
||||
export function getSessionSearchIndexMode(): SessionSearchIndexMode {
|
||||
return indexModeStorage.getStore() ?? 'opportunistic'
|
||||
return indexModeStorage.getStore()?.mode ?? 'opportunistic'
|
||||
}
|
||||
|
||||
export function withSessionSearchIndexRequired<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return indexModeStorage.run('required', fn)
|
||||
export function getSessionSearchCaptureSignal(): AbortSignal | undefined {
|
||||
return indexModeStorage.getStore()?.signal
|
||||
}
|
||||
|
||||
export function withSessionSearchIndexRequired<T>(
|
||||
fn: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
return indexModeStorage.run({ mode: 'required', signal }, fn)
|
||||
}
|
||||
|
||||
export function registerSessionSearchIndexSink(next: SessionSearchIndexSink | null): void {
|
||||
@@ -113,8 +125,11 @@ export async function withSessionSearchCapture<T>(
|
||||
return { value, messages: scope.messages }
|
||||
}
|
||||
|
||||
export function checkpointSessionSearchCapture(): Promise<void> | undefined {
|
||||
return captureStorage.getStore()?.checkpoint?.()
|
||||
export async function checkpointSessionSearchCapture(): Promise<void> {
|
||||
const signal = getSessionSearchCaptureSignal()
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
await captureStorage.getStore()?.checkpoint?.()
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
}
|
||||
|
||||
export function withStreamingSessionSearchCapture<T>(
|
||||
@@ -123,3 +138,16 @@ export function withStreamingSessionSearchCapture<T>(
|
||||
): Promise<T> {
|
||||
return captureStorage.run({ messages, checkpoint: () => messages.checkpoint() }, fn)
|
||||
}
|
||||
|
||||
export function isSessionSearchFileCurrent(
|
||||
indexed: SessionSearchIndexedFile | null,
|
||||
file: FileWithMtime
|
||||
): boolean {
|
||||
return (
|
||||
indexed !== null &&
|
||||
indexed.mtimeMs === file.mtimeMs &&
|
||||
(indexed.sizeBytes === null ||
|
||||
file.sizeBytes === undefined ||
|
||||
indexed.sizeBytes === file.sizeBytes)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { SessionSearchIndexSink, SessionSearchIndexUpdate } from './session-search-capture'
|
||||
import {
|
||||
getSessionSearchCaptureSignal,
|
||||
withSessionSearchCapture,
|
||||
withStreamingSessionSearchCapture
|
||||
} from './session-search-capture'
|
||||
@@ -14,10 +16,18 @@ export async function captureIndexedSessionParse<T>(
|
||||
base: Pick<SessionSearchIndexUpdate, 'candidate' | 'mode' | 'previousByteOffset'>,
|
||||
parse: () => Promise<Parsed<T>>
|
||||
): Promise<T> {
|
||||
const signal = getSessionSearchCaptureSignal()
|
||||
const read = async (): Promise<Parsed<T>> => {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
const result = await parse()
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
return result
|
||||
}
|
||||
if (!sink.streamingCapture) {
|
||||
const captured = await withSessionSearchCapture(parse)
|
||||
const captured = await withSessionSearchCapture(read)
|
||||
await sink.apply({
|
||||
...base,
|
||||
signal,
|
||||
session: captured.value.session,
|
||||
byteOffset: captured.value.byteOffset,
|
||||
messages: captured.messages
|
||||
@@ -25,9 +35,14 @@ export async function captureIndexedSessionParse<T>(
|
||||
return captured.value.value
|
||||
}
|
||||
const channel = new SessionSearchMessageChannel()
|
||||
const parsed = withStreamingSessionSearchCapture(channel, parse)
|
||||
const stop = (): void => channel.stop()
|
||||
signal?.addEventListener('abort', stop, { once: true })
|
||||
if (signal?.aborted) {
|
||||
stop()
|
||||
}
|
||||
const parsed = withStreamingSessionSearchCapture(channel, read)
|
||||
const indexing = Promise.resolve(
|
||||
sink.apply({ ...base, messages: channel, result: parsed })
|
||||
sink.apply({ ...base, signal, messages: channel, result: parsed })
|
||||
).finally(() => channel.stop())
|
||||
void indexing.catch(() => undefined)
|
||||
try {
|
||||
@@ -39,5 +54,7 @@ export async function captureIndexedSessionParse<T>(
|
||||
channel.close(error)
|
||||
await indexing.catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', stop)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { EventEmitter, getEventListeners } from 'node:events'
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
|
||||
import {
|
||||
withSessionSearchIndexRequired,
|
||||
withStreamingSessionSearchCapture
|
||||
} from './session-search-capture'
|
||||
import type { OpenCodeSqliteParentMessage } from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
|
||||
class TestWorker extends EventEmitter {
|
||||
requests: OpenCodeSqliteParentMessage[] = []
|
||||
terminate = vi.fn(async () => 0)
|
||||
unref(): void {}
|
||||
postMessage(request: OpenCodeSqliteParentMessage): void {
|
||||
this.requests.push(request)
|
||||
}
|
||||
}
|
||||
const args = { dbPath: '/isolated/opencode.db', sessionId: 'a', platform: 'linux' as const }
|
||||
|
||||
it('cancels a queued parse without terminating the ordinary list ahead of it', async () => {
|
||||
const worker = new TestWorker()
|
||||
const client = new OpenCodeSqliteWorkerClient({
|
||||
workerFactory: () => worker as unknown as Worker
|
||||
})
|
||||
const list = client.list({ dbPaths: [args.dbPath], limit: 10, issues: [] })
|
||||
const controller = new AbortController()
|
||||
const parsed = withSessionSearchIndexRequired(() => client.parse(args), controller.signal)
|
||||
const rejected = expect(parsed).rejects.toMatchObject({ name: 'AbortError' })
|
||||
controller.abort()
|
||||
await rejected
|
||||
expect(worker.terminate).not.toHaveBeenCalled()
|
||||
expect(worker.requests).toHaveLength(1)
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
|
||||
worker.emit('message', {
|
||||
id: worker.requests[0].id,
|
||||
ok: true,
|
||||
value: { candidates: [], issues: [] }
|
||||
})
|
||||
expect(await list).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels during backpressure without a late ack and allows the queued list to finish', async () => {
|
||||
const workers: TestWorker[] = []
|
||||
const client = new OpenCodeSqliteWorkerClient({
|
||||
workerFactory: () => {
|
||||
const worker = new TestWorker()
|
||||
workers.push(worker)
|
||||
return worker as unknown as Worker
|
||||
}
|
||||
})
|
||||
const controller = new AbortController()
|
||||
let release!: () => void
|
||||
const drained = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const checkpoint = vi.fn(() => drained)
|
||||
const parsed = withSessionSearchIndexRequired(
|
||||
() => withStreamingSessionSearchCapture({ push() {}, checkpoint }, () => client.parse(args)),
|
||||
controller.signal
|
||||
)
|
||||
const rejected = expect(parsed).rejects.toMatchObject({ name: 'AbortError' })
|
||||
const list = client.list({ dbPaths: [args.dbPath], limit: 10, issues: [] })
|
||||
workers[0].emit('message', {
|
||||
id: workers[0].requests[0].id,
|
||||
ok: true,
|
||||
captureBatch: 1,
|
||||
value: []
|
||||
})
|
||||
expect(checkpoint).toHaveBeenCalledOnce()
|
||||
controller.abort()
|
||||
await rejected
|
||||
release()
|
||||
await drained
|
||||
await Promise.resolve()
|
||||
expect(workers[0].terminate).toHaveBeenCalledOnce()
|
||||
expect(workers[0].requests).toHaveLength(1)
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
|
||||
workers[1].emit('message', {
|
||||
id: workers[1].requests[0].id,
|
||||
ok: true,
|
||||
value: { candidates: [], issues: [] }
|
||||
})
|
||||
expect(await list).toEqual([])
|
||||
})
|
||||
|
||||
it('removes the cancellation listener after success and rejects pre-aborted work before spawning', async () => {
|
||||
const worker = new TestWorker()
|
||||
const factory = vi.fn(() => worker as unknown as Worker)
|
||||
const client = new OpenCodeSqliteWorkerClient({ workerFactory: factory })
|
||||
const controller = new AbortController()
|
||||
const parsed = withSessionSearchIndexRequired(() => client.parse(args), controller.signal)
|
||||
worker.emit('message', { id: worker.requests[0].id, ok: true, value: { session: null } })
|
||||
expect(await parsed).toBeNull()
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
|
||||
controller.abort()
|
||||
await expect(
|
||||
withSessionSearchIndexRequired(() => client.parse(args), controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(factory).toHaveBeenCalledOnce()
|
||||
expect(worker.requests).toHaveLength(1)
|
||||
expect(worker.terminate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unblocks a local capture checkpoint on abort while an ordinary list still completes after its sink stops', async () => {
|
||||
const { captureIndexedSessionParse } = await import('./session-search-indexed-parse')
|
||||
const { captureSessionSearchMessage, checkpointSessionSearchCapture } =
|
||||
await import('./session-search-capture')
|
||||
const controller = new AbortController()
|
||||
let produced = 0
|
||||
const parse = async () => {
|
||||
for (let part = 0; part < 40; part++) {
|
||||
produced++
|
||||
captureSessionSearchMessage({ role: 'user', text: 'part', timestamp: null })
|
||||
await checkpointSessionSearchCapture()
|
||||
}
|
||||
return { value: 'complete', session: null, byteOffset: 40 }
|
||||
}
|
||||
const base = {
|
||||
candidate: {
|
||||
agent: 'opencode' as const,
|
||||
codexHome: null,
|
||||
file: { path: args.dbPath, mtimeMs: 1, modifiedAt: new Date(1).toISOString() }
|
||||
},
|
||||
mode: 'replace' as const,
|
||||
previousByteOffset: 0
|
||||
}
|
||||
const sink = {
|
||||
streamingCapture: true,
|
||||
indexedFile: () => null,
|
||||
markStale() {},
|
||||
async apply() {
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
withSessionSearchIndexRequired(
|
||||
() => captureIndexedSessionParse(sink, base, parse),
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(produced).toBe(1)
|
||||
produced = 0
|
||||
expect(await captureIndexedSessionParse(sink, base, parse)).toBe('complete')
|
||||
expect(produced).toBe(40)
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { captureIndexableText, toolCallText } from './session-search-content'
|
||||
import { isSessionSearchCaptureActive } from './session-search-capture'
|
||||
import {
|
||||
checkpointSessionSearchCapture,
|
||||
isSessionSearchCaptureActive
|
||||
} from './session-search-capture'
|
||||
|
||||
/** The preview ring is deliberately small; search consumes every part once. */
|
||||
export function captureOpenCodeSession(db: SyncDatabase, sessionId: string): void {
|
||||
export async function captureOpenCodeSession(db: SyncDatabase, sessionId: string): Promise<void> {
|
||||
if (!isSessionSearchCaptureActive()) {
|
||||
return
|
||||
}
|
||||
@@ -27,5 +30,6 @@ export function captureOpenCodeSession(db: SyncDatabase, sessionId: string): voi
|
||||
captureIndexableText('tool', part.state.output, row.ts)
|
||||
}
|
||||
}
|
||||
await checkpointSessionSearchCapture()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SessionSearchCapturedMessage } from './session-search-capture'
|
||||
import type { OpenCodeSqliteCaptureBatch } from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
|
||||
export const OPENCODE_CAPTURE_BATCH_CHARS = 256 * 1024
|
||||
export const OPENCODE_CAPTURE_BATCH_MESSAGES = 128
|
||||
|
||||
/** One acknowledged batch in flight; a single capped part may cross the threshold. */
|
||||
export class OpenCodeWorkerSearchCapture {
|
||||
private messages: SessionSearchCapturedMessage[] = []
|
||||
private chars = 0
|
||||
private sequence = 0
|
||||
private acknowledgeBatch: (() => void) | null = null
|
||||
|
||||
constructor(
|
||||
private readonly id: number,
|
||||
private readonly send: (batch: OpenCodeSqliteCaptureBatch) => void
|
||||
) {}
|
||||
|
||||
push(message: SessionSearchCapturedMessage): void {
|
||||
this.messages.push(message)
|
||||
this.chars += message.text.length
|
||||
}
|
||||
|
||||
checkpoint(): Promise<void> {
|
||||
return this.chars >= OPENCODE_CAPTURE_BATCH_CHARS ||
|
||||
this.messages.length >= OPENCODE_CAPTURE_BATCH_MESSAGES
|
||||
? this.flush()
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (!this.messages.length) {
|
||||
return
|
||||
}
|
||||
const messages = this.messages
|
||||
this.messages = []
|
||||
this.chars = 0
|
||||
const sequence = ++this.sequence
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.acknowledgeBatch = resolve
|
||||
this.send({ id: this.id, ok: true, captureBatch: sequence, value: messages })
|
||||
})
|
||||
} finally {
|
||||
this.acknowledgeBatch = null
|
||||
}
|
||||
}
|
||||
|
||||
acknowledge(sequence: number): void {
|
||||
if (sequence === this.sequence) {
|
||||
this.acknowledgeBatch?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import type {
|
||||
OpenCodeSqliteWorkerRequest,
|
||||
OpenCodeSqliteWorkerResponse
|
||||
} from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import { AsyncResource } from 'node:async_hooks'
|
||||
import {
|
||||
captureSessionSearchMessage,
|
||||
checkpointSessionSearchCapture,
|
||||
getSessionSearchCaptureSignal,
|
||||
type SessionSearchCapturedMessage
|
||||
} from './session-search-capture'
|
||||
|
||||
export type OpenCodeCaptureConsumer = (messages: SessionSearchCapturedMessage[]) => Promise<void>
|
||||
|
||||
export function bindOpenCodeCaptureConsumer(): OpenCodeCaptureConsumer {
|
||||
return AsyncResource.bind(async (messages: SessionSearchCapturedMessage[]) => {
|
||||
for (const message of messages) {
|
||||
captureSessionSearchMessage(message)
|
||||
}
|
||||
await checkpointSessionSearchCapture()
|
||||
})
|
||||
}
|
||||
|
||||
export function receiveOpenCodeCaptureBatch(args: {
|
||||
call: { capture?: OpenCodeCaptureConsumer; timer: NodeJS.Timeout | null; timeoutMs: number }
|
||||
response: Extract<OpenCodeSqliteWorkerResponse, { ok: true }>
|
||||
worker: Worker | null
|
||||
isActive: () => boolean
|
||||
onTimeout: () => void
|
||||
onError: (error: Error) => void
|
||||
}): void {
|
||||
const { call } = args
|
||||
if (!call.capture) {
|
||||
args.onError(new Error('Unexpected OpenCode capture batch.'))
|
||||
return
|
||||
}
|
||||
// Backpressure belongs to the writer; the worker deadline covers time spent producing.
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
void call
|
||||
.capture(args.response.value as SessionSearchCapturedMessage[])
|
||||
.then(() => {
|
||||
if (!args.isActive()) {
|
||||
return
|
||||
}
|
||||
call.timer = setTimeout(args.onTimeout, call.timeoutMs)
|
||||
call.timer.unref?.()
|
||||
args.worker?.postMessage({
|
||||
id: args.response.id,
|
||||
kind: 'captureAck',
|
||||
batch: args.response.captureBatch
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (args.isActive()) {
|
||||
args.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The request owns its abort listener until either queued or active work settles. */
|
||||
export function bindOpenCodeCaptureCancellation(
|
||||
resolve: (value: unknown) => void,
|
||||
reject: (error: Error) => void,
|
||||
cancel: () => void
|
||||
): { resolve: typeof resolve; reject: typeof reject } {
|
||||
const signal = getSessionSearchCaptureSignal()
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
const cleanup = (): void => signal?.removeEventListener('abort', cancel)
|
||||
return {
|
||||
resolve: (value) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
reject: (error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenCodePendingCall = {
|
||||
request: OpenCodeSqliteWorkerRequest
|
||||
timeoutMs: number
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
capture?: OpenCodeCaptureConsumer
|
||||
}
|
||||
@@ -76,6 +76,7 @@ const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000
|
||||
|
||||
type AiVaultHandlerOptions = AiVaultSessionSources &
|
||||
AiVaultResumeHandlerOptions & {
|
||||
persistSearchPolicy?: () => Promise<void>
|
||||
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
|
||||
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
|
||||
resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver
|
||||
@@ -267,7 +268,12 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
||||
ipcMain.handle('aiVault:searchIndexSize', () => ({
|
||||
bytes: readAiVaultSearchIndexSizeBytes()
|
||||
}))
|
||||
ipcMain.handle('aiVault:clearSearchIndex', () => clearAiVaultSearchIndex())
|
||||
ipcMain.handle('aiVault:clearSearchIndex', () => {
|
||||
if (!options.persistSearchPolicy) {
|
||||
throw new Error('Search policy persistence is unavailable.')
|
||||
}
|
||||
return clearAiVaultSearchIndex(options.persistSearchPolicy)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'aiVault:resolveSessionTitles',
|
||||
(_event, args: AiVaultSessionTitlesArgs): Promise<AiVaultSessionTitlesResult> =>
|
||||
|
||||
@@ -215,6 +215,7 @@ export function registerCoreHandlers(
|
||||
registerRuntimeEnvironmentHandlers(store)
|
||||
registerEphemeralVmHandlers(store, pluginService)
|
||||
registerAiVaultHandlers({
|
||||
persistSearchPolicy: () => store.flushPendingOrThrowAsync({ drainToStableGeneration: false }),
|
||||
ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(),
|
||||
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
|
||||
prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume,
|
||||
|
||||
@@ -278,7 +278,9 @@ export function registerSettingsHandlers(
|
||||
applyAppIcon(result.appIcon)
|
||||
}
|
||||
if ('aiVaultSearch' in sanitizedArgs) {
|
||||
await applyAiVaultSearchSettings(result)
|
||||
await applyAiVaultSearchSettings(result, {
|
||||
persist: () => store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
|
||||
})
|
||||
}
|
||||
|
||||
// Why: telemetry-plan.md§Settings — fire `settings_changed` only for
|
||||
@@ -328,9 +330,7 @@ export function registerSettingsHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('settings:listFonts', () => {
|
||||
return listSystemFontFamilies()
|
||||
})
|
||||
ipcMain.handle('settings:listFonts', () => listSystemFontFamilies())
|
||||
|
||||
ipcMain.handle('settings:previewGhosttyImport', () => {
|
||||
return previewGhosttyImport(store)
|
||||
|
||||
@@ -148,6 +148,8 @@ async function startOrcadRuntime(
|
||||
const { collectOrcadHealth } = await import('./orcad-health')
|
||||
|
||||
const runtimeUserDataPath = getAppEnvironment().getPath('userData')
|
||||
const { initSessionSearchPaths } = await import('../ai-vault-search/session-search-paths')
|
||||
initSessionSearchPaths(runtimeUserDataPath)
|
||||
initOrcaProfilePaths()
|
||||
const profile = ensureActiveOrcaProfile(runtimeUserDataPath)
|
||||
// Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable`
|
||||
|
||||
@@ -298,7 +298,8 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin
|
||||
|
||||
protected readonly aiVault = new RuntimeAiVaultCommands(
|
||||
() => this.prepareAiVaultSessionResumeFn,
|
||||
() => this.store ?? null
|
||||
() => this.store ?? null,
|
||||
(targetId) => this.getSshProviderFn?.(targetId)
|
||||
)
|
||||
|
||||
protected readonly claudeAgentTeams = new ClaudeAgentTeamsService()
|
||||
|
||||
@@ -37,16 +37,23 @@ const searchAiVaultSessions = vi.fn()
|
||||
const readAiVaultSearchCoverage = vi.fn()
|
||||
const readAiVaultSearchIndexStatus = vi.fn()
|
||||
const configureAiVaultSessionSearch = vi.fn()
|
||||
const sshSearchAiVault = vi.fn()
|
||||
|
||||
function makeDispatcher(): RpcDispatcher {
|
||||
function makeDispatcher(legacy = false): RpcDispatcher {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
searchAiVaultSessions,
|
||||
readAiVaultSearchCoverage,
|
||||
readAiVaultSearchIndexStatus,
|
||||
configureAiVaultSessionSearch
|
||||
configureAiVaultSessionSearch,
|
||||
sshSearchAiVault
|
||||
} as unknown as OrcaRuntimeService
|
||||
return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS })
|
||||
return new RpcDispatcher({
|
||||
runtime,
|
||||
methods: legacy
|
||||
? AI_VAULT_METHODS.filter((method) => !method.name.startsWith('aiVault.sshSearch'))
|
||||
: AI_VAULT_METHODS
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -58,6 +65,32 @@ beforeEach(() => {
|
||||
readAiVaultSearchIndexStatus.mockReturnValue(INDEX_STATUS)
|
||||
configureAiVaultSessionSearch.mockReset()
|
||||
configureAiVaultSessionSearch.mockResolvedValue(INDEX_STATUS)
|
||||
sshSearchAiVault.mockReset().mockResolvedValue(RESULT)
|
||||
})
|
||||
|
||||
describe('targeted SSH search wire boundary', () => {
|
||||
it('passes final target identity and cancellation without invoking the controlling runtime index', async () => {
|
||||
const signal = new AbortController().signal
|
||||
expect(
|
||||
await makeDispatcher().dispatch(
|
||||
makeRequest('aiVault.sshSearchSessions', { targetId: 'C', query: 'needle' }),
|
||||
{ signal }
|
||||
)
|
||||
).toMatchObject({ ok: true, result: RESULT })
|
||||
expect(sshSearchAiVault).toHaveBeenCalledWith('C', 'query', { query: 'needle' }, signal)
|
||||
expect(searchAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
it('an older registry refuses targeted query and clear instead of stripping the target and executing locally', async () => {
|
||||
for (const method of ['aiVault.sshSearchSessions', 'aiVault.sshSearchConfigure']) {
|
||||
expect(
|
||||
await makeDispatcher(true).dispatch(
|
||||
makeRequest(method, { targetId: 'C', query: 'needle', clearIndex: true })
|
||||
)
|
||||
).toMatchObject({ ok: false, error: { code: 'method_not_found' } })
|
||||
}
|
||||
expect(searchAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(configureAiVaultSessionSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AiVaultSearchSessionsParams', () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
SessionSearchConfigureSchema,
|
||||
SessionSearchQuerySchema
|
||||
} from '../../../../shared/ai-vault-search-contract'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalBoolean } from '../schemas'
|
||||
import { restampAiVaultListResult } from '../../../ai-vault/session-list-results'
|
||||
import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
AI_VAULT_SEARCH_LIMIT_MAX,
|
||||
AI_VAULT_SEARCH_QUERY_MAX_LENGTH
|
||||
} from '../../../../shared/ai-vault-search-types'
|
||||
import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title'
|
||||
import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation'
|
||||
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
@@ -84,30 +84,41 @@ export const AiVaultSessionTitlesParams = z.object({
|
||||
.max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)
|
||||
})
|
||||
|
||||
export const AiVaultSearchSessionsParams = z.object({
|
||||
query: z.string().trim().min(1).max(AI_VAULT_SEARCH_QUERY_MAX_LENGTH),
|
||||
limit: z.number().int().min(1).max(AI_VAULT_SEARCH_LIMIT_MAX).optional(),
|
||||
agents: z.array(z.enum(AI_VAULT_AGENTS)).max(AI_VAULT_AGENTS.length).optional(),
|
||||
export const AiVaultSearchSessionsParams = SessionSearchQuerySchema.extend({
|
||||
// Preserve local RPC coercion and truncation for existing clients.
|
||||
scopePaths: z
|
||||
.array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH))
|
||||
.transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
|
||||
.optional(),
|
||||
since: z.string().datetime({ offset: true }).optional(),
|
||||
sort: z.enum(['relevance', 'newest']).optional(),
|
||||
tier: z.enum(['full', 'conversation']).optional(),
|
||||
refresh: OptionalBoolean,
|
||||
executionHostId: executionHostIdSchema.optional()
|
||||
})
|
||||
|
||||
export const AiVaultConfigureSessionSearchParams = z.object({
|
||||
export const AiVaultConfigureSessionSearchParams = SessionSearchConfigureSchema.extend({
|
||||
enabled: OptionalBoolean,
|
||||
paused: z.boolean().optional(),
|
||||
historyDays: z.number().int().positive().max(3650).nullable().optional(),
|
||||
clearIndex: OptionalBoolean,
|
||||
executionHostId: executionHostIdSchema.optional()
|
||||
})
|
||||
|
||||
export const AI_VAULT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'aiVault.sshSearchSessions',
|
||||
params: SessionSearchQuerySchema.extend({ targetId: z.string().min(1).max(512) }),
|
||||
handler: ({ targetId, ...params }, { runtime, signal }) =>
|
||||
runtime.sshSearchAiVault(targetId, 'query', params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'aiVault.sshSearchIndexStatus',
|
||||
params: z.object({ targetId: z.string().min(1).max(512) }),
|
||||
handler: ({ targetId }, { runtime, signal }) =>
|
||||
runtime.sshSearchAiVault(targetId, 'status', {}, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'aiVault.sshSearchConfigure',
|
||||
params: SessionSearchConfigureSchema.extend({ targetId: z.string().min(1).max(512) }),
|
||||
handler: ({ targetId, ...params }, { runtime, signal }) =>
|
||||
runtime.sshSearchAiVault(targetId, 'configure', params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'aiVault.searchSessions',
|
||||
params: AiVaultSearchSessionsParams,
|
||||
|
||||
@@ -28,20 +28,24 @@ import type {
|
||||
AiVaultSearchResult
|
||||
} from '../../shared/ai-vault-search-types'
|
||||
import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import { projectSessionSearchResult } from '../../shared/ai-vault-search-projection'
|
||||
import {
|
||||
SessionSearchConfigureSchema,
|
||||
SessionSearchQuerySchema,
|
||||
type SessionSearchConfigure
|
||||
} from '../../shared/ai-vault-search-contract'
|
||||
|
||||
export type AiVaultSessionSearchConfigureArgs = {
|
||||
enabled?: boolean
|
||||
paused?: boolean
|
||||
historyDays?: number | null
|
||||
clearIndex?: boolean
|
||||
}
|
||||
export type AiVaultSessionSearchConfigureArgs = SessionSearchConfigure
|
||||
|
||||
export class RuntimeAiVaultCommands {
|
||||
constructor(
|
||||
private readonly getPrepareResume: () =>
|
||||
| ((args: AiVaultPrepareSessionResumeArgs) => Promise<AiVaultPrepareSessionResumeResult>)
|
||||
| null,
|
||||
private readonly getStore: () => RuntimeStore | null = () => null
|
||||
private readonly getStore: () => RuntimeStore | null = () => null,
|
||||
private readonly getSshProvider: (targetId: string) => IPtyProvider | undefined = () =>
|
||||
undefined
|
||||
) {}
|
||||
|
||||
list(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
@@ -49,7 +53,55 @@ export class RuntimeAiVaultCommands {
|
||||
}
|
||||
|
||||
search(args: AiVaultSearchArgs, signal?: AbortSignal): Promise<AiVaultSearchResult> {
|
||||
return searchAiVaultSessions(args, { signal })
|
||||
const status = this.searchIndexStatus()
|
||||
if (status.available === false || status.applied === false) {
|
||||
throw new Error(status.reason)
|
||||
}
|
||||
return searchAiVaultSessions(args, { signal }).then(projectSessionSearchResult)
|
||||
}
|
||||
|
||||
async sshSearch(
|
||||
targetId: string,
|
||||
operation: 'query' | 'status' | 'configure',
|
||||
args: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown> {
|
||||
const provider = this.getSshProvider(targetId)
|
||||
if (!provider?.requestHostRpc) {
|
||||
throw new Error('SSH search unavailable: target is not connected to this runtime.')
|
||||
}
|
||||
const params =
|
||||
operation === 'query'
|
||||
? SessionSearchQuerySchema.parse(args)
|
||||
: operation === 'configure'
|
||||
? SessionSearchConfigureSchema.parse(args)
|
||||
: {}
|
||||
try {
|
||||
return await provider.requestHostRpc(
|
||||
`aiVault.search${{ query: 'Sessions', status: 'IndexStatus', configure: 'Configure' }[operation]}`,
|
||||
params,
|
||||
{ signal, timeoutMs: 15_000 }
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
operation === 'status' &&
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === -32601
|
||||
) {
|
||||
return {
|
||||
enabled: false,
|
||||
historyDays: null,
|
||||
indexSizeBytes: null,
|
||||
available: false,
|
||||
applied: false,
|
||||
reason:
|
||||
'This SSH relay does not support full-text search. Update the controlling Orca runtime and reconnect the target.'
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
searchCoverage(signal?: AbortSignal): Promise<AiVaultSearchCoverage> {
|
||||
@@ -68,9 +120,17 @@ export class RuntimeAiVaultCommands {
|
||||
args: AiVaultSessionSearchConfigureArgs
|
||||
): Promise<AiVaultSearchIndexStatus> {
|
||||
const store = this.getStore()
|
||||
if (!store?.getSettings || !store.updateSettings) {
|
||||
if (
|
||||
!store?.getSettings ||
|
||||
!store.updateSettings ||
|
||||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow)
|
||||
) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const status = this.searchIndexStatus()
|
||||
if (status.available === false) {
|
||||
throw new Error(status.reason)
|
||||
}
|
||||
const current = resolveAiVaultSearchSettings(store.getSettings())
|
||||
const next = {
|
||||
enabled: args.enabled ?? current.enabled,
|
||||
@@ -81,8 +141,20 @@ export class RuntimeAiVaultCommands {
|
||||
: normalizeAiVaultSearchHistoryDays(args.historyDays)
|
||||
}
|
||||
store.updateSettings({ aiVaultSearch: next }, { notifyListeners: true })
|
||||
await applyAiVaultSearchSettings({ aiVaultSearch: next }, { clearIndex: args.clearIndex })
|
||||
return { ...next, indexSizeBytes: readAiVaultSearchIndexStatus().indexSizeBytes }
|
||||
await applyAiVaultSearchSettings(
|
||||
{ aiVaultSearch: next },
|
||||
{
|
||||
clearIndex: args.clearIndex,
|
||||
persist: async () => {
|
||||
if (store.flushPendingOrThrowAsync) {
|
||||
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
|
||||
} else {
|
||||
store.flushOrThrow!()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return readAiVaultSearchIndexStatus()
|
||||
}
|
||||
|
||||
resolveTitles(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
const apply = vi.hoisted(() =>
|
||||
vi.fn(async (_settings: unknown, options: { persist?: () => Promise<void> }) => {
|
||||
await options.persist?.()
|
||||
return null
|
||||
})
|
||||
)
|
||||
vi.mock('../ai-vault-search/session-search-enablement', () => ({
|
||||
applyAiVaultSearchSettings: apply,
|
||||
readAiVaultSearchIndexStatus: () => ({
|
||||
enabled: true,
|
||||
historyDays: null,
|
||||
indexSizeBytes: 0,
|
||||
available: true,
|
||||
applied: true
|
||||
})
|
||||
}))
|
||||
|
||||
it('does not acknowledge enabling until the durable store barrier completes', async () => {
|
||||
let release!: () => void
|
||||
const flushed = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const flushPendingOrThrowAsync = vi.fn(() => flushed)
|
||||
const store = {
|
||||
getSettings: () => ({}),
|
||||
updateSettings: vi.fn(),
|
||||
flushPendingOrThrowAsync
|
||||
} as unknown as RuntimeStore
|
||||
const commands = new RuntimeAiVaultCommands(
|
||||
() => null,
|
||||
() => store
|
||||
)
|
||||
let acknowledged = false
|
||||
const pending = commands.configureSearch({ enabled: true }).then((status) => {
|
||||
acknowledged = true
|
||||
return status
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledWith({ drainToStableGeneration: false })
|
||||
)
|
||||
expect(acknowledged).toBe(false)
|
||||
release()
|
||||
expect(await pending).toMatchObject({ enabled: true, applied: true })
|
||||
})
|
||||
|
||||
it('reports persistence failure instead of returning a successful policy acknowledgement', async () => {
|
||||
const store = {
|
||||
getSettings: () => ({}),
|
||||
updateSettings: vi.fn(),
|
||||
flushPendingOrThrowAsync: vi.fn().mockRejectedValue(new Error('disk full'))
|
||||
} as unknown as RuntimeStore
|
||||
const commands = new RuntimeAiVaultCommands(
|
||||
() => null,
|
||||
() => store
|
||||
)
|
||||
await expect(commands.configureSearch({ enabled: true })).rejects.toThrow('disk full')
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
|
||||
it('routes nested search and policy only through the selected runtime’s registered provider', async () => {
|
||||
const request = vi.fn(async () => ({ marker: 'C-only' }))
|
||||
const provider = vi.fn((id: string) =>
|
||||
id === 'C' ? ({ requestHostRpc: request } as unknown as IPtyProvider) : undefined
|
||||
)
|
||||
const localStore = vi.fn()
|
||||
const commands = new RuntimeAiVaultCommands(() => null, localStore, provider)
|
||||
const signal = new AbortController().signal
|
||||
expect(
|
||||
await commands.sshSearch('C', 'query', { query: 'same', executionHostId: 'runtime:A' }, signal)
|
||||
).toEqual({ marker: 'C-only' })
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'aiVault.searchSessions',
|
||||
{ query: 'same' },
|
||||
{ signal, timeoutMs: 15_000 }
|
||||
)
|
||||
await commands.sshSearch('C', 'configure', { enabled: false, clearIndex: true }, signal)
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
'aiVault.searchConfigure',
|
||||
{ enabled: false, clearIndex: true },
|
||||
{ signal, timeoutMs: 15_000 }
|
||||
)
|
||||
await expect(commands.sshSearch('unknown', 'query', { query: 'same' })).rejects.toThrow(
|
||||
'not connected'
|
||||
)
|
||||
expect(localStore).not.toHaveBeenCalled()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('classifies only affirmative method absence as unsupported, never disconnects', async () => {
|
||||
const requestHostRpc = vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error('method absent'), { code: -32601 }))
|
||||
const commands = new RuntimeAiVaultCommands(
|
||||
() => null,
|
||||
() => null,
|
||||
() => ({ requestHostRpc }) as unknown as IPtyProvider
|
||||
)
|
||||
expect(await commands.sshSearch('C', 'status', {})).toMatchObject({
|
||||
available: false,
|
||||
applied: false
|
||||
})
|
||||
requestHostRpc.mockRejectedValue(new Error('connection lost'))
|
||||
await expect(commands.sshSearch('C', 'status', {})).rejects.toThrow('connection lost')
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import type { RuntimeSubscriptionRegistry } from './runtime-subscription-registr
|
||||
export type RuntimeServiceCommandSurface = {
|
||||
listAiVaultSessions: RuntimeAiVaultCommands['list']
|
||||
searchAiVaultSessions: RuntimeAiVaultCommands['search']
|
||||
sshSearchAiVault: RuntimeAiVaultCommands['sshSearch']
|
||||
readAiVaultSearchCoverage: RuntimeAiVaultCommands['searchCoverage']
|
||||
readAiVaultSearchIndexStatus: RuntimeAiVaultCommands['searchIndexStatus']
|
||||
configureAiVaultSessionSearch: RuntimeAiVaultCommands['configureSearch']
|
||||
@@ -95,6 +96,7 @@ export function installRuntimeServiceCommandSurface(
|
||||
Object.assign(target, {
|
||||
listAiVaultSessions: vault.list.bind(vault),
|
||||
searchAiVaultSessions: vault.search.bind(vault),
|
||||
sshSearchAiVault: vault.sshSearch.bind(vault),
|
||||
readAiVaultSearchCoverage: vault.searchCoverage.bind(vault),
|
||||
readAiVaultSearchIndexStatus: vault.searchIndexStatus.bind(vault),
|
||||
configureAiVaultSessionSearch: vault.configureSearch.bind(vault),
|
||||
|
||||
@@ -55,6 +55,17 @@ export class AiVaultHandler {
|
||||
dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) =>
|
||||
this.resolveSessionTitles(service, params, context.signal)
|
||||
)
|
||||
if (service.search) {
|
||||
for (const [suffix, action] of [
|
||||
['Sessions', 'query'],
|
||||
['IndexStatus', 'status'],
|
||||
['Configure', 'configure']
|
||||
] as const) {
|
||||
dispatcher.onRequest(`aiVault.search${suffix}`, (params, context) =>
|
||||
service.search!(action, params, context.signal)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSessionTitles(
|
||||
|
||||
@@ -73,10 +73,7 @@ export function requeueRelayAiVaultServiceStart(
|
||||
return true
|
||||
}
|
||||
|
||||
export function settleRelayAiVaultServiceCall(
|
||||
call: RelayAiVaultServiceCall,
|
||||
value: Error | AiVaultListResult | AiVaultSessionTitlesResult
|
||||
): void {
|
||||
export function settleRelayAiVaultServiceCall(call: RelayAiVaultServiceCall, value: unknown): void {
|
||||
// A cancelled call is settled before its cancel watchdog is armed, so the
|
||||
// timer has to be cleared even when the reject/resolve is already done.
|
||||
if (call.timer) {
|
||||
@@ -102,7 +99,7 @@ export type RelayAiVaultServiceCall = {
|
||||
lane: RelayAiVaultServiceLane
|
||||
signal?: AbortSignal
|
||||
forceStart: boolean
|
||||
resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
@@ -113,6 +110,11 @@ export type RelayAiVaultServiceCall = {
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceApi = {
|
||||
search?(
|
||||
action: 'query' | 'status' | 'configure',
|
||||
params: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown>
|
||||
listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise<AiVaultListResult>
|
||||
resolveSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
|
||||
@@ -114,6 +114,38 @@ describe('RelayAiVaultServiceClient', () => {
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('dispatches search and controls while a history scan is pending', async () => {
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({})
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
for (const action of ['query', 'status', 'configure'] as const) {
|
||||
const pending = client.search(action, action === 'configure' ? { paused: true } : {})
|
||||
await Promise.resolve()
|
||||
const request = relayRequests(child, 'search').at(-1)
|
||||
expect(request, `${action} dispatches before list completion`).toBeDefined()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: request!.id,
|
||||
operation: 'search',
|
||||
value: { action }
|
||||
})
|
||||
await expect(pending).resolves.toEqual({ action })
|
||||
}
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await list
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('does not start queued cache work until cancelled work acknowledges', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
|
||||
@@ -84,10 +84,13 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi {
|
||||
}
|
||||
}
|
||||
|
||||
private request<T extends AiVaultListResult | AiVaultSessionTitlesResult>(
|
||||
request: RelayAiVaultServiceRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
search: NonNullable<RelayAiVaultServiceApi['search']> = (action, params, signal) =>
|
||||
this.request(
|
||||
{ type: 'request', id: this.nextId++, operation: 'search', action, params },
|
||||
signal
|
||||
)
|
||||
|
||||
private request<T>(request: RelayAiVaultServiceRequest, signal?: AbortSignal): Promise<T> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('Relay AI Vault service was disposed.'))
|
||||
}
|
||||
@@ -186,8 +189,7 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi {
|
||||
this.child = child
|
||||
this.ready = new Promise<ChildProcess>((resolve, reject) => {
|
||||
this.readyReject = reject
|
||||
// Why: held on the instance so a crash before ready cannot leave the deadline
|
||||
// armed, where it would later fault the healthy replacement sidecar.
|
||||
// A failed startup must not leave a deadline armed against its replacement.
|
||||
this.readyTimer = setTimeout(
|
||||
() => this.onFault(new Error('Relay AI Vault service did not become ready.')),
|
||||
RELAY_AI_VAULT_READY_TIMEOUT_MS
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import { RelaySessionSearchOwner } from './session-search-owner'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
||||
@@ -24,6 +25,7 @@ let init: RelayAiVaultServiceInit | null = null
|
||||
let cacheLane = Promise.resolve()
|
||||
let interactiveLane = Promise.resolve()
|
||||
let shuttingDown = false
|
||||
let searchOwner: RelaySessionSearchOwner | null = null
|
||||
|
||||
function send(message: RelayAiVaultServiceChildMessage): void {
|
||||
process.send?.(message)
|
||||
@@ -39,6 +41,12 @@ async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
|
||||
if (!init) {
|
||||
throw new Error('Relay AI Vault service is not initialized.')
|
||||
}
|
||||
if (request.operation === 'search') {
|
||||
searchOwner ??= new RelaySessionSearchOwner(init.remoteHome)
|
||||
const value = await searchOwner.request(request.action, request.params, controller.signal)
|
||||
send({ type: 'result', id: request.id, operation: 'search', value })
|
||||
return
|
||||
}
|
||||
if (request.operation === 'titles') {
|
||||
const value = await readAiVaultSessionTitlesFromFiles(request.requests, {
|
||||
signal: controller.signal
|
||||
@@ -79,6 +87,7 @@ async function shutdown(): Promise<void> {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.allSettled([cacheLane, interactiveLane])
|
||||
await searchOwner?.close()
|
||||
process.disconnect?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ export type RelayAiVaultServiceInit = {
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceRequest =
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
operation: 'search'
|
||||
action: 'query' | 'status' | 'configure'
|
||||
params: unknown
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
@@ -32,11 +39,11 @@ export type RelayAiVaultServiceRequest =
|
||||
export type RelayAiVaultServiceLane = 'cache' | 'interactive'
|
||||
export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation']
|
||||
|
||||
/** Title reads must not queue behind a full scan; they back interactive UI. */
|
||||
/** Queries, controls and title reads must not queue behind a full history scan. */
|
||||
export function relayAiVaultServiceLane(
|
||||
operation: RelayAiVaultServiceOperation
|
||||
): RelayAiVaultServiceLane {
|
||||
return operation === 'titles' ? 'interactive' : 'cache'
|
||||
return operation === 'list' ? 'cache' : 'interactive'
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceParentMessage =
|
||||
@@ -46,6 +53,7 @@ export type RelayAiVaultServiceParentMessage =
|
||||
| { type: 'shutdown' }
|
||||
|
||||
export type RelayAiVaultServiceChildMessage =
|
||||
| { type: 'result'; id: number; operation: 'search'; value: unknown }
|
||||
| {
|
||||
type: 'ready'
|
||||
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
|
||||
@@ -68,7 +76,9 @@ export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVa
|
||||
return (
|
||||
message.type === 'request' &&
|
||||
Number.isSafeInteger(message.id) &&
|
||||
(message.operation === 'list' || message.operation === 'titles')
|
||||
(message.operation === 'list' ||
|
||||
message.operation === 'titles' ||
|
||||
message.operation === 'search')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { build } from 'esbuild'
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import type { RelayPlatform } from '../main/ssh/relay-protocol'
|
||||
|
||||
let directory: string | undefined
|
||||
const clients: RelayAiVaultServiceClient[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.splice(0).map((client) => client.dispose()))
|
||||
if (directory) {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('excludes a second scanner process, then releases ownership on owner crash without replaying clear', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-search-process-'))
|
||||
const entry = join(directory, 'scanner.cjs')
|
||||
await build({
|
||||
entryPoints: [resolve('src/relay/ai-vault-service-entry.ts')],
|
||||
outfile: entry,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
const children: ChildProcess[] = []
|
||||
const make = () => {
|
||||
const client = new RelayAiVaultServiceClient({
|
||||
init: {
|
||||
remoteHome: directory!,
|
||||
hostPlatform: getRemoteHostPlatform(`${process.platform}-${process.arch}` as RelayPlatform)
|
||||
},
|
||||
processFactory: () => {
|
||||
const child = fork(entry, [], {
|
||||
execArgv: [],
|
||||
env: {
|
||||
...buildRelayAiVaultServiceEnv(),
|
||||
HOME: directory,
|
||||
USERPROFILE: directory,
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc']
|
||||
})
|
||||
children.push(child)
|
||||
return child
|
||||
}
|
||||
})
|
||||
clients.push(client)
|
||||
return client
|
||||
}
|
||||
const first = make()
|
||||
const second = make()
|
||||
await first.search('configure', { enabled: true, paused: true })
|
||||
const index = join(directory, '.orca', 'session-search-relay', 'index.sqlite')
|
||||
expect(existsSync(index)).toBe(true)
|
||||
await expect(second.search('configure', { enabled: false, clearIndex: true })).rejects.toThrow(
|
||||
'in use'
|
||||
)
|
||||
expect(await first.search('status', {})).toMatchObject({ enabled: true, paused: true })
|
||||
children[0]!.kill('SIGKILL')
|
||||
await vi.waitFor(() => expect(children[0]!.signalCode).toBe('SIGKILL'))
|
||||
expect(await second.search('status', {})).toMatchObject({ enabled: true, paused: true })
|
||||
expect(existsSync(index)).toBe(true)
|
||||
expect(await second.search('configure', { enabled: false, clearIndex: true })).toMatchObject({
|
||||
enabled: false,
|
||||
applied: true
|
||||
})
|
||||
expect(existsSync(index)).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isolatedScanRoots } from '../main/ai-vault/session-scanner-test-fixtures'
|
||||
|
||||
const discovery = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../main/ai-vault/session-scanner-source-discovery', () => ({
|
||||
discoverAiVaultSessionSources: discovery
|
||||
}))
|
||||
import { RelaySessionSearchOwner } from './session-search-owner'
|
||||
import { SessionSearchStore } from '../main/ai-vault-search/session-search-store'
|
||||
import * as candidateParser from '../main/ai-vault-search/session-search-parse-candidates'
|
||||
|
||||
let owner: RelaySessionSearchOwner | undefined
|
||||
let directory: string | undefined
|
||||
afterEach(async () => {
|
||||
await owner?.close()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
discovery.mockReset()
|
||||
if (directory) {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows discovery longer than an ownership window to finish', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-search-progress-'))
|
||||
vi.useFakeTimers()
|
||||
let complete: (() => void) | undefined
|
||||
let aborted = 0
|
||||
discovery.mockImplementation(
|
||||
({ options }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
complete = () => resolve([])
|
||||
options.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted++
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
owner = new RelaySessionSearchOwner(directory, {
|
||||
directory: join(directory, 'search'),
|
||||
roots: isolatedScanRoots(directory)
|
||||
})
|
||||
const state = vi.spyOn(SessionSearchStore.prototype, 'setBackfillState')
|
||||
await owner.request('configure', { enabled: true })
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
expect(discovery).toHaveBeenCalledOnce()
|
||||
expect(aborted).toBe(0)
|
||||
complete!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(state).toHaveBeenCalledWith('complete')
|
||||
const replacement = new RelaySessionSearchOwner(directory, {
|
||||
directory: join(directory, 'search'),
|
||||
roots: isolatedScanRoots(directory)
|
||||
})
|
||||
try {
|
||||
await expect(replacement.request('configure', { enabled: false })).resolves.toMatchObject({
|
||||
enabled: false,
|
||||
applied: true
|
||||
})
|
||||
} finally {
|
||||
await replacement.close()
|
||||
}
|
||||
await expect(owner.request('status', {})).resolves.toMatchObject({ enabled: false })
|
||||
state.mockRestore()
|
||||
})
|
||||
|
||||
it('finishes a slow parse before handing ownership to another relay', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-search-progress-'))
|
||||
vi.useFakeTimers()
|
||||
discovery.mockResolvedValue([])
|
||||
let complete: (() => void) | undefined
|
||||
let parseSignal: AbortSignal | undefined
|
||||
vi.spyOn(candidateParser, 'parseSearchCandidates').mockImplementation(
|
||||
(_store, _candidates, signal) =>
|
||||
new Promise<void>((resolve) => {
|
||||
complete = resolve
|
||||
parseSignal = signal
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
})
|
||||
)
|
||||
const state = vi.spyOn(SessionSearchStore.prototype, 'setBackfillState')
|
||||
owner = new RelaySessionSearchOwner(directory, {
|
||||
directory: join(directory, 'search'),
|
||||
roots: isolatedScanRoots(directory)
|
||||
})
|
||||
await owner.request('configure', { enabled: true })
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
expect(complete).toBeDefined()
|
||||
expect(parseSignal?.aborted).toBe(false)
|
||||
complete!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(state).toHaveBeenCalledWith('complete')
|
||||
})
|
||||
|
||||
it('lets pause interrupt slow discovery even after the ownership deadline', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-search-progress-'))
|
||||
vi.useFakeTimers()
|
||||
let aborted = false
|
||||
discovery.mockImplementation(
|
||||
({ options }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
owner = new RelaySessionSearchOwner(directory, {
|
||||
directory: join(directory, 'search'),
|
||||
roots: isolatedScanRoots(directory)
|
||||
})
|
||||
await owner.request('configure', { enabled: true })
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
expect(aborted).toBe(false)
|
||||
await expect(owner.request('configure', { paused: true })).resolves.toMatchObject({
|
||||
enabled: true,
|
||||
paused: true,
|
||||
applied: true
|
||||
})
|
||||
expect(aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not let an expired pass release ownership of a queued resumed pass', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-search-progress-'))
|
||||
vi.useFakeTimers()
|
||||
const signals: AbortSignal[] = []
|
||||
discovery.mockImplementation(
|
||||
({ options }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signals.push(options.signal)
|
||||
options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
})
|
||||
)
|
||||
owner = new RelaySessionSearchOwner(directory, {
|
||||
directory: join(directory, 'search'),
|
||||
roots: isolatedScanRoots(directory)
|
||||
})
|
||||
await owner.request('configure', { enabled: true })
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
const paused = owner.request('configure', { paused: true })
|
||||
const resumed = owner.request('configure', { paused: false })
|
||||
await Promise.all([paused, resumed])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(signals).toHaveLength(2)
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
expect(signals[1].aborted).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { RelaySessionSearchOwner } from './session-search-owner'
|
||||
import { isolatedScanRoots } from '../main/ai-vault/session-scanner-test-fixtures'
|
||||
import {
|
||||
userRecord,
|
||||
assistantRecord
|
||||
} from '../main/ai-vault-search/session-search-transcript-fixtures'
|
||||
|
||||
const owners: RelaySessionSearchOwner[] = []
|
||||
const directories: string[] = []
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await Promise.all(owners.splice(0).map((owner) => owner.close()))
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
it('releases its lease without killing the scanner or extending ownership on status traffic', async () => {
|
||||
const { make } = await fixture()
|
||||
vi.useFakeTimers()
|
||||
const first = make()
|
||||
const second = make()
|
||||
await first.request('configure', { enabled: true, paused: true })
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
await first.request('status', {})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(await second.request('configure', { enabled: false })).toMatchObject({
|
||||
enabled: false,
|
||||
applied: true
|
||||
})
|
||||
await expect(first.request('status', {})).rejects.toThrow('in use')
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-ssh-search-'))
|
||||
directories.push(home)
|
||||
const directory = join(home, 'search')
|
||||
const roots = isolatedScanRoots(home)
|
||||
const make = () => {
|
||||
const owner = new RelaySessionSearchOwner(home, { directory, roots })
|
||||
owners.push(owner)
|
||||
return owner
|
||||
}
|
||||
return { home, directory, roots, make }
|
||||
}
|
||||
|
||||
it('keeps consent off without creating an index, then searches a host-only deep marker across restart', async () => {
|
||||
const { directory, roots, make } = await fixture()
|
||||
const owner = make()
|
||||
expect(await owner.request('status', {})).toMatchObject({ enabled: false, available: true })
|
||||
expect(existsSync(directory)).toBe(false)
|
||||
await mkdir(roots.claudeProjectsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, 'session.jsonl'),
|
||||
`${[
|
||||
userRecord(0, 'ordinary title'),
|
||||
assistantRecord(1, 'remoteonlyneedle'),
|
||||
userRecord(2, 'ordinary tail')
|
||||
].join('\n')}\n`
|
||||
)
|
||||
await owner.request('configure', { enabled: true })
|
||||
const result = (await owner.request('query', { query: 'remoteonlyneedle' })) as {
|
||||
hits: unknown[]
|
||||
}
|
||||
expect(result.hits).toHaveLength(1)
|
||||
expect(existsSync(join(directory, 'index.sqlite'))).toBe(true)
|
||||
await owner.close()
|
||||
const replacement = make()
|
||||
expect(await replacement.request('status', {})).toMatchObject({ enabled: true, applied: true })
|
||||
expect(
|
||||
await replacement.request('query', { query: 'remoteonlyneedle', refresh: false })
|
||||
).toMatchObject({ hits: [expect.anything()] })
|
||||
await rm(join(roots.claudeProjectsDir, 'session.jsonl'))
|
||||
expect(
|
||||
await replacement.request('query', { query: 'remoteonlyneedle', refresh: false })
|
||||
).toMatchObject({ hits: [] })
|
||||
await replacement.request('configure', { enabled: false, clearIndex: true })
|
||||
expect(existsSync(join(directory, 'index.sqlite'))).toBe(false)
|
||||
expect(await replacement.request('query', { query: 'remoteonlyneedle' })).toMatchObject({
|
||||
hits: [],
|
||||
coverage: { enabled: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses another owner and never acknowledges its clear; release permits the replacement', async () => {
|
||||
const { make } = await fixture()
|
||||
const first = make()
|
||||
const second = make()
|
||||
await first.request('configure', { enabled: true, paused: true })
|
||||
await expect(second.request('configure', { enabled: false, clearIndex: true })).rejects.toThrow(
|
||||
'in use'
|
||||
)
|
||||
expect(await first.request('status', {})).toMatchObject({ enabled: true, paused: true })
|
||||
await first.close()
|
||||
expect(await second.request('configure', { enabled: false, clearIndex: true })).toMatchObject({
|
||||
enabled: false,
|
||||
applied: true
|
||||
})
|
||||
})
|
||||
|
||||
it('can clear and durably disable a corrupt previously enabled index', async () => {
|
||||
const { directory, make } = await fixture()
|
||||
const first = make()
|
||||
await first.request('configure', { enabled: true, paused: true })
|
||||
await first.close()
|
||||
await writeFile(join(directory, 'index.sqlite'), 'not a SQLite database')
|
||||
const replacement = make()
|
||||
await expect(
|
||||
replacement.request('configure', { enabled: false, clearIndex: true })
|
||||
).resolves.toMatchObject({ enabled: false, applied: true })
|
||||
expect(existsSync(join(directory, 'index.sqlite'))).toBe(false)
|
||||
await replacement.close()
|
||||
await expect(make().request('status', {})).resolves.toMatchObject({ enabled: false })
|
||||
})
|
||||
|
||||
it('retains an initialization failure in status until successful recovery', async () => {
|
||||
const { directory, make } = await fixture()
|
||||
const first = make()
|
||||
await first.request('configure', { enabled: true, paused: true })
|
||||
await first.close()
|
||||
await writeFile(join(directory, 'index.sqlite'), 'not a SQLite database')
|
||||
const replacement = make()
|
||||
await expect(replacement.request('query', { query: 'fixture' })).rejects.toThrow('not a database')
|
||||
await expect(replacement.request('status', {})).resolves.toMatchObject({
|
||||
enabled: true,
|
||||
applied: false,
|
||||
reason: expect.stringContaining('not a database')
|
||||
})
|
||||
await expect(replacement.request('configure', { clearIndex: true })).resolves.toMatchObject({
|
||||
enabled: true,
|
||||
applied: true
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
closeSync,
|
||||
openSync
|
||||
} from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import Database from '../main/sqlite/sync-database'
|
||||
import { hardenSecurePath, writeDurableSecureJsonFile } from '../shared/secure-file'
|
||||
import { restrictWindowsPathSync } from '../shared/secure-path-windows-acl'
|
||||
import {
|
||||
SessionSearchService,
|
||||
type SessionSearchScanRoots
|
||||
} from '../main/ai-vault-search/session-search-service'
|
||||
import { sessionSearchCapability } from '../main/ai-vault-search/session-search-capability'
|
||||
import {
|
||||
DEFAULT_AI_VAULT_SEARCH_SETTINGS,
|
||||
type AiVaultSearchSettings,
|
||||
type AiVaultSearchIndexStatus
|
||||
} from '../shared/ai-vault-search-settings'
|
||||
import {
|
||||
SessionSearchConfigureSchema,
|
||||
SessionSearchQuerySchema,
|
||||
type SessionSearchConfigure
|
||||
} from '../shared/ai-vault-search-contract'
|
||||
import { throwIfSignalAborted } from '../shared/abort-signal-reason'
|
||||
import { projectSessionSearchResult } from '../shared/ai-vault-search-projection'
|
||||
|
||||
/** Account-local ownership; SQLite releases the exclusion lock even after a child crash. */
|
||||
export class RelaySessionSearchOwner {
|
||||
private service: SessionSearchService | null = null
|
||||
private lock: Database | null = null
|
||||
private policy: AiVaultSearchSettings = DEFAULT_AI_VAULT_SEARCH_SETTINGS
|
||||
private chain: Promise<unknown> = Promise.resolve()
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private disposed = false
|
||||
private applicationError: string | undefined
|
||||
private readonly directory: string
|
||||
private readonly roots: SessionSearchScanRoots
|
||||
|
||||
constructor(
|
||||
private readonly home: string,
|
||||
options: { directory?: string; roots?: SessionSearchScanRoots } = {}
|
||||
) {
|
||||
if (!options.roots && home !== homedir()) {
|
||||
throw new Error('Search source home does not match the relay account.')
|
||||
}
|
||||
this.directory = options.directory ?? join(home, '.orca', 'session-search-relay')
|
||||
this.roots = options.roots ?? {
|
||||
wslHomeDirs: [],
|
||||
additionalCodexSessionsDirs: [
|
||||
join(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions')
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
request(
|
||||
operation: 'query' | 'status' | 'configure',
|
||||
raw: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown> {
|
||||
return this.serialize(async () => {
|
||||
throwIfSignalAborted(signal)
|
||||
if (this.disposed) {
|
||||
throw new Error('Search owner is closed.')
|
||||
}
|
||||
const capability = sessionSearchCapability()
|
||||
if (operation === 'status' && !this.lock) {
|
||||
this.policy = this.readPolicy()
|
||||
// An existing owner's effective policy is only observable while holding the lock.
|
||||
if (!existsSync(join(this.directory, 'owner.sqlite'))) {
|
||||
return this.status(capability.available, capability.reason)
|
||||
}
|
||||
}
|
||||
if (!capability.available) {
|
||||
if (operation === 'status') {
|
||||
return this.status(false, capability.reason)
|
||||
}
|
||||
throw new Error(capability.reason)
|
||||
}
|
||||
this.acquire()
|
||||
try {
|
||||
if (operation === 'status') {
|
||||
return this.status(true)
|
||||
}
|
||||
if (operation === 'configure') {
|
||||
const args = SessionSearchConfigureSchema.parse(raw)
|
||||
await this.configure(args)
|
||||
return this.status(true)
|
||||
}
|
||||
const query = SessionSearchQuerySchema.parse(raw)
|
||||
this.service ??= this.createService()
|
||||
const result = await this.service.search(query, this.roots, signal)
|
||||
return projectSessionSearchResult(result)
|
||||
} catch (error) {
|
||||
await this.release()
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private get databasePath(): string {
|
||||
return join(this.directory, 'index.sqlite')
|
||||
}
|
||||
|
||||
private serialize<T>(run: () => Promise<T>): Promise<T> {
|
||||
const result = this.chain.catch(() => undefined).then(run)
|
||||
this.chain = result
|
||||
return result
|
||||
}
|
||||
|
||||
private readPolicy(): AiVaultSearchSettings {
|
||||
const file = join(this.directory, 'policy.json')
|
||||
if (!existsSync(file)) {
|
||||
return { ...DEFAULT_AI_VAULT_SEARCH_SETTINGS }
|
||||
}
|
||||
this.assertOwned(file, false)
|
||||
if (statSync(file).size > 8192) {
|
||||
throw new Error('Search policy exceeds its size limit.')
|
||||
}
|
||||
const saved = JSON.parse(readFileSync(file, 'utf8'))
|
||||
if (saved.home !== this.home || saved.sources !== 1) {
|
||||
throw new Error('Search source configuration changed; host policy must be reviewed.')
|
||||
}
|
||||
const policy = SessionSearchConfigureSchema.parse(saved.policy)
|
||||
if (typeof policy.enabled !== 'boolean' || policy.historyDays === undefined) {
|
||||
throw new Error('Invalid search policy.')
|
||||
}
|
||||
return {
|
||||
enabled: policy.enabled,
|
||||
historyDays: policy.historyDays,
|
||||
...(policy.paused ? { paused: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private assertOwned(path: string, directory: boolean): void {
|
||||
const stat = lstatSync(path)
|
||||
if (
|
||||
stat.isSymbolicLink() ||
|
||||
(directory ? !stat.isDirectory() : !stat.isFile()) ||
|
||||
(process.getuid && stat.uid !== process.getuid())
|
||||
) {
|
||||
throw new Error('Unsafe search owner path.')
|
||||
}
|
||||
}
|
||||
|
||||
private acquire(): void {
|
||||
if (this.lock) {
|
||||
return
|
||||
}
|
||||
if (existsSync(dirname(this.directory))) {
|
||||
this.assertOwned(dirname(this.directory), true)
|
||||
}
|
||||
mkdirSync(this.directory, { recursive: true, mode: 0o700 })
|
||||
this.assertOwned(this.directory, true)
|
||||
if (process.platform === 'win32') {
|
||||
if (!restrictWindowsPathSync(this.directory, true)) {
|
||||
throw new Error('Could not secure the host search directory.')
|
||||
}
|
||||
} else {
|
||||
hardenSecurePath(this.directory, {
|
||||
isDirectory: true,
|
||||
platform: process.platform,
|
||||
sync: true
|
||||
})
|
||||
}
|
||||
const path = join(this.directory, 'owner.sqlite')
|
||||
try {
|
||||
closeSync(openSync(path, 'wx', 0o600))
|
||||
} catch (error) {
|
||||
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.assertOwned(path, false)
|
||||
for (const suffix of ['', '-wal', '-shm', '-journal']) {
|
||||
const file = `${this.databasePath}${suffix}`
|
||||
if (existsSync(file)) {
|
||||
this.assertOwned(file, false)
|
||||
}
|
||||
}
|
||||
const lock = new Database(path, { timeout: 0 })
|
||||
try {
|
||||
lock.exec('BEGIN EXCLUSIVE')
|
||||
} catch {
|
||||
lock.close()
|
||||
throw new Error(
|
||||
'Search index is in use by another relay. Retry after its current indexing pass completes.'
|
||||
)
|
||||
}
|
||||
this.lock = lock
|
||||
try {
|
||||
this.policy = this.readPolicy()
|
||||
} catch (error) {
|
||||
this.lock = null
|
||||
lock.close()
|
||||
throw error
|
||||
}
|
||||
// Do not extend on traffic: a newer relay generation must get a chance to acquire.
|
||||
this.timer = setTimeout(() => {
|
||||
void this.serialize(() => this.yieldBackfill()).catch(() => undefined)
|
||||
}, 5_000)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
private async configure(args: SessionSearchConfigure): Promise<void> {
|
||||
const next: AiVaultSearchSettings = {
|
||||
enabled: args.enabled ?? this.policy.enabled,
|
||||
historyDays: args.historyDays === undefined ? this.policy.historyDays : args.historyDays,
|
||||
...((args.paused ?? this.policy.paused) ? { paused: true } : {})
|
||||
}
|
||||
try {
|
||||
// Configuration must remain usable even when the existing index cannot be opened.
|
||||
this.service ??= this.createService(false)
|
||||
await this.service.configure(next, this.roots, { clearIndex: args.clearIndex })
|
||||
if (
|
||||
!writeDurableSecureJsonFile(join(this.directory, 'policy.json'), {
|
||||
home: this.home,
|
||||
sources: 1,
|
||||
policy: next
|
||||
})
|
||||
) {
|
||||
throw new Error('Could not secure the host search policy.')
|
||||
}
|
||||
this.policy = next
|
||||
this.applicationError = undefined
|
||||
} catch (error) {
|
||||
this.applicationError =
|
||||
error instanceof Error ? error.message : 'Search configuration failed.'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private createService(enabled = this.policy.enabled): SessionSearchService {
|
||||
try {
|
||||
const service = new SessionSearchService({
|
||||
databasePath: this.databasePath,
|
||||
...this.policy,
|
||||
enabled
|
||||
})
|
||||
this.applicationError = undefined
|
||||
return service
|
||||
} catch (error) {
|
||||
this.applicationError =
|
||||
error instanceof Error ? error.message : 'Search initialization failed.'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private status(available: boolean, reason?: string): AiVaultSearchIndexStatus {
|
||||
let indexSizeBytes: number | null = null
|
||||
if (existsSync(this.databasePath)) {
|
||||
indexSizeBytes = ['', '-wal', '-shm', '-journal'].reduce((bytes, suffix) => {
|
||||
try {
|
||||
return bytes + statSync(`${this.databasePath}${suffix}`).size
|
||||
} catch {
|
||||
return bytes
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
return {
|
||||
...this.policy,
|
||||
available,
|
||||
applied: available && !this.applicationError,
|
||||
indexSizeBytes,
|
||||
...((reason ?? this.applicationError) ? { reason: reason ?? this.applicationError } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private async release(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.timer = null
|
||||
try {
|
||||
await this.service?.close()
|
||||
} finally {
|
||||
this.service = null
|
||||
this.lock?.close()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
|
||||
private async yieldBackfill(): Promise<void> {
|
||||
const service = this.service
|
||||
if (service?.coverage().backfill === 'running') {
|
||||
// Finish discovery and parsing before handoff; restarting either can starve large histories.
|
||||
void service
|
||||
.ensureBackfill(this.roots)
|
||||
.then(() =>
|
||||
this.serialize(async () => {
|
||||
if (this.service === service) {
|
||||
await this.yieldBackfill()
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(() => undefined)
|
||||
return
|
||||
}
|
||||
await this.release()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.disposed = true
|
||||
await this.serialize(() => this.release())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createElement, StrictMode, type ReactNode } from 'react'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSearchCoverageStore } from './ai-vault-search-coverage-store'
|
||||
import type { AiVaultSearchCoverage } from '../../../../shared/ai-vault-search-types'
|
||||
import {
|
||||
AI_VAULT_SEARCH_COVERAGE_POLL_MS,
|
||||
@@ -59,25 +60,16 @@ describe('useAiVaultSearchCoveragePoll', () => {
|
||||
expect(searchCoverage).toHaveBeenCalledTimes(callsAfterFirstRead + 3)
|
||||
})
|
||||
|
||||
it('ignores a slow running answer that lands after a newer complete one', async () => {
|
||||
let releaseFirst: (value: ReturnType<typeof coverage>) => void = () => undefined
|
||||
searchCoverage
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(coverage('complete'))
|
||||
const { result } = renderHook(() => useAiVaultSearchCoveragePoll(true), { wrapper })
|
||||
it('publishes consistently slow successes without overlapping polls', async () => {
|
||||
searchCoverage.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(coverage('running')), 5_000))
|
||||
)
|
||||
const { result } = renderHook(() => useAiVaultSearchCoveragePoll(true))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AI_VAULT_SEARCH_COVERAGE_POLL_MS)
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
})
|
||||
expect(result.current?.backfill).toBe('complete')
|
||||
await act(async () => {
|
||||
releaseFirst(coverage('running'))
|
||||
})
|
||||
expect(result.current?.backfill).toBe('complete')
|
||||
expect(result.current?.backfill).toBe('running')
|
||||
expect(searchCoverage).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('keeps polling while the backfill is still running', async () => {
|
||||
@@ -147,3 +139,31 @@ it('shares a single polling subscription between surfaces', async () => {
|
||||
})
|
||||
expect(searchCoverage).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('observes controls after mutation and ignores the outstanding older poll', async () => {
|
||||
let releaseOld!: (value: AiVaultSearchCoverage) => void
|
||||
let finishAction!: () => void
|
||||
searchCoverage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseOld = resolve
|
||||
})
|
||||
)
|
||||
const store = createSearchCoverageStore()
|
||||
const unsubscribe = store.subscribe(() => undefined)
|
||||
const controlled = store.control(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishAction = resolve
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
expect(searchCoverage).toHaveBeenCalledTimes(1)
|
||||
finishAction()
|
||||
await controlled
|
||||
expect(store.getSnapshot().coverage?.backfill).toBe('complete')
|
||||
releaseOld(coverage('running'))
|
||||
await Promise.resolve()
|
||||
expect(store.getSnapshot().coverage?.backfill).toBe('complete')
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
@@ -27,22 +27,38 @@ export function createSearchCoverageStore() {
|
||||
snapshot = { ...snapshot, ...next }
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
const refresh = async (): Promise<void> => {
|
||||
const issued = ++generation
|
||||
try {
|
||||
const coverage = await window.api.aiVault.searchCoverage()
|
||||
if (issued === generation) {
|
||||
publish({ coverage, unavailable: false, observedAt: Date.now() })
|
||||
}
|
||||
} catch {
|
||||
if (issued === generation) {
|
||||
publish({ coverage: null, unavailable: true })
|
||||
}
|
||||
let pending: Promise<void> | null = null
|
||||
const refresh = (afterControl = false): Promise<void> => {
|
||||
if (snapshot.busy && !afterControl) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
const issued = generation
|
||||
const request = (async () => {
|
||||
try {
|
||||
const coverage = await window.api.aiVault.searchCoverage()
|
||||
if (issued === generation) {
|
||||
publish({ coverage, unavailable: false, observedAt: Date.now() })
|
||||
}
|
||||
} catch {
|
||||
if (issued === generation) {
|
||||
publish({ coverage: null, unavailable: true })
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
if (pending === request) {
|
||||
pending = null
|
||||
}
|
||||
})
|
||||
pending = request
|
||||
return request
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
refresh,
|
||||
refresh: () => refresh(),
|
||||
subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
if (listeners.size === 1) {
|
||||
@@ -57,6 +73,7 @@ export function createSearchCoverageStore() {
|
||||
stop?.()
|
||||
stop = null
|
||||
generation++
|
||||
pending = null
|
||||
snapshot = {
|
||||
coverage: null,
|
||||
error: false,
|
||||
@@ -71,14 +88,22 @@ export function createSearchCoverageStore() {
|
||||
if (snapshot.busy) {
|
||||
return
|
||||
}
|
||||
const controlled = ++generation
|
||||
pending = null
|
||||
publish({ busy: true, error: false })
|
||||
try {
|
||||
await action()
|
||||
await refresh()
|
||||
if (controlled === generation) {
|
||||
await refresh(true)
|
||||
}
|
||||
} catch {
|
||||
publish({ error: true })
|
||||
if (controlled === generation) {
|
||||
publish({ error: true })
|
||||
}
|
||||
} finally {
|
||||
publish({ busy: false })
|
||||
if (controlled === generation) {
|
||||
publish({ busy: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { z } from 'zod'
|
||||
import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from './ai-vault-types'
|
||||
import {
|
||||
AI_VAULT_SEARCH_LIMIT_MAX,
|
||||
AI_VAULT_SEARCH_QUERY_MAX_LENGTH
|
||||
} from './ai-vault-search-types'
|
||||
|
||||
export const SessionSearchQuerySchema = z.object({
|
||||
query: z.string().trim().min(1).max(AI_VAULT_SEARCH_QUERY_MAX_LENGTH),
|
||||
limit: z.number().int().min(1).max(AI_VAULT_SEARCH_LIMIT_MAX).optional(),
|
||||
agents: z.array(z.enum(AI_VAULT_AGENTS)).max(AI_VAULT_AGENTS.length).optional(),
|
||||
scopePaths: z.array(z.string().min(1).max(4096)).max(AI_VAULT_SCOPE_PATHS_MAX_COUNT).optional(),
|
||||
since: z.string().datetime({ offset: true }).optional(),
|
||||
sort: z.enum(['relevance', 'newest']).optional(),
|
||||
tier: z.enum(['full', 'conversation']).optional(),
|
||||
refresh: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const SessionSearchConfigureSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
paused: z.boolean().optional(),
|
||||
historyDays: z.number().int().min(1).max(3650).nullable().optional(),
|
||||
clearIndex: z.boolean().optional()
|
||||
})
|
||||
export type SessionSearchConfigure = z.infer<typeof SessionSearchConfigureSchema>
|
||||
|
||||
export const SessionSearchStatusSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
paused: z.boolean().optional(),
|
||||
historyDays: z.number().int().positive().nullable(),
|
||||
indexSizeBytes: z.number().nonnegative().nullable(),
|
||||
available: z.boolean().optional(),
|
||||
applied: z.boolean().optional(),
|
||||
reason: z.string().max(4096).optional()
|
||||
})
|
||||
|
||||
const boundedText = z.string().max(32768)
|
||||
export const SessionSearchResultSchema = z.object({
|
||||
hits: z
|
||||
.array(
|
||||
z.object({
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
sessionId: boundedText,
|
||||
filePath: boundedText,
|
||||
codexHome: boundedText.nullable(),
|
||||
title: boundedText,
|
||||
cwd: boundedText.nullable(),
|
||||
branch: boundedText.nullable(),
|
||||
updatedAt: boundedText.nullable(),
|
||||
messageCount: z.number().nonnegative(),
|
||||
resumeCommand: boundedText,
|
||||
score: z.number(),
|
||||
duplicateCount: z.number().optional(),
|
||||
evidence: z.object({
|
||||
role: z.enum(['user', 'assistant', 'tool', 'system', 'unknown']),
|
||||
timestamp: boundedText.nullable(),
|
||||
snippet: boundedText
|
||||
})
|
||||
})
|
||||
)
|
||||
.max(AI_VAULT_SEARCH_LIMIT_MAX),
|
||||
route: z.enum(['phrase', 'and', 'or', 'typo+phrase', 'typo+and', 'typo+or']),
|
||||
repairedTerms: z.array(boundedText).max(512).optional(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
coverage: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
sessionsIndexed: z.number().nonnegative(),
|
||||
messagesIndexed: z.number().nonnegative(),
|
||||
providers: z
|
||||
.array(
|
||||
z.object({
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
sessionsIndexed: z.number().nonnegative(),
|
||||
messagesIndexed: z.number().nonnegative(),
|
||||
filesDiscovered: z.number().optional(),
|
||||
parseFailures: z.number().optional(),
|
||||
scanIssues: z.number().optional()
|
||||
})
|
||||
)
|
||||
.max(AI_VAULT_AGENTS.length),
|
||||
backfill: z.enum(['idle', 'running', 'complete']),
|
||||
filesPending: z.number().nonnegative(),
|
||||
lastIndexedAt: boundedText.nullable(),
|
||||
indexing: z
|
||||
.object({
|
||||
phase: z.enum([
|
||||
'idle',
|
||||
'discovering',
|
||||
'indexing',
|
||||
'updating',
|
||||
'paused',
|
||||
'complete',
|
||||
'error'
|
||||
]),
|
||||
filesProcessed: z.number().nonnegative(),
|
||||
filesTotal: z.number().nonnegative().nullable(),
|
||||
failures: z.number().nonnegative(),
|
||||
startedAt: z.number()
|
||||
})
|
||||
.optional()
|
||||
}),
|
||||
omittedHits: z.number().int().nonnegative().optional(),
|
||||
truncatedSnippets: z.number().int().nonnegative().optional(),
|
||||
sourceUnavailableFiles: z.number().int().nonnegative().optional()
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { projectSessionSearchResult } from './ai-vault-search-projection'
|
||||
import type { AiVaultSearchResult, AiVaultSearchHit } from './ai-vault-search-types'
|
||||
|
||||
const hit: AiVaultSearchHit = {
|
||||
agent: 'codex',
|
||||
sessionId: 'same-id',
|
||||
filePath: '/same/file.jsonl',
|
||||
codexHome: null,
|
||||
title: 'title',
|
||||
cwd: '/same',
|
||||
branch: null,
|
||||
updatedAt: null,
|
||||
messageCount: 1,
|
||||
resumeCommand: 'codex resume same-id',
|
||||
score: 1,
|
||||
evidence: { role: 'user', timestamp: null, snippet: '🦀'.repeat(5000) }
|
||||
}
|
||||
const result = (hits: AiVaultSearchHit[]): AiVaultSearchResult => ({
|
||||
hits,
|
||||
route: 'phrase',
|
||||
durationMs: 1,
|
||||
coverage: {
|
||||
sessionsIndexed: hits.length,
|
||||
messagesIndexed: hits.length,
|
||||
providers: [],
|
||||
backfill: 'complete',
|
||||
filesPending: 0,
|
||||
lastIndexedAt: null
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds snippets on UTF-8 boundaries while preserving paths and session identity', () => {
|
||||
const projected = projectSessionSearchResult(result([hit]))
|
||||
expect(Buffer.byteLength(projected.hits[0]!.evidence.snippet)).toBe(4096)
|
||||
expect(projected.hits[0]!.evidence.snippet).not.toContain('\uFFFD')
|
||||
expect(projected.hits[0]).toMatchObject({ sessionId: hit.sessionId, filePath: hit.filePath })
|
||||
expect(projected.truncatedSnippets).toBe(1)
|
||||
})
|
||||
|
||||
it('omits oversized identities and caps serialized response size with explicit accounting', () => {
|
||||
const projected = projectSessionSearchResult(
|
||||
result([
|
||||
{ ...hit, sessionId: 'x'.repeat(40000) },
|
||||
...Array.from({ length: 100 }, () => ({ ...hit, title: 'x'.repeat(32000) }))
|
||||
])
|
||||
)
|
||||
expect(Buffer.byteLength(JSON.stringify(projected))).toBeLessThanOrEqual(512 * 1024)
|
||||
expect(projected.omittedHits! + projected.hits.length).toBe(101)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { SessionSearchResultSchema } from './ai-vault-search-contract'
|
||||
import type { AiVaultSearchResult } from './ai-vault-search-types'
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
function snippet(text: string): string {
|
||||
const bytes = encoder.encode(text)
|
||||
if (bytes.length <= 4096) {
|
||||
return text
|
||||
}
|
||||
let end = 4096
|
||||
while (end > 0) {
|
||||
try {
|
||||
return decoder.decode(bytes.subarray(0, end))
|
||||
} catch {
|
||||
end--
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Bound before every host transport; never shorten session identities or resume paths. */
|
||||
export function projectSessionSearchResult(result: AiVaultSearchResult): AiVaultSearchResult {
|
||||
const metadata = SessionSearchResultSchema.parse({ ...result, hits: [] })
|
||||
const hits: AiVaultSearchResult['hits'] = []
|
||||
let truncatedSnippets = result.truncatedSnippets ?? 0
|
||||
let omittedHits = result.omittedHits ?? 0
|
||||
let bytes = encoder.encode(JSON.stringify(metadata)).length + 256
|
||||
if (bytes > 64 * 1024) {
|
||||
throw new Error('Search metadata exceeds the response limit.')
|
||||
}
|
||||
for (const hit of result.hits) {
|
||||
const text = snippet(hit.evidence.snippet)
|
||||
const projected = { ...hit, evidence: { ...hit.evidence, snippet: text } }
|
||||
const size = encoder.encode(JSON.stringify(projected)).length + 1
|
||||
if (
|
||||
hits.length >= 100 ||
|
||||
bytes + size > 512 * 1024 ||
|
||||
!SessionSearchResultSchema.shape.hits.element.safeParse(projected).success
|
||||
) {
|
||||
omittedHits++
|
||||
continue
|
||||
}
|
||||
if (text !== hit.evidence.snippet) {
|
||||
truncatedSnippets++
|
||||
}
|
||||
bytes += size
|
||||
hits.push(projected)
|
||||
}
|
||||
return SessionSearchResultSchema.parse({
|
||||
...result,
|
||||
hits,
|
||||
...(omittedHits ? { omittedHits } : {}),
|
||||
...(truncatedSnippets ? { truncatedSnippets } : {})
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export function resolveAiVaultSearchSettings(
|
||||
|
||||
/** What Settings shows about the local index: the policy plus its disk footprint. */
|
||||
export type AiVaultSearchIndexStatus = AiVaultSearchSettings & {
|
||||
available?: boolean
|
||||
applied?: boolean
|
||||
reason?: string
|
||||
/** Database + WAL sidecars in bytes; null when no index file exists. */
|
||||
indexSizeBytes: number | null
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ export type AiVaultSearchHit = {
|
||||
export type AiVaultSearchRoute = 'phrase' | 'and' | 'or' | 'typo+phrase' | 'typo+and' | 'typo+or'
|
||||
|
||||
export type AiVaultSearchResult = {
|
||||
omittedHits?: number
|
||||
truncatedSnippets?: number
|
||||
sourceUnavailableFiles?: number
|
||||
hits: AiVaultSearchHit[]
|
||||
route: AiVaultSearchRoute
|
||||
/** Query terms after typo repair, when any were changed. */
|
||||
|
||||
@@ -26,7 +26,10 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [
|
||||
// Forked so a native @parcel/watcher fault kills the child, not the server.
|
||||
{ filename: 'parcel-watcher-process-entry.js' },
|
||||
// Forked so PTYs outlive the runtime process; its absence makes every restart destructive.
|
||||
{ filename: 'daemon-entry.js' }
|
||||
{ filename: 'daemon-entry.js' },
|
||||
{ filename: 'session-scanner-service-entry.js' },
|
||||
{ filename: 'session-scanner-opencode-sqlite-worker-entry.js' },
|
||||
{ filename: 'wsl-transcript-fs-process-entry.js' }
|
||||
]
|
||||
|
||||
/** Written after the artifacts, so it is never an input to its own hash. */
|
||||
|
||||
@@ -52,6 +52,7 @@ export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [
|
||||
{ filename: 'relay.js' },
|
||||
{ filename: 'relay-watcher.js', daemonServiceChild: true },
|
||||
{ filename: 'relay-ai-vault-service.js', daemonServiceChild: true },
|
||||
{ filename: 'session-scanner-opencode-sqlite-worker-entry.js' },
|
||||
{ filename: 'managed-hook-runtime.js' },
|
||||
// Forked by the AI Vault title reader; without it a relay answers every WSL
|
||||
// title request with no title and no error.
|
||||
|
||||
@@ -63,7 +63,8 @@ export function sendRemoteRuntimeRequestWithStatusPreflight<TResult>(
|
||||
timeoutMs: number,
|
||||
validateStatus: (response: RuntimeRpcResponse<RuntimeStatus>) => void,
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
clientCapabilities: readonly RuntimeCapability[] = []
|
||||
clientCapabilities: readonly RuntimeCapability[] = [],
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
return sendRemoteRuntimeRequestOnSocket(
|
||||
pairing,
|
||||
@@ -72,7 +73,7 @@ export function sendRemoteRuntimeRequestWithStatusPreflight<TResult>(
|
||||
timeoutMs,
|
||||
envelope,
|
||||
validateStatus,
|
||||
undefined,
|
||||
signal,
|
||||
clientCapabilities
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,7 +183,12 @@ export async function sendRemoteRuntimeRequestOnSocket<TResult>(
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(pairing.endpoint, { maxPayload: REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES })
|
||||
ws = new WebSocket(pairing.endpoint, {
|
||||
maxPayload:
|
||||
method.startsWith('aiVault.') && /search/i.test(method)
|
||||
? 4 * 1024 * 1024
|
||||
: REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
finishError(
|
||||
|
||||
@@ -206,7 +206,8 @@ describe('runtime client public export parity', () => {
|
||||
timeoutMs: number,
|
||||
validateStatus: (response: RuntimeRpcResponse<Runtime.RuntimeStatus>) => void,
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
clientCapabilities?: readonly RuntimeCapability[],
|
||||
signal?: AbortSignal
|
||||
) => Promise<RuntimeRpcResponse<TResult>>
|
||||
>()
|
||||
expectTypeOf(RemoteClient.subscribeRemoteRuntimeRequest).toEqualTypeOf<
|
||||
|
||||
Reference in New Issue
Block a user