Files
Arcrun/cli/src/commands/run.ts
T
uncle6me-web 5d38b599fd feat(#11): CLI/MCP 薄殼對齊 — P0 run 死端點修 + P1 list 同源 + R4 防複發機制
P0 CLI run 改打 /webhooks/named/:name/trigger 真端點(原打 /webhooks/<name> 死端點 404)。
P1 CLI/MCP list 收斂到 GET /webhooks/named(KV 同源):
   - CLI list 停 CfKvClient 直連 KV,順手修 key 前綴 bug(原讀 workflow: 對不上部署的 {apiKey}:wf:)
     + self-hosted 不再需 CF API token。
   - MCP u6u_list_workflows 從讀 KBDB record 改讀 GET /webhooks/named(registry 簽名加 partnerToken)。
R4 防複發機制:
   - cli-mcp-capability-matrix.md(13 能力對照,docs/ gitignored 不進此 commit,僅本機)
   - thin-shell-smoke.sh(對真端點斷言非 404,本機手動跑非 CI/cron)
   - 機制自驗:注入故意死端點當場攔下、exit 1。

依賴關係:本批依賴 #8(webhooks-named GET 補 description/created_at 欄位、search/backfill 端點),
故疊在 feat/issue-8 branch 上、作獨立 commit。

⚠️ tsc 綠 = code done 非完成。端到端待 leo21c(CLI/MCP 真打通 200 非 404、smoke 對已部署 prod
全綠——目前 smoke 揭 #8 search/backfill 在 prod 仍 404=未部署)。P2 validate 收斂待 #10、
tag resource_id 語意債待方向①。#11 留 open。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:36:58 +08:00

182 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* acr run <workflow_name> [--input key=value...]
*
* 玩法一(Standard / Local):
* 在本機找 <workflow_name>.yaml,解析 triplets + config
* 直接 POST /cypher/execute 給 cypher.arcrun.dev 執行。
* YAML 不存在 KV,每次執行都帶著走。
*
* 玩法二(Self-hostedworkflow 已 push 到 KV):
* POST /webhooks/<workflow_name>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<void> {
const config = loadConfig();
const executorUrl = getCypherExecutorUrl(config);
// 解析 --input key=value 為 JSON object
const inputContext: Record<string, string> = {};
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<string, string> = { '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/<name>
// 需先 push 到 KV)。導致 self-hosted 用戶(如壓測 Haiku)有本機 YAML 卻 acr run 直接打
// /webhooks/<name> → 沒 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 P0issue #11):原打 /webhooks/${name} 是死端點(404)。
// 真端點是 /webhooks/named/:name/triggerwebhooks-named.ts:279X-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 <file>.yaml 再 acr run <name>`));
} 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<typeof ora>,
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}` : ''}`);
}
}
}
}