diff --git a/config/scripts/benchmark-cli-error-imports.mjs b/config/scripts/benchmark-cli-error-imports.mjs new file mode 100644 index 00000000000..a4648f84aec --- /dev/null +++ b/config/scripts/benchmark-cli-error-imports.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { existsSync, realpathSync } from 'node:fs' +import { delimiter, join, resolve } from 'node:path' + +// Emit each revision with tsc -p config/tsconfig.cli.json --outDir --composite false --incremental false. +// Run: node config/scripts/benchmark-cli-error-imports.mjs +const [beforeDir, afterDir] = process.argv.slice(2) +assert.ok(beforeDir && afterDir, 'Pass distinct before and after TypeScript output directories.') +assert.notEqual( + realpathSync(beforeDir), + realpathSync(afterDir), + 'Do not compare a build to itself.' +) +const entries = { + before: join(resolve(beforeDir), 'cli', 'index.js'), + after: join(resolve(afterDir), 'cli', 'index.js') +} +for (const entry of Object.values(entries)) { + assert.ok(existsSync(entry), `Missing emitted CLI: ${entry}`) +} + +const { runProcessSync } = createRequire(import.meta.url)( + join(resolve(afterDir), 'shared', 'child-process', 'run-process.js') +) + +const child = String.raw` + const { performance } = require('node:perf_hooks') + const { writeSync } = require('node:fs') + const { createHash } = require('node:crypto') + const { basename } = require('node:path') + let stdout = '', stderr = '' + process.stdout.write = (text) => { stdout += text; return true } + process.stderr.write = (text) => { stderr += text; return true } + const started = performance.now() + const cli = require(process.argv[1]) + const importMs = performance.now() - started + cli.main(JSON.parse(process.argv[2])).then(() => { + const totalMs = performance.now() - started + const modules = Object.keys(require.cache) + writeSync(1, JSON.stringify({ + importMs, totalMs, modules: modules.length, + featureFormatters: modules.filter((file) => ['browser', 'terminal', 'project', 'automation', 'workspace', 'computer'].some((name) => basename(file) === name + '-format.js')), + stdout: createHash('sha256').update(stdout).digest('hex'), + stderr: createHash('sha256').update(stderr).digest('hex'), + exitCode: process.exitCode || 0 + })) + process.exitCode = 0 + }).catch((error) => { writeSync(2, String(error)); process.exitCode = 1 }) +` +const cases = [ + ['--help'], + ['help', 'terminal', 'read'], + ['does-not-exist'], + ['computer', 'click', '--does-not-exist'], + ['does-not-exist', '--json'] +] +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] +const summarize = (samples) => ({ + importMs: median(samples.map((sample) => sample.importMs)), + totalMs: median(samples.map((sample) => sample.totalMs)), + modules: samples[0].modules +}) +const rows = [] +for (const args of cases) { + const samples = { before: [], after: [] } + let expected + for (let run = 0; run < 22; run++) { + for (const variant of run % 2 ? ['after', 'before'] : ['before', 'after']) { + const result = runProcessSync({ + program: process.execPath, + args: ['-e', child, entries[variant], JSON.stringify(args)], + timeoutMs: 30_000, + env: { + ...process.env, + NODE_PATH: [resolve('node_modules'), process.env.NODE_PATH] + .filter(Boolean) + .join(delimiter) + } + }) + assert.equal(result.timedOut, false, 'CLI child timed out.') + assert.equal(result.code, 0, result.stderr) + const sample = JSON.parse(result.stdout) + const output = { stdout: sample.stdout, stderr: sample.stderr, exitCode: sample.exitCode } + expected ??= output + assert.deepEqual(output, expected, `${variant} output changed for ${args.join(' ')}`) + if (variant === 'after') { + assert.deepEqual( + sample.featureFormatters, + [], + 'Help and syntax errors must skip feature formatters.' + ) + } + if (run >= 2) { + samples[variant].push(sample) + } + } + } + assert.ok(samples.after[0].modules < samples.before[0].modules, 'Expected fewer loaded modules.') + rows.push({ + args, + before: summarize(samples.before), + after: summarize(samples.after), + output: expected, + samples + }) +} +console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + measurement: + 'Fresh-process import + main; excludes process creation; warmed filesystem; 2 warmups and 20 samples per variant, alternating order.', + entries, + rows + }, + null, + 2 + ) +) diff --git a/config/scripts/cli-runtime-client-deferral-equivalence.mjs b/config/scripts/cli-runtime-client-deferral-equivalence.mjs index f443bf3b9f9..a231b5ba75a 100644 --- a/config/scripts/cli-runtime-client-deferral-equivalence.mjs +++ b/config/scripts/cli-runtime-client-deferral-equivalence.mjs @@ -2,7 +2,7 @@ // Equivalence check for deferring the RuntimeClient module graph in the CLI. // // Builds the CLI twice with the REAL tsc emit — once from the working tree and -// once with the seven touched files restored from git HEAD~ (the pre-deferral +// once with the touched files restored from git HEAD~ (the pre-deferral // implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE // across a matrix of invocations. // @@ -13,7 +13,7 @@ // // Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline ] import { execFileSync, spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -21,8 +21,11 @@ const REPO = fileURLToPath(new URL('../..', import.meta.url)) // The files this change touches. Restoring exactly these from the baseline rev // reconstructs the old implementation without disturbing anything else. +// Files absent at the baseline (e.g. cli-error.ts, split out of format.ts +// later) are removed for the baseline build and put back afterwards. const TOUCHED = [ 'src/cli/args.ts', + 'src/cli/cli-error.ts', 'src/cli/dispatch.ts', 'src/cli/flags.ts', 'src/cli/format.ts', @@ -72,12 +75,16 @@ function buildTree(label, baselineRev) { if (baselineRev) { for (const file of TOUCHED) { const path = join(REPO, file) - restored.push([path, readFileSync(path)]) - const old = execFileSync('git', ['show', `${baselineRev}:${file}`], { + restored.push([path, existsSync(path) ? readFileSync(path) : null]) + const old = spawnSync('git', ['show', `${baselineRev}:${file}`], { cwd: REPO, maxBuffer: 64 * 1024 * 1024 }) - writeFileSync(path, old) + if (old.status === 0) { + writeFileSync(path, old.stdout) + } else { + rmSync(path, { force: true }) + } } } execFileSync( @@ -97,7 +104,11 @@ function buildTree(label, baselineRev) { ) } finally { for (const [path, contents] of restored) { - writeFileSync(path, contents) + if (contents === null) { + rmSync(path, { force: true }) + } else { + writeFileSync(path, contents) + } } } return join(outDir, 'cli/index.js') diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts new file mode 100644 index 00000000000..6a87f149079 --- /dev/null +++ b/src/cli/cli-error.ts @@ -0,0 +1,144 @@ +import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery' +import { + matchAutomationOwnerConflict, + stripAutomationOwnerConflictCode +} from '../shared/automation-owner-conflict' +import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery' +import type { RuntimeRpcFailure } from './runtime-client' +import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' + +type CliErrorContext = { + commandPath?: readonly string[] +} + +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 +} diff --git a/src/cli/format.ts b/src/cli/format.ts index 1487a69eea0..dd6b7b739c7 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,13 +1,8 @@ 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' +import type { RuntimeRpcSuccess } from './runtime-client' + +export { formatCliError, reportCliError } from './cli-error' export { formatBrowserProfileList, @@ -67,10 +62,6 @@ export { formatWorktreeShow } from './workspace-format' -type CliErrorContext = { - commandPath?: readonly string[] -} - export function printResult( response: RuntimeRpcSuccess, json: boolean, @@ -83,138 +74,6 @@ export function printResult( 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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 9389113b195..a0e1354307f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,7 @@ import { resolveHostFlagEnvironmentId } from './execution-host-flag' import { listSshTargets } from './host-selector-alternatives' -import { reportCliError } from './format' +import { reportCliError } from './cli-error' import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index 658cc60f0a4..5fcdf8b9686 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -84,14 +84,12 @@ describe('RuntimeClient module-graph deferral', () => { process.exitCode = 0 }) - // Why: the whole point of the change. These six modules load on EVERY - // invocation, so a value-import of the barrel from any of them drags the - // RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path. + // These eager modules must not pull the RuntimeClient dependency graph into help. it.each([ 'args.ts', 'flags.ts', 'dispatch.ts', - 'format.ts', + 'cli-error.ts', 'selectors.ts', 'execution-host-flag.ts' ])('%s imports error classes from ./runtime/types, not the barrel', (file) => { @@ -110,6 +108,7 @@ describe('RuntimeClient module-graph deferral', () => { expect(source).toContain("import type { RuntimeClient } from './runtime-client'") expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m) expect(source).toContain("await import('./runtime-client.js')") + expect(source).toContain("import { reportCliError } from './cli-error'") }) it('constructs no client for --help', async () => {