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');