mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
The terminal error banner and a paired host's held pane showed terminal_held_wsl_distro_*:<distro> as is; the banner now swaps either the code (older hosts) or the English sentence (newer hosts) for the localized copy, and drops the issue link for it. A held start that arrives as the sentence now gets the Start card too. The CLI names the distro for an older server's raw hold code and for an unavailable WSL folder. The banner's footer reports the shell whose spawn failed (wsl.exe, say) instead of the client's default shell.
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
|
|
import {
|
|
matchAutomationOwnerConflict,
|
|
stripAutomationOwnerConflictCode
|
|
} from '../shared/automation-owner-conflict'
|
|
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
|
|
import { worktreeSelectorRecovery } from './worktree-selector-recovery'
|
|
import { ambiguousWorkspaceChoices, selectorAmbiguityRecovery } from './selector-ambiguity-recovery'
|
|
import type { RuntimeRpcFailure } from './runtime-client'
|
|
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
|
|
import { readableWslDistroCliRefusal } from './wsl-distro-cli-refusal'
|
|
|
|
export type CliErrorContext = {
|
|
commandPath?: readonly string[]
|
|
/** The `--worktree` value this invocation sent; the runtime's error never echoes it. */
|
|
worktreeSelector?: string
|
|
/** The runtime error omits the original --repo selector. */
|
|
repoSelector?: string
|
|
/** True when --environment, --pairing-code, --host runtime:, or their env vars chose a server. */
|
|
remoteServerSelected?: boolean
|
|
/** Concise orchestration failures: compact JSON plus one `error: <code>: <message>` stderr line. */
|
|
conciseOrchestration?: boolean
|
|
}
|
|
|
|
type ContextRecovery = { nextSteps: readonly string[] } & Record<string, unknown>
|
|
|
|
const STALE_HANDLE_SERVER_HINT =
|
|
'Terminal handles belong to the Orca server that issued them. If this handle came from a paired server (for example `orca terminal list --environment <server>`), pass the same --environment <server> to this command.'
|
|
|
|
function contextRecovery(
|
|
code: string | undefined,
|
|
context: CliErrorContext
|
|
): ContextRecovery | undefined {
|
|
if (code === 'selector_not_found' && context.worktreeSelector) {
|
|
return worktreeSelectorRecovery(context.worktreeSelector)
|
|
}
|
|
if (code === 'selector_ambiguous') {
|
|
return selectorAmbiguityRecovery({
|
|
repo: context.repoSelector,
|
|
worktree: context.worktreeSelector,
|
|
remoteServerSelected: context.remoteServerSelected
|
|
})
|
|
}
|
|
// A remote handle sent locally looks stale rather than missing a flag.
|
|
if (
|
|
code === 'terminal_handle_stale' &&
|
|
context.commandPath?.[0] === 'terminal' &&
|
|
context.remoteServerSelected === false
|
|
) {
|
|
return { nextSteps: [STALE_HANDLE_SERVER_HINT] }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function errorData(error: unknown): unknown {
|
|
if (error instanceof RuntimeRpcFailureError) {
|
|
return error.response.error.data
|
|
}
|
|
return error instanceof RuntimeClientError ? error.data : undefined
|
|
}
|
|
|
|
export function cliErrorCode(error: unknown): string | undefined {
|
|
if (error instanceof RuntimeRpcFailureError) {
|
|
return error.response.error.code
|
|
}
|
|
return error instanceof RuntimeClientError ? error.code : undefined
|
|
}
|
|
|
|
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
|
|
const message = readableWslDistroCliRefusal(
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
const selector = contextRecovery(cliErrorCode(error), context)
|
|
const workspaceChoices =
|
|
error instanceof RuntimeClientError &&
|
|
!(error instanceof RuntimeRpcFailureError) &&
|
|
error.code === 'selector_ambiguous'
|
|
? ambiguousWorkspaceChoices(error.data)
|
|
: undefined
|
|
if (workspaceChoices) {
|
|
return formatMessageWithNextSteps(
|
|
`${message}\nWorkspace choices (JSON data):\n${JSON.stringify(workspaceChoices, null, 2)}`,
|
|
nextStepsFromData(errorData(error))
|
|
)
|
|
}
|
|
if (selector) {
|
|
return formatMessageWithNextSteps(
|
|
message,
|
|
nextStepsFromData(mergeContextRecovery(errorData(error), selector))
|
|
)
|
|
}
|
|
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
|
|
if (hasOrchestrationRequestId(error.data)) {
|
|
return message
|
|
}
|
|
return formatRuntimeUnavailableError(message, error.data)
|
|
}
|
|
// Match before generic recovery; flattened errors may retain only the conflict token.
|
|
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
|
|
if (conflict) {
|
|
return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps)
|
|
}
|
|
const sidecarExit = computerSidecarExitRecovery(error, context)
|
|
if (sidecarExit) {
|
|
return formatMessageWithNextSteps(message, sidecarExit.nextSteps)
|
|
}
|
|
if (error instanceof RuntimeClientError) {
|
|
const nextSteps = nextStepsFromData(error.data)
|
|
if (nextSteps.length > 0) {
|
|
return formatMessageWithNextSteps(message, nextSteps)
|
|
}
|
|
if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') {
|
|
return formatMessageWithNextSteps(
|
|
message,
|
|
computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? []
|
|
)
|
|
}
|
|
}
|
|
if (
|
|
error instanceof RuntimeRpcFailureError &&
|
|
error.response.error.code === 'runtime_unavailable'
|
|
) {
|
|
return formatRuntimeUnavailableError(message, error.response.error.data)
|
|
}
|
|
if (error instanceof RuntimeRpcFailureError) {
|
|
return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
|
|
}
|
|
return message
|
|
}
|
|
|
|
function hasOrchestrationRequestId(data: unknown): boolean {
|
|
return (
|
|
data !== null &&
|
|
typeof data === 'object' &&
|
|
typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
|
|
)
|
|
}
|
|
|
|
function formatRuntimeUnavailableError(message: string, data: unknown): string {
|
|
const nextSteps = nextStepsFromData(data)
|
|
if (nextSteps.length > 0) {
|
|
return formatMessageWithNextSteps(message, nextSteps)
|
|
}
|
|
return `${message}\nOrca is not running. Run 'orca open' first.`
|
|
}
|
|
|
|
// Streaming commands print one JSON object per line with --json, so their failure is one line too.
|
|
const JSON_LINES_COMMANDS = new Set(['plugin dev watch'])
|
|
|
|
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
|
|
const selector = contextRecovery(cliErrorCode(error), context)
|
|
const concise = context.conciseOrchestration
|
|
if (json) {
|
|
const response = cliErrorJsonResponse(error, context, selector)
|
|
console.log(
|
|
concise
|
|
? JSON.stringify({ ok: false, error: response.error })
|
|
: JSON_LINES_COMMANDS.has(context.commandPath?.join(' ') ?? '')
|
|
? JSON.stringify(response)
|
|
: JSON.stringify(response, null, 2)
|
|
)
|
|
if (concise) {
|
|
console.error(formatOrchestrationErrorLine(response.error.code, response.error.message))
|
|
}
|
|
} else if (concise) {
|
|
// Recovery detail goes to stdout so stderr stays the one-line failure.
|
|
const [first = '', ...detail] = formatCliError(error, context).split('\n')
|
|
if (detail.length > 0) {
|
|
console.log(detail.join('\n'))
|
|
}
|
|
console.error(formatOrchestrationErrorLine(cliErrorCode(error) ?? 'runtime_error', first))
|
|
} else {
|
|
console.error(formatCliError(error, context))
|
|
}
|
|
}
|
|
|
|
// Agents that read only stderr, or pipe stdout through tail, must still see the failure.
|
|
export function formatOrchestrationErrorLine(
|
|
code: string,
|
|
message: string,
|
|
level: 'error' | 'warning' = 'error'
|
|
): string {
|
|
return `${level}: ${code}: ${message.split('\n')[0]}`
|
|
}
|
|
|
|
function cliErrorJsonResponse(
|
|
error: unknown,
|
|
context: CliErrorContext,
|
|
selector: ContextRecovery | undefined
|
|
): RuntimeRpcFailure {
|
|
if (error instanceof RuntimeRpcFailureError) {
|
|
const sidecarExit = computerSidecarExitRecovery(error, context)
|
|
const response = sidecarExit
|
|
? { ...error.response, error: { ...error.response.error, data: sidecarExit } }
|
|
: withAutomationOwnerConflictRecovery(error.response)
|
|
return selector
|
|
? {
|
|
...response,
|
|
error: {
|
|
...response.error,
|
|
data: mergeContextRecovery(response.error.data, selector)
|
|
}
|
|
}
|
|
: response
|
|
}
|
|
return {
|
|
id: 'local',
|
|
ok: false,
|
|
error: {
|
|
code:
|
|
matchAutomationOwnerConflict(error) ??
|
|
(error instanceof RuntimeClientError ? error.code : 'runtime_error'),
|
|
message: stripAutomationOwnerConflictCode(
|
|
error instanceof Error ? error.message : String(error)
|
|
),
|
|
data: localCliErrorData(error, context)
|
|
},
|
|
_meta: {
|
|
runtimeId: null
|
|
}
|
|
}
|
|
}
|
|
|
|
function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure {
|
|
const code = matchAutomationOwnerConflict(response)
|
|
const conflict = automationOwnerConflictRecovery(code)
|
|
if (!conflict || !code) {
|
|
return response
|
|
}
|
|
return {
|
|
...response,
|
|
error: {
|
|
...response.error,
|
|
// Restore conflict classification lost during transport flattening.
|
|
code,
|
|
message: stripAutomationOwnerConflictCode(response.error.message),
|
|
data: response.error.data ?? conflict
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string {
|
|
if (nextSteps.length === 0) {
|
|
return message
|
|
}
|
|
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
|
|
}
|
|
|
|
// Preserve both mutation request IDs and selector recovery hints.
|
|
function mergeContextRecovery(
|
|
data: unknown,
|
|
selector: ReturnType<typeof contextRecovery>
|
|
): unknown {
|
|
if (!selector) {
|
|
return data
|
|
}
|
|
if (
|
|
data &&
|
|
typeof data === 'object' &&
|
|
'validSelectorForms' in data &&
|
|
'nextSteps' in data &&
|
|
Array.isArray(data.validSelectorForms) &&
|
|
data.validSelectorForms.every((form: unknown) => typeof form === 'string') &&
|
|
Array.isArray(data.nextSteps) &&
|
|
data.nextSteps.every((step: unknown) => typeof step === 'string')
|
|
) {
|
|
return data
|
|
}
|
|
if (data === null || typeof data !== 'object') {
|
|
return selector
|
|
}
|
|
return {
|
|
...selector,
|
|
...data,
|
|
nextSteps: [...selector.nextSteps, ...nextStepsFromData(data)]
|
|
}
|
|
}
|
|
|
|
function nextStepsFromData(data: unknown): string[] {
|
|
if (
|
|
data &&
|
|
typeof data === 'object' &&
|
|
Array.isArray((data as { nextSteps?: unknown }).nextSteps)
|
|
) {
|
|
return (data as { nextSteps: unknown[] }).nextSteps.filter(
|
|
(step): step is string => typeof step === 'string'
|
|
)
|
|
}
|
|
return []
|
|
}
|
|
|
|
// Older servers do not include this recovery hint in error data.
|
|
function computerSidecarExitRecovery(error: unknown, context: CliErrorContext) {
|
|
if (
|
|
context.commandPath?.[0] !== 'computer' ||
|
|
cliErrorCode(error) !== 'accessibility_error' ||
|
|
!(error instanceof Error) ||
|
|
!error.message.startsWith('computer sidecar exited with code')
|
|
) {
|
|
return undefined
|
|
}
|
|
return computerUseErrorRecoveryData('accessibility_error', error.message)
|
|
}
|
|
|
|
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
|
|
const sidecarExit = computerSidecarExitRecovery(error, context)
|
|
if (sidecarExit) {
|
|
return sidecarExit
|
|
}
|
|
const selector = contextRecovery(cliErrorCode(error), context)
|
|
// Error-specific recovery takes precedence over the generic fallback.
|
|
if (error instanceof RuntimeClientError && error.data !== undefined) {
|
|
return mergeContextRecovery(error.data, selector)
|
|
}
|
|
if (selector) {
|
|
return selector
|
|
}
|
|
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
|
|
if (conflict) {
|
|
return conflict
|
|
}
|
|
if (
|
|
error instanceof RuntimeClientError &&
|
|
error.code === 'invalid_argument' &&
|
|
context.commandPath?.[0] === 'computer'
|
|
) {
|
|
return computerUseErrorRecoveryData('invalid_argument')
|
|
}
|
|
return undefined
|
|
}
|