From 9c9aff0046ca69e1d17f62ab0202c73a3c487bd9 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 13:49:23 +0800 Subject: [PATCH] =?UTF-8?q?t158=20P1=20=E7=B4=A0=E6=9D=90=EF=BC=9Aworkflow?= =?UTF-8?q?=20export/import=20=E5=8E=9F=E8=AA=9E=EF=BC=88definition=20?= =?UTF-8?q?=E7=AB=AF=E9=BB=9E=EF=BC=8BCLI=20=E5=91=BD=E4=BB=A4=EF=BC=8C?= =?UTF-8?q?=E6=9C=AA=E6=8E=A5=E7=B7=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-31:「你要做的就是一個叫 export,另一個是 import……現在如果我要把我做的 工作流分享給同事,我要怎麼 export?他要如何 import?是缺了功能用 search 來湊嗎?」 - cypher GET /webhooks/named/:name/definition:吐可攜定義(graph+config+description =record 原樣)——export 的引擎端;import 端直接 POST /webhooks/named 即送進任何實例 - cli/commands/workflow.ts:acr workflow export (definition→.workflow.yaml, flow 從 graph.edges 反推供人讀)/import (graph 直 POST,零編圖零 search 零存在性驗證=V2 純複製;手寫 yaml 無 graph → 指去 acr push) - ⚠️ 未接線:index.ts 尚未掛 workflow 命令組(P1 收尾:接線+分享場景 A export→ B import 驗收+parser 共用模組化) 安裝器側 P0(rag-installer 5a6539d)已用同形狀(打包期預編 graph+純上傳)上 prod。 Co-Authored-By: Claude Fable 5 --- cli/src/commands/workflow.ts | 128 +++++++++++++++++++ cypher-executor/src/routes/webhooks-named.ts | 22 ++++ 2 files changed, 150 insertions(+) create mode 100644 cli/src/commands/workflow.ts diff --git a/cli/src/commands/workflow.ts b/cli/src/commands/workflow.ts new file mode 100644 index 0000000..303f268 --- /dev/null +++ b/cli/src/commands/workflow.ts @@ -0,0 +1,128 @@ +/** + * 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); + } +} diff --git a/cypher-executor/src/routes/webhooks-named.ts b/cypher-executor/src/routes/webhooks-named.ts index c8af7e4..3d15b5c 100644 --- a/cypher-executor/src/routes/webhooks-named.ts +++ b/cypher-executor/src/routes/webhooks-named.ts @@ -471,6 +471,28 @@ webhooksNamedRouter.get('/q/:ns/:name', async (c) => { return queryNamed(c, c.req.param('ns'), c.req.param('name'), queryStringContext(c)); }); +// GET /webhooks/named/:name/definition — 吐 workflow 的可攜定義(t158 export 原語)。 +// leo 07-31:「如果我要把我做的工作流分享給同事,我要怎麼 export?他要如何 import? +// 在從前就是寫成幾個 yaml 丟過去讓新的送進 KBDB 不是嗎?」 +// 回 record 原樣(graph+config+description)=import 端可直接 POST /webhooks/named 送進 +// 任何實例(acr workflow import/安裝器同一條路)。執行語義不驗證(部署≠發現)。 +webhooksNamedRouter.get('/webhooks/named/:name/definition', async (c) => { + const apiKey = c.req.header('X-Arcrun-API-Key'); + if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); + const name = c.req.param('name'); + const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text'); + if (!raw) return c.json({ error: `找不到 workflow "${name}"` }, 404); + const rec = JSON.parse(raw) as NamedWorkflowRecord; + return c.json({ + name: rec.name, + description: rec.description ?? '', + graph: rec.graph, + config: rec.config ?? {}, + created_at: rec.created_at ?? '', + ...(rec.cron_expr ? { cron_expr: rec.cron_expr } : {}), + }); +}); + // GET /webhooks/named — 列出當前 api_key 下所有 workflow webhooksNamedRouter.get('/webhooks/named', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key');