/** * acr run [--input key=value...] * * 玩法一(Standard / Local): * 在本機找 .yaml,解析 triplets + config, * 直接 POST /cypher/execute 給 cypher.arcrun.dev 執行。 * YAML 不存在 KV,每次執行都帶著走。 * * 玩法二(Self-hosted,workflow 已 push 到 KV): * POST /webhooks/,executor 從 WEBHOOKS KV 讀取定義執行。 */ import chalk from 'chalk'; import ora from 'ora'; import { existsSync, readFileSync } from 'node:fs'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; import { loadWorkflowYaml, parseTriplets } from '../lib/yaml-parser.js'; interface RunOptions { input?: string[]; } export async function cmdRun(workflowName: string, options: RunOptions): Promise { const config = loadConfig(); const executorUrl = getCypherExecutorUrl(config); // 解析 --input key=value 為 JSON object const inputContext: Record = {}; for (const pair of (options.input ?? [])) { const idx = pair.indexOf('='); if (idx < 0) { console.error(chalk.red(`--input 格式錯誤:${pair}(應為 key=value)`)); process.exit(1); } inputContext[pair.slice(0, idx)] = pair.slice(idx + 1); } const headers: Record = { 'Content-Type': 'application/json' }; if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key; // ── 玩法一:本機有 YAML → 直接帶著打 /cypher/execute(不需先 push)────────────── // 2026-06-09 修:原本只有 standard/local 走這條,self-hosted 一律走玩法二(/webhooks/, // 需先 push 到 KV)。導致 self-hosted 用戶(如壓測 Haiku)有本機 YAML 卻 acr run 直接打 // /webhooks/ → 沒 push = 404 純文字 → res.json() 爆「Unexpected non-whitespace...」假錯誤。 // 正解:只要本機找得到 YAML 就走玩法一直接執行(三模式一致);找不到才退玩法二(按名字打已 push 的)。 const localYamlPath = findWorkflowYaml(workflowName); if (localYamlPath) { const yamlPath = localYamlPath; let workflow; try { workflow = loadWorkflowYaml(yamlPath); } catch (e) { console.error(chalk.red(`YAML 解析失敗:${e instanceof Error ? e.message : e}`)); process.exit(1); } const triplets = parseTriplets(workflow.flow); const spinner = ora(`執行 workflow "${workflow.name}"`).start(); try { const res = await fetch(`${executorUrl}/cypher/execute`, { method: 'POST', headers, body: JSON.stringify({ triplets: workflow.flow, config: workflow.config ?? {}, // node_name → {component, ...params} context: inputContext, // --input key=value 傳入的動態參數 graph_id: workflow.name, graph_name: workflow.name, }), }); // 非 2xx 先擋:直接 res.json() 對 404「Not Found」這種純文字會爆出誤導的 // 「Unexpected non-whitespace character after JSON」。看 res.ok 給人話。 if (!res.ok) { const body = await res.text(); spinner.fail(chalk.red(`執行失敗(HTTP ${res.status}):${body.slice(0, 200)}`)); process.exit(1); } const data = await res.json() as { success: boolean; data?: unknown; error?: string; trace?: Array<{ node: string; status: string; error?: string }>; duration_ms: number; failed_node?: string; }; printResult(spinner, data, triplets.length); } catch (e) { spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); process.exit(1); } return; } // ── 玩法二:本機沒這個 YAML → 按名字打已 push 到 KV 的 workflow ─────────────── // thin-shell-alignment P0(issue #11):原打 /webhooks/${name} 是死端點(404)。 // 真端點是 /webhooks/named/:name/trigger(webhooks-named.ts:279,X-Arcrun-API-Key header)。 const spinner = ora(`執行 workflow "${workflowName}"`).start(); try { const res = await fetch(`${executorUrl}/webhooks/named/${workflowName}/trigger`, { method: 'POST', headers, body: JSON.stringify(inputContext), }); // 非 2xx 先擋(同玩法一):404 純文字別硬 res.json()。404 多半是「還沒 push」。 if (!res.ok) { if (res.status === 404) { spinner.fail(chalk.red(`找不到已部署的 workflow "${workflowName}"`)); console.error(chalk.gray(` 本機也沒有 ${workflowName}.yaml。請確認:`)); console.error(chalk.gray(` ① 本機有 YAML → 在該檔所在目錄跑 acr run(會直接執行,不需先 push)`)); console.error(chalk.gray(` ② 要跑已部署的 → 先 acr push .yaml 再 acr run `)); } else { const body = await res.text(); spinner.fail(chalk.red(`執行失敗(HTTP ${res.status}):${body.slice(0, 200)}`)); } process.exit(1); } const data = await res.json() as { success: boolean; data?: unknown; error?: string; trace?: Array<{ node: string; status: string; error?: string }>; duration_ms: number; failed_node?: string; }; printResult(spinner, data, 0); } catch (e) { spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); process.exit(1); } } // ── helpers ────────────────────────────────────────────────────────────────── function findWorkflowYaml(name: string): string | null { // 容忍使用者直接給含副檔名的檔名(acr run foo.yaml)——剝掉再補,避免找成 foo.yaml.yaml。 const base = name.replace(/\.(ya?ml)$/i, ''); const candidates = [ name, // 原樣(已含副檔名或本就是路徑) `${base}.yaml`, `${base}.yml`, `workflows/${base}.yaml`, `workflows/${base}.yml`, ]; for (const p of candidates) { if (existsSync(p)) return p; } return null; } function printResult( spinner: ReturnType, data: { success: boolean; data?: unknown; error?: string; trace?: Array<{ node: string; status: string; error?: string }>; duration_ms: number; failed_node?: string }, _nodeCount: number, ): void { if (data.success) { spinner.succeed(chalk.green(`✓ 執行成功(${data.duration_ms}ms)`)); if (data.data !== undefined && data.data !== null) { console.log('\n 結果:'); console.log(JSON.stringify(data.data, null, 2).split('\n').map(l => ` ${l}`).join('\n')); } } else { spinner.fail(chalk.red(`✗ 執行失敗(${data.duration_ms}ms)`)); if (data.failed_node) console.log(chalk.red(`\n 失敗節點:${data.failed_node}`)); if (data.error) console.log(chalk.red(` 錯誤:${data.error}`)); if (data.trace) { console.log('\n 執行追蹤:'); for (const step of data.trace) { const icon = step.status === 'failed' ? chalk.red('✗') : chalk.green('✓'); console.log(` ${icon} ${step.node}${step.error ? ` — ${step.error}` : ''}`); } } } }