Files
orca/src/cli/agent-session-search-format.ts
T
Jinwoo Hong 73a58bd21a feat(session-search): resolve Workspace and Project scope on the host (#21509)
* refactor(session-search): move the AI Vault project key to shared

The host must spell a project key exactly as the client does, so the two
sides share one function instead of two copies that can drift.

* feat(session-search): add a scope identity to the search request

The panel cannot keep translating a project into one path per worktree: a
repo with 580 of them exceeds the 64-path cap and the search fails outright.
The request now carries the scope's identity instead, and a host acknowledges
the scope it resolved so a client can tell a scoped answer from an old host's
unscoped one.

* feat(session-search): resolve a scope identity on the host that answers

Every entry point already funnels into searchSessionService, so the identity
becomes paths there once: native, WSL, SSH and relay hosts cannot disagree.
A host that does not know the workspace or project answers scope-unknown
rather than widening the search to everything it has.

* test(session-search): pin how a host resolves a scope identity

Covers prior paths, a workspace another now claims, folder workspaces, a
custom worktree base path, flat placement where the global root belongs to
every project, and the 580-worktree fold the panel's path list could not do.

* fix(session-search): type the scope store by what the catalog reads

A full Repo/Project/ProjectHostSetup requirement forced test stores to stand
up rows the catalog never looks at.

* feat(session-search): send the scope identity from the panel

Workspace and Project name what to narrow to; All sends nothing. A host that
answers a scoped search without acknowledging it is reported as needing an
update, and none of its hits are shown, because they are not this scope's.

* test(session-search): pin the new-client-against-old-host skew

An old host strips the identity and answers with every session it has, and
the answer is well-formed. The missing acknowledgement is the only evidence,
so the merge drops those hits and names the host instead.

* test(session-search): pin the identity and acknowledgement across every entry point

IPC, the runtime RPC method, the relay handler and the shared remote client
each carry the identity out and the acknowledgement back, and the relay -- which
has no repo catalog -- reports the scope rather than widening the search.

* fix(session-search): acknowledge the scope on an all-computers merge

The merge built its results without the acknowledgement, so the renderer read
it as an old host, dropped every hit and asked for an update. That is the
default path: the panel defaults to Workspace and the host scope falls back to
All. Per-host skew is still reported through `hosts`.

Host-resolved paths no longer travel in `filters.scopePaths`. That field is
capped at 64 for the clients that write it by hand, and the scanner child
re-parses the request with the same schema -- so a project whose worktrees do
not share one managed directory failed at 65 paths with "not ready". They ride
beside the request now, where no wire cap applies.

Managed directories come from buildKnownOrcaWorkspaceLayouts, so a workspace
root the user has since moved away from is covered too.

A workspace identity is resolved through this host's own worktree registry
rather than the directory embedded in the client-supplied id.

* test(session-search): follow the service search signature

Host-resolved paths are a second argument now, so the call-shape assertions
that pinned a one-argument call name it.

* fix(session-search): answer consent and readiness before an unknown scope

The registry short-circuited an unresolvable scope before current.search ran,
and current.search is where disabled and not-ready are decided. A host with
indexing off that lacks the project told the user it did not have the
workspace, which they cannot act on. The verdict now travels to the service
beside the request, and the service answers it after its own checks.

* fix(session-search): acknowledge only a scope that resolved

An unknown verdict is still a verdict, and it was being acknowledged as if the
host had narrowed. The skipped banner also counted only 'searched' as having
resolved the scope, so a host that resolved it and came back stale or timed out
let the scope lines reappear where they explain nothing.

* refactor(session-search): drop the version-mismatch receipt

No stable release ships search, so the only hosts that have it and predate
`within` are dev and ad hoc builds. The acknowledgement, the needs-update
outcome and the copy behind it would be permanent dead weight from the first
stable release on. The scope-unknown outcome and the off / not-ready / unknown
ordering stay.

Also trims this PR's new docblocks to the repo's one-line why rule.
2026-09-18 16:46:31 -04:00

149 lines
5.4 KiB
TypeScript

import {
stripAnsiEscapeSequences,
TERMINAL_CONTROL_CHARACTER_PATTERN
} from '../shared/ansi-escape-sequences'
import { aiVaultAgentLabel } from '../shared/ai-vault-types'
import type {
AiVaultSearchHit,
AiVaultSearchResponse,
AiVaultSearchStatus
} from '../shared/ai-vault-search-types'
type SessionSearchResults = Extract<AiVaultSearchResponse, { kind: 'results' }>
/** Malformed cursors are a caller mistake and leave through the CLI error channel. */
export type PrintableSessionSearchResponse = Exclude<
AiVaultSearchResponse,
{ kind: 'malformed-cursor' }
>
/**
* Transcript text reaches the terminal verbatim, so an OSC 52 or cursor sequence
* inside a tool log would otherwise run on the reader's terminal. The `[[` `]]`
* match marks are left as the engine wrote them: this CLI emits no ANSI anywhere,
* so a colour scheme invented here would be the only one in the surface.
*/
export function terminalSafe(value: string): string {
return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
}
function oneLine(value: string): string {
return terminalSafe(value)
.replaceAll(/[\r\n]+/g, ' ')
.trim()
}
function formatHit(hit: AiVaultSearchHit): string {
const host = oneLine(hit.executionHostId ?? '')
const header = [
aiVaultAgentLabel(hit.agent),
oneLine(hit.updatedAt ?? '') || 'unknown time',
oneLine(hit.title) || '(untitled)',
...(host ? [`host=${host}`] : [])
].join(' ')
const evidence = hit.evidence
? ` ${hit.evidence.role}: ${oneLine(hit.evidence.snippet)}`
: ' no text evidence for this match'
// Withheld for paired callers by the contract, so its absence is not a failure.
const resume = hit.resumeCommand ? [` resume: ${oneLine(hit.resumeCommand)}`] : []
return [header, evidence, ...resume].join('\n')
}
function formatTruncation(truncated: SessionSearchResults['truncated']): string[] {
return [
...(truncated.candidates
? ['ranking saw only the first batch of candidate sessions, so a better match may be missing']
: []),
...(truncated.query ? ['query was cut'] : []),
...(truncated.freshness
? ['freshness wait timed out; these results come from the index as it stood']
: []),
...(truncated.snippets > 0 ? [`${truncated.snippets} snippets were shortened`] : [])
]
}
function formatDebug(debug: SessionSearchResults['debug']): string[] {
if (!debug) {
return []
}
return [
'',
'debug:',
` route: ${debug.route}`,
...(debug.repairedTerms
? [` repairedTerms: ${debug.repairedTerms.map((term) => oneLine(term)).join(' ')}`]
: []),
` plannerScope: ${debug.plannerReport.scope}`
]
}
function formatUnavailable(
reason: 'disabled' | 'not-ready' | 'no-service' | 'scope-unknown'
): string {
if (reason === 'disabled') {
return 'Session search is off on this host.'
}
// The CLI scopes with --path, never with an identity, so this only reaches a
// caller that built a request by hand.
if (reason === 'scope-unknown') {
return 'This host does not know the workspace or project that search was scoped to.'
}
if (reason === 'not-ready') {
return 'Session search is not ready on this host yet. Try again once its index has started.'
}
return [
'This host runs no session search service.',
'An Orca host older than session search answers the same way; update it and try again.'
].join('\n')
}
function formatResults(response: SessionSearchResults): string {
const body =
response.hits.length === 0 ? ['No sessions match this query.'] : response.hits.map(formatHit)
const cursor = oneLine(response.page.cursor ?? '')
const footer = [
`${response.hits.length} ${response.hits.length === 1 ? 'result' : 'results'} on this page, ${Math.round(response.durationMs)} ms.`,
...(response.page.hasMore && cursor
? [`more pages: re-run with --cursor ${cursor}`]
: response.page.hasMore
? ['more pages exist, but this host issued no cursor for them']
: []),
...formatTruncation(response.truncated)
]
return [...body, '', ...footer, ...formatDebug(response.debug)].join('\n')
}
export function formatSessionSearchResponse(response: PrintableSessionSearchResponse): string {
if (response.kind === 'unavailable') {
return formatUnavailable(response.reason)
}
if (response.kind === 'stale-cursor') {
return [
'The index moved on since that page, so the cursor no longer names a place in it.',
'Re-run the same search without --cursor to start again from page 1.'
].join('\n')
}
return formatResults(response)
}
function formatEpochMs(value: number | null): string {
return value === null ? 'never' : new Date(value).toISOString()
}
export function formatSessionSearchStatus(status: AiVaultSearchStatus): string {
return [
`enabled: ${status.enabled}`,
`phase: ${status.phase}`,
`filesIndexed: ${status.filesIndexed}`,
`filesDue: ${status.filesDue}`,
`filesFailed: ${status.filesFailed}`,
`lastReconcileAt: ${formatEpochMs(status.lastReconcileAt)}`,
`lastSweepCompletedAt: ${formatEpochMs(status.lastSweepCompletedAt)}`,
`generation: ${status.generation}`,
`degradedRoots: ${status.degradedRoots.length}`,
// A paired host withholds the root itself and sends the count with a fixed reason.
...status.degradedRoots.map(
(root) => ` ${root.root ? oneLine(root.root) : '(withheld)'}: ${oneLine(root.reason)}`
)
].join('\n')
}