5d38b599fd
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>
182 lines
7.2 KiB
TypeScript
182 lines
7.2 KiB
TypeScript
/**
|
||
* acr run <workflow_name> [--input key=value...]
|
||
*
|
||
* 玩法一(Standard / Local):
|
||
* 在本機找 <workflow_name>.yaml,解析 triplets + config,
|
||
* 直接 POST /cypher/execute 給 cypher.arcrun.dev 執行。
|
||
* YAML 不存在 KV,每次執行都帶著走。
|
||
*
|
||
* 玩法二(Self-hosted,workflow 已 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 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 <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}` : ''}`);
|
||
}
|
||
}
|
||
}
|
||
}
|