/** * acr workflow export / acr workflow import — workflow 可攜原語(t158)。 * * leo 07-31 定調:「你要做的就是一個叫 export,另一個是 import,打包好的幾個工作流 * 準備好直接 import 就好了。現在如果我要把我做的工作流分享給同事,我要怎麼 export? * 他要如何 import?是缺了功能用 search 來湊嗎?在從前就是寫成幾個 yaml 丟過去 * 讓新的送進 KBDB 不是嗎?」 * * - export:GET /webhooks/named/:name/definition → 寫成 .workflow.yaml 可攜檔 * (name/description/flow[從 graph.edges 反推,供人讀]/config/graph[可執行形,引擎產])。 * - import:讀可攜檔 → **直接 POST /webhooks/named**。零編圖、零 /cypher/search、 * 零存在性驗證(部署≠發現,V2 純複製)——缺件的 workflow 照樣進,跑錯再改。 * 手寫的 yaml(無 graph 欄)請走 acr push(那條才需要編圖)。 * - 安裝器走同一條路:workflows.json 打包期預編 graph,pushWorkflow 直接 POST—— * 不准安裝器走私有路徑。 */ import chalk from 'chalk'; import ora from 'ora'; import yaml from 'js-yaml'; import { readFileSync, writeFileSync } from 'node:fs'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; type GraphShape = { nodes?: Array<{ id?: string }>; edges?: Array<{ from?: string; to?: string; type?: string }>; }; /** graph.edges → flow 三元組(人讀用;graph 才是可執行真相)。 */ function flowFromGraph(graph: GraphShape): string[] { return (graph.edges ?? []) .filter(e => e.from && e.to) .map(e => `${e.from} >> ${e.type ?? 'ON_SUCCESS'} >> ${e.to}`); } function requireStandardConfig(): { executorUrl: string; apiKey: string } { const config = loadConfig(); if (config.mode === 'local') { console.error(chalk.red('Local 模式不支援 workflow export/import(需要連上實例)。')); process.exit(1); } if (!config.api_key) { console.error(chalk.red('缺少 api_key/NAMESPACE,請先 acr init。')); process.exit(1); } return { executorUrl: getCypherExecutorUrl(config), apiKey: config.api_key }; } export async function cmdWorkflowExport(name: string, options: { output?: string }): Promise { const { executorUrl, apiKey } = requireStandardConfig(); const spinner = ora(`從 ${executorUrl} 匯出 "${name}"`).start(); try { const res = await fetch(`${executorUrl}/webhooks/named/${encodeURIComponent(name)}/definition`, { headers: { 'X-Arcrun-API-Key': apiKey }, }); if (!res.ok) { const err = await res.text(); spinner.fail(chalk.red(`匯出失敗(${res.status}):${err.slice(0, 200)}`)); process.exit(1); } const def = await res.json() as { name: string; description: string; graph: GraphShape; config: Record; }; const out = options.output ?? `${def.name}.workflow.yaml`; const doc = { name: def.name, description: def.description, // flow=從 graph 反推的可讀視圖;import 用的是 graph(可執行真相) flow: flowFromGraph(def.graph), config: def.config ?? {}, graph: def.graph, }; writeFileSync(out, yaml.dump(doc, { lineWidth: 120, noRefs: true }), 'utf8'); spinner.succeed(chalk.green(`✓ 已匯出 → ${out}`)); console.log(chalk.gray(` 給同事:把這個檔傳過去,對方 acr workflow import ${out} 即可。`)); } catch (e) { spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); process.exit(1); } } export async function cmdWorkflowImport(filePath: string): Promise { const { executorUrl, apiKey } = requireStandardConfig(); let doc: { name?: string; description?: string; config?: Record; graph?: GraphShape }; try { doc = yaml.load(readFileSync(filePath, 'utf8')) as typeof doc; } catch (e) { console.error(chalk.red(`讀不了 ${filePath}:${e instanceof Error ? e.message : e}`)); process.exit(1); } if (!doc?.name) { console.error(chalk.red('檔案缺 name 欄位。')); process.exit(1); } if (!doc.graph || !Array.isArray(doc.graph.nodes)) { // 手寫 yaml(只有 flow 沒 graph)=acr push 的場景(那條會編圖)。import 專吃 export 檔。 console.error(chalk.red('這個檔沒有 graph 欄位(不是 export 產物)。')); console.log(chalk.gray('手寫的 workflow.yaml 請改用:acr push ' + filePath)); process.exit(1); } const spinner = ora(`匯入 "${doc.name}" → ${executorUrl}`).start(); try { // 純複製:graph 直接送,不編圖、不打 /cypher/search、不驗零件存在(跑錯再改)。 const res = await fetch(`${executorUrl}/webhooks/named`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey }, body: JSON.stringify({ name: doc.name, graph: { ...doc.graph, id: doc.name, name: doc.name }, config: doc.config ?? {}, description: doc.description ?? '', }), }); if (!res.ok) { const err = await res.text(); spinner.fail(chalk.red(`匯入失敗(${res.status}):${err.slice(0, 200)}`)); process.exit(1); } const data = await res.json() as { webhook_url?: string }; spinner.succeed(chalk.green(`✓ "${doc.name}" 已匯入`)); if (data.webhook_url) console.log(chalk.bold(` Webhook URL:${chalk.cyan(data.webhook_url)}`)); console.log(chalk.gray(' 沒驗零件存在——跑起來若報「找不到零件」,補上零件/recipe 或改 config 再跑。')); } catch (e) { spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`)); process.exit(1); } }