mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* 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.
149 lines
5.4 KiB
TypeScript
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')
|
|
}
|