mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
* 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>
257 lines
8.1 KiB
TypeScript
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)
|
|
}
|