Files
orca/src/cli/format.ts
T
Brennan BensonandMerge Sim aabcc57366 fix(runtime): publish remote control outages to host surfaces (#17531)
* fix(runtime): publish remote control diagnostics to renderer

* test(runtime): account for diagnostics bridge listener

* fix(i18n): add runtime connection state labels

* test(runtime): clean up shared control connection

* fix(runtime): fence diagnostics by shared-control capability

* fix(runtime): preserve authoritative transport state

* fix(runtime): preserve diagnostic overlay lifecycle

* fix(runtime): avoid publishing unchanged diagnostics state

---------

Co-authored-by: Merge Sim <sim@local>
2026-08-31 12:25:17 -07:00

257 lines
8.1 KiB
TypeScript

import type { CliStatusResult } from '../shared/runtime-types'
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
import {
matchAutomationOwnerConflict,
stripAutomationOwnerConflictCode
} from '../shared/automation-owner-conflict'
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
import { prepareComputerCliJsonResult } from './computer-format'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
export {
formatBrowserProfileList,
formatScreenshot,
formatSnapshot,
formatTabList,
formatTabListWithProfiles,
formatTabProfileClone,
formatTabProfileShow,
formatTabShow
} from './browser-format'
export {
formatComputerAction,
formatGetAppState,
formatListApps,
formatListWindows
} from './computer-format'
export type { ComputerActionFollowUpTarget } from './computer-format'
export {
formatProjectHostSetupCreateResult,
formatProjectHostSetupDeleteResult,
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectHostSetupUpdateResult,
formatProjectList
} from './project-format'
export {
formatTerminalClose,
formatTerminalCreate,
formatTerminalFocus,
formatTerminalList,
formatTerminalRead,
formatTerminalRename,
formatTerminalSend,
formatTerminalShow,
formatTerminalSplit,
formatTerminalWait
} from './terminal-format'
export {
formatAutomationList,
formatAutomationRemoved,
formatAutomationRun,
formatAutomationRuns,
formatAutomationShow
} from './automation-format'
export type { AutomationListPayload, AutomationShowPayload } from './automation-format'
export {
formatEnvironment,
formatEnvironmentList,
formatMemorySnapshot,
formatRepoList,
formatRepoRefs,
formatRepoShow,
formatWorktreeList,
formatWorktreePs,
formatWorktreeShow
} from './workspace-format'
type CliErrorContext = {
commandPath?: readonly string[]
}
export function printResult<TResult>(
response: RuntimeRpcSuccess<TResult>,
json: boolean,
formatter: (value: TResult) => string
): void {
if (json) {
console.log(JSON.stringify(prepareComputerCliJsonResult(response), null, 2))
return
}
console.log(formatter(response.result))
}
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
if (hasOrchestrationRequestId(error.data)) {
return message
}
return `${message}\nOrca is not running. Run 'orca open' first.`
}
// Why: error-specific recovery must win over the generic computer fallback.
// Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token.
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
if (conflict) {
return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.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 `${message}\nOrca is not running. Run 'orca open' first.`
}
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'
)
}
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
} else {
const response: RuntimeRpcFailure = {
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
}
}
console.log(JSON.stringify(response, null, 2))
}
} else {
console.error(formatCliError(error, context))
}
}
/** Machine-readable half of the same recovery the human message carries. */
function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure {
const code = matchAutomationOwnerConflict(response)
const conflict = automationOwnerConflictRecovery(code)
if (!conflict || !code) {
return response
}
return {
...response,
error: {
...response.error,
// Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport.
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')}`
}
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 []
}
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
// Why: error-specific recovery must win over the generic computer fallback.
if (error instanceof RuntimeClientError && error.data !== undefined) {
return error.data
}
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
}
export type HostListEntry = {
kind: 'local' | 'ssh' | 'environment'
name: string
id: string
selector: string
}
// Why: the selector column is the point of this command — the name alone is what callers already
// had, and passing it on the wrong axis is the mistake this output exists to prevent.
export function formatHostList(result: { hosts: HostListEntry[] }): string {
const kindLabel: Record<HostListEntry['kind'], string> = {
local: 'local',
ssh: 'ssh target',
environment: 'orca server'
}
return result.hosts
.map((host) => `${kindLabel[host.kind].padEnd(11)} ${host.name} -> ${host.selector}`)
.join('\n')
}
export function formatCliStatus(status: CliStatusResult): string {
return [
...(status.target && status.target.kind === 'environment'
? [`target: environment ${status.target.environment}`]
: []),
`appRunning: ${status.app.running}`,
`pid: ${status.app.pid ?? 'none'}`,
`desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`,
`runtimeState: ${status.runtime.state}`,
`runtimeReachable: ${status.runtime.reachable}`,
`runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`,
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
`graphState: ${status.graph.state}`
].join('\n')
}
export function formatStatus(status: CliStatusResult): string {
return formatCliStatus(status)
}